1use anyhow::{bail, ensure, Context, Result};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::{BTreeMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use crate::config::{Config, ResolvedConfig};
11use crate::ctx_dirs::{self, ConfigSourceKind};
12use crate::profiles::TomlProfile;
13
14#[derive(Debug, Clone, Default, Deserialize, Serialize)]
16#[serde(deny_unknown_fields)]
17pub struct ModelDefinition {
18 pub provider: String,
19 pub model: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub api_key_env: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub base_url: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub timeout_seconds: Option<u64>,
26}
27
28impl ModelDefinition {
29 pub fn validate(&self) -> Result<()> {
30 ensure!(
31 !self.provider.trim().is_empty(),
32 "model provider is required"
33 );
34 ensure!(
35 !self.model.trim().is_empty(),
36 "provider model name is required"
37 );
38 if let Some(env) = &self.api_key_env {
39 let mut chars = env.chars();
40 ensure!(
41 chars
42 .next()
43 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
44 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'),
45 "api_key_env must be an environment variable name"
46 );
47 }
48 Ok(())
49 }
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
53#[serde(deny_unknown_fields)]
54pub struct AgentResource {
55 pub agent: AgentSettings,
56 pub prompt: PromptSettings,
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
60#[serde(deny_unknown_fields)]
61pub struct AgentSettings {
62 pub name: String,
63 #[serde(default)]
64 pub description: String,
65 pub model: String,
66 #[serde(default)]
67 pub tools: Vec<String>,
68 #[serde(default, rename = "override")]
70 pub override_existing: bool,
71 #[serde(default)]
72 pub execution: ExecutionLimits,
73 #[serde(default)]
74 pub permissions: Permissions,
75 #[serde(default, skip_serializing_if = "DelegationSettings::is_empty")]
76 pub delegation: DelegationSettings,
77}
78
79#[derive(Debug, Default, Clone, Deserialize, Serialize)]
82#[serde(default, deny_unknown_fields)]
83pub struct DelegationSettings {
84 pub allow: Vec<String>,
85}
86
87impl DelegationSettings {
88 fn is_empty(&self) -> bool {
89 self.allow.is_empty()
90 }
91
92 fn validate(&self, agent_name: &str, tools: &[String]) -> Result<()> {
93 ensure!(
94 self.allow.len() <= 32,
95 "delegation allows at most 32 agents"
96 );
97 ensure!(
98 self.allow.iter().all(|name| identifier(name)),
99 "invalid delegation agent name"
100 );
101 ensure!(
102 self.allow.iter().collect::<HashSet<_>>().len() == self.allow.len(),
103 "duplicate delegation agent name"
104 );
105 ensure!(
106 !self.allow.iter().any(|name| name == agent_name),
107 "an agent cannot delegate to itself"
108 );
109 ensure!(
110 tools.iter().any(|tool| tool == "agent.invoke") == !self.is_empty(),
111 "agent.invoke and a nonempty delegation.allow must be declared together"
112 );
113 Ok(())
114 }
115}
116
117#[derive(Debug, Clone, Deserialize, Serialize)]
118#[serde(deny_unknown_fields)]
119pub struct PromptSettings {
120 pub system: String,
121}
122
123#[derive(Debug, Clone, Deserialize, Serialize)]
124#[serde(default, deny_unknown_fields)]
125pub struct ExecutionLimits {
126 pub max_turns: u32,
127 pub timeout_seconds: u64,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub max_total_tokens: Option<u64>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub max_tool_calls: Option<u64>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub max_context_bytes: Option<u64>,
134}
135
136impl Default for ExecutionLimits {
137 fn default() -> Self {
138 Self {
139 max_turns: 12,
140 timeout_seconds: 300,
141 max_total_tokens: None,
142 max_tool_calls: None,
143 max_context_bytes: None,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
149#[serde(rename_all = "snake_case")]
150pub enum Capability {
151 ReadOnly,
152 AgentDelegate,
153 WorkspaceWrite,
154 ProcessExecute,
155 Network,
156 ExternalSideEffect,
157}
158
159#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
160#[serde(rename_all = "kebab-case")]
161pub enum PermissionMode {
162 ReadOnly,
163}
164
165#[derive(Debug, Default, Clone, Deserialize, Serialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct Permissions {
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub mode: Option<PermissionMode>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub allow: Option<Vec<Capability>>,
174 pub require_approval: Vec<Capability>,
175}
176
177impl Permissions {
178 pub fn allowed(&self) -> Vec<Capability> {
179 self.allow
180 .clone()
181 .unwrap_or_else(|| vec![Capability::ReadOnly])
182 }
183
184 fn validate(&self) -> Result<()> {
185 ensure!(
186 self.mode.is_none() || (self.allow.is_none() && self.require_approval.is_empty()),
187 "permissions.mode cannot be combined with allow or require_approval"
188 );
189 let allowed = self.allowed();
190 ensure!(
191 allowed.iter().collect::<HashSet<_>>().len() == allowed.len(),
192 "duplicate allowed capability"
193 );
194 ensure!(
195 self.require_approval.iter().collect::<HashSet<_>>().len()
196 == self.require_approval.len(),
197 "duplicate approval capability"
198 );
199 ensure!(
200 !self.require_approval.iter().any(|c| allowed.contains(c)),
201 "a capability cannot be both allowed and require approval"
202 );
203 Ok(())
204 }
205}
206
207fn identifier(value: &str) -> bool {
208 !value.is_empty()
209 && value
210 .chars()
211 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
212}
213
214impl AgentResource {
215 pub fn parse(content: &str) -> Result<Self> {
216 let resource: Self = toml::from_str(content).context("invalid agent resource TOML")?;
217 ensure!(identifier(&resource.agent.name), "invalid agent name");
218 ensure!(identifier(&resource.agent.model), "invalid model alias");
219 ensure!(
220 !resource.prompt.system.trim().is_empty(),
221 "prompt.system is required"
222 );
223 ensure!(
224 resource.agent.execution.max_turns > 0,
225 "max_turns must be greater than zero"
226 );
227 ensure!(
228 resource.agent.execution.timeout_seconds > 0,
229 "timeout_seconds must be greater than zero"
230 );
231 ensure!(
232 resource.agent.execution.max_total_tokens != Some(0),
233 "max_total_tokens must be greater than zero"
234 );
235 ensure!(
236 resource.agent.execution.max_tool_calls != Some(0),
237 "max_tool_calls must be greater than zero"
238 );
239 ensure!(
240 resource
241 .agent
242 .execution
243 .max_context_bytes
244 .is_none_or(|bytes| { (2 * 1024 * 1024..=8 * 1024 * 1024).contains(&bytes) }),
245 "max_context_bytes must be between 2097152 and 8388608 bytes"
246 );
247 let tools = &resource.agent.tools;
248 ensure!(tools.iter().all(|t| identifier(t)), "invalid tool name");
249 ensure!(
250 tools.iter().collect::<HashSet<_>>().len() == tools.len(),
251 "duplicate tool name"
252 );
253 resource.agent.permissions.validate()?;
254 resource
255 .agent
256 .delegation
257 .validate(&resource.agent.name, tools)?;
258 Ok(resource)
259 }
260
261 pub fn version(&self) -> Result<String> {
264 Ok(format!(
265 "sha256:{:x}",
266 Sha256::digest(serde_json::to_vec(self)?)
267 ))
268 }
269
270 pub fn prompt_profile(&self) -> TomlProfile {
273 TomlProfile::new(
274 self.agent.name.clone(),
275 self.agent.description.clone(),
276 self.agent.tools.clone(),
277 self.prompt.system.clone(),
278 )
279 }
280
281 #[allow(dead_code)]
283 #[deprecated(note = "use prompt_profile")]
284 pub fn prompt_agent(&self) -> TomlProfile {
285 self.prompt_profile()
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
290#[serde(rename_all = "snake_case")]
291pub enum ResourceScope {
292 Global,
293 Workspace,
294 Explicit,
295}
296
297#[derive(Debug, Clone)]
298pub struct ResourceDirectory {
299 pub path: PathBuf,
300 pub scope: ResourceScope,
301}
302
303pub fn resource_directories(
306 resolved: &ResolvedConfig,
307 root: &Path,
308 global: &Path,
309) -> Vec<ResourceDirectory> {
310 if matches!(
311 resolved.source,
312 ConfigSourceKind::Explicit | ConfigSourceKind::Env
313 ) {
314 return resolved
315 .path
316 .as_ref()
317 .map(|path| {
318 let path = if path.is_absolute() {
319 path.clone()
320 } else {
321 root.join(path)
322 };
323 ResourceDirectory {
324 path: path.parent().unwrap_or(root).join("agents"),
325 scope: ResourceScope::Explicit,
326 }
327 })
328 .into_iter()
329 .collect();
330 }
331 vec![
332 ResourceDirectory {
333 path: global.join("agents"),
334 scope: ResourceScope::Global,
335 },
336 ResourceDirectory {
337 path: root.join(".ctx/agents"),
338 scope: ResourceScope::Workspace,
339 },
340 ]
341}
342
343pub fn cli_resource_directories(resolved: &ResolvedConfig) -> Result<Vec<ResourceDirectory>> {
344 Ok(resource_directories(
345 resolved,
346 &std::env::current_dir()?,
347 &ctx_dirs::config_dir(),
348 ))
349}
350
351#[derive(Debug, Clone, Serialize)]
352pub struct LoadedAgentResource {
353 pub path: PathBuf,
354 pub scope: ResourceScope,
355 pub version: String,
356 pub definition: AgentResource,
357}
358
359pub fn load_resources(
362 directories: &[ResourceDirectory],
363 config: &Config,
364) -> Result<BTreeMap<String, LoadedAgentResource>> {
365 let mut resources: BTreeMap<String, LoadedAgentResource> = BTreeMap::new();
366 let mut visited = HashSet::new();
367 for directory in directories {
368 let canonical = match directory.path.canonicalize() {
369 Ok(path) => path,
370 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
371 Err(e) => {
372 return Err(e).with_context(|| format!("reading {}", directory.path.display()))
373 }
374 };
375 if !visited.insert(canonical.clone()) {
376 continue;
377 }
378 let mut paths = std::fs::read_dir(&canonical)
379 .with_context(|| format!("reading {}", canonical.display()))?
380 .map(|entry| entry.map(|e| e.path()))
381 .collect::<std::io::Result<Vec<_>>>()?;
382 paths.sort();
383 let mut names = HashSet::new();
384 for path in paths
385 .into_iter()
386 .filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
387 {
388 let content = std::fs::read_to_string(&path)
389 .with_context(|| format!("reading {}", path.display()))?;
390 let definition = AgentResource::parse(&content)
391 .with_context(|| format!("agent resource {}", path.display()))?;
392 let name = &definition.agent.name;
393 ensure!(
394 names.insert(name.clone()),
395 "duplicate agent '{name}' in {}",
396 canonical.display()
397 );
398 ensure!(
399 !config.profiles.inline.contains_key(name)
400 && !config.profiles.script.contains_key(name),
401 "agent resource {} conflicts with profile '{name}'; rename one of the definitions",
402 path.display()
403 );
404 if let Some(previous) = resources.get(name) {
405 ensure!(
406 definition.agent.override_existing,
407 "agent '{name}' in {} shadows {}; set agent.override = true to replace it",
408 path.display(),
409 previous.path.display()
410 );
411 }
412 let version = definition.version()?;
413 resources.insert(
414 name.clone(),
415 LoadedAgentResource {
416 path,
417 scope: directory.scope,
418 version,
419 definition,
420 },
421 );
422 }
423 }
424 for loaded in resources.values() {
427 let alias = &loaded.definition.agent.model;
428 let model = config.models.get(alias).with_context(|| {
429 format!(
430 "agent resource {} references unknown model '{alias}'",
431 loaded.path.display()
432 )
433 })?;
434 model
435 .validate()
436 .with_context(|| format!("model '{alias}' for {}", loaded.path.display()))?;
437 }
438 Ok(resources)
439}
440
441#[derive(Debug, Serialize)]
443pub struct ProfileEntry {
444 pub name: String,
445 pub description: String,
446 pub source: String,
447 pub tools: Vec<String>,
448 pub arguments: Vec<crate::profiles::ProfileArgument>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub system_prompt: Option<String>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub resource: Option<LoadedAgentResource>,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub model_config: Option<ModelInspection>,
455}
456
457#[derive(Debug, Serialize)]
459pub struct ModelInspection {
460 pub alias: String,
461 pub provider: String,
462 pub model: String,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub base_url: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub timeout_seconds: Option<u64>,
467 #[serde(skip_serializing_if = "Option::is_none")]
468 pub local_only: Option<bool>,
469}
470
471pub fn catalog(
472 config: &Config,
473 directories: &[ResourceDirectory],
474) -> Result<BTreeMap<String, ProfileEntry>> {
475 let resources = load_resources(directories, config)?;
476 let mut entries = BTreeMap::new();
477 for (name, profile) in &config.profiles.inline {
478 entries.insert(
479 name.clone(),
480 ProfileEntry {
481 name: name.clone(),
482 description: profile.description.clone(),
483 source: "toml".into(),
484 tools: profile.tools.clone(),
485 arguments: vec![],
486 system_prompt: Some(profile.system_prompt.clone()),
487 resource: None,
488 model_config: None,
489 },
490 );
491 }
492 for profile in crate::profile_script::load_profile_definitions(config)? {
493 ensure!(
494 !entries.contains_key(&profile.name),
495 "ambiguous profile '{}' (inline and Lua)",
496 profile.name
497 );
498 entries.insert(
499 profile.name.clone(),
500 ProfileEntry {
501 name: profile.name,
502 description: profile.description,
503 source: "lua".into(),
504 tools: profile.tools,
505 arguments: profile.arguments,
506 system_prompt: None,
507 resource: None,
508 model_config: None,
509 },
510 );
511 }
512 for (name, resource) in resources {
513 let agent = &resource.definition.agent;
514 let model_config = inspect_model(config, &agent.model)?;
515 entries.insert(
516 name.clone(),
517 ProfileEntry {
518 name,
519 description: agent.description.clone(),
520 source: "resource".into(),
521 tools: agent.tools.clone(),
522 arguments: vec![],
523 system_prompt: Some(resource.definition.prompt.system.clone()),
524 resource: Some(resource),
525 model_config: Some(model_config),
526 },
527 );
528 }
529 Ok(entries)
530}
531
532pub fn list_profiles(
533 config: &Config,
534 directories: &[ResourceDirectory],
535 json_output: bool,
536) -> Result<()> {
537 let entries = catalog(config, directories)?;
538 if json_output {
539 println!(
540 "{}",
541 serde_json::to_string_pretty(&entries.values().collect::<Vec<_>>())?
542 );
543 } else {
544 println!(
545 "{:<24} {:<10} {:<44} TOOLS",
546 "PROFILE", "TYPE", "DESCRIPTION"
547 );
548 for entry in entries.values() {
549 println!(
550 "{:<24} {:<10} {:<44} {}",
551 entry.name,
552 entry.source,
553 entry.description,
554 entry.tools.join(", ")
555 );
556 }
557 if entries.is_empty() {
558 println!("No profiles configured.");
559 }
560 }
561 Ok(())
562}
563
564pub fn show_profile(
565 config: &Config,
566 directories: &[ResourceDirectory],
567 name: &str,
568 json_output: bool,
569) -> Result<()> {
570 let mut entries = catalog(config, directories)?;
571 let entry = entries
572 .remove(name)
573 .with_context(|| format!("profile '{name}' not found"))?;
574 if json_output {
575 println!("{}", serde_json::to_string_pretty(&entry)?);
576 } else {
577 println!(
578 "Profile: {}\nSource: {}\nDescription: {}\nTools: {}",
579 entry.name,
580 entry.source,
581 entry.description,
582 entry.tools.join(", ")
583 );
584 if let Some(resource) = &entry.resource {
585 println!("Path: {}\nScope: {:?}\nVersion: {}\nModel: {}\nMax turns: {}\nTimeout: {}s\nAllowed capabilities: {}\nApproval required: {}",
586 resource.path.display(), resource.scope, resource.version, resource.definition.agent.model,
587 resource.definition.agent.execution.max_turns, resource.definition.agent.execution.timeout_seconds,
588 serde_json::to_string(&resource.definition.agent.permissions.allowed())?,
589 serde_json::to_string(&resource.definition.agent.permissions.require_approval)?);
590 }
591 if let Some(prompt) = entry.system_prompt {
592 println!("\nSystem prompt:\n{prompt}");
593 }
594 for argument in entry.arguments {
595 println!(
596 "Argument: {} (required: {}) — {}",
597 argument.name, argument.required, argument.description
598 );
599 }
600 }
601 Ok(())
602}
603
604pub fn validate_profiles(config: &Config, directories: &[ResourceDirectory]) -> Result<()> {
605 let providers = crate::agent_model::ModelProviderCatalog::with_builtins()?;
606 for (alias, model) in &config.models {
607 ensure!(identifier(alias), "invalid model alias '{alias}'");
608 providers.validate_definition(alias, model)?;
609 }
610 let entries = catalog(config, directories)?;
611 println!(
612 "Validated {} profiles ({} projected from executable agents).",
613 entries.len(),
614 entries.values().filter(|e| e.resource.is_some()).count()
615 );
616 Ok(())
617}
618
619pub async fn test_profile(
620 config: &Config,
621 directories: &[ResourceDirectory],
622 name: &str,
623 args: Vec<(String, String)>,
624) -> Result<()> {
625 if config.profiles.script.contains_key(name) {
628 return crate::profile_script::test_profile(name, args, config).await;
629 }
630 if !args.is_empty() {
631 bail!("static profile '{name}' does not accept arguments");
632 }
633 if let Some(agent) = config.profiles.inline.get(name) {
634 println!(
635 "Profile: {name}\nSource: toml\nTools: {}\n\nSystem prompt:\n{}",
636 agent.tools.join(", "),
637 agent.system_prompt
638 );
639 } else {
640 let mut resources = load_resources(directories, config)?;
641 let resource = resources
642 .remove(name)
643 .with_context(|| format!("profile '{name}' not found"))?;
644 println!(
645 "Profile: {name}\nSource: agent projection\nTools: {}\n\nSystem prompt:\n{}",
646 resource.definition.agent.tools.join(", "),
647 resource.definition.prompt.system
648 );
649 }
650 Ok(())
651}
652
653pub fn list(config: &Config, directories: &[ResourceDirectory], json_output: bool) -> Result<()> {
656 let resources = resource_catalog(config, directories)?;
657 validate_builtin_models(config, resources.values())?;
658 if json_output {
659 println!(
660 "{}",
661 serde_json::to_string_pretty(&resources.values().collect::<Vec<_>>())?
662 );
663 } else {
664 println!("{:<24} {:<44} MODEL", "AGENT", "DESCRIPTION");
665 for entry in resources.values() {
666 let resource = entry.resource.as_ref().expect("resource catalog entry");
667 println!(
668 "{:<24} {:<44} {}",
669 entry.name, entry.description, resource.definition.agent.model
670 );
671 }
672 if resources.is_empty() {
673 println!("No executable agents configured.");
674 }
675 }
676 Ok(())
677}
678
679pub fn show(
681 config: &Config,
682 directories: &[ResourceDirectory],
683 name: &str,
684 json_output: bool,
685) -> Result<()> {
686 let mut resources = resource_catalog(config, directories)?;
687 let entry = resources
688 .remove(name)
689 .with_context(|| format!("executable agent '{name}' not found"))?;
690 validate_builtin_models(config, std::iter::once(&entry))?;
691 if json_output {
692 println!("{}", serde_json::to_string_pretty(&entry)?);
693 } else {
694 let resource = entry.resource.as_ref().expect("resource catalog entry");
695 println!(
696 "Agent: {}\nDescription: {}\nPath: {}\nScope: {:?}\nVersion: {}\nModel: {}\nTools: {}\nMax turns: {}\nTimeout: {}s\nAllowed capabilities: {}\nApproval required: {}\n\nSystem prompt:\n{}",
697 entry.name,
698 entry.description,
699 resource.path.display(),
700 resource.scope,
701 resource.version,
702 resource.definition.agent.model,
703 entry.tools.join(", "),
704 resource.definition.agent.execution.max_turns,
705 resource.definition.agent.execution.timeout_seconds,
706 serde_json::to_string(&resource.definition.agent.permissions.allowed())?,
707 serde_json::to_string(&resource.definition.agent.permissions.require_approval)?,
708 entry.system_prompt.as_deref().unwrap_or_default(),
709 );
710 if let Some(model) = &entry.model_config {
711 if model.local_only == Some(true) {
712 println!(
713 "Model provider: {}\nProvider model: {}\nBase URL: {}\nProvider timeout: {}s\nLocal only: true",
714 model.provider,
715 model.model,
716 model.base_url.as_deref().expect("Ollama base URL"),
717 model.timeout_seconds.expect("Ollama timeout"),
718 );
719 }
720 }
721 }
722 Ok(())
723}
724
725pub fn validate(config: &Config, directories: &[ResourceDirectory]) -> Result<()> {
727 let resources = load_resources(directories, config)?;
728 let providers = crate::agent_model::ModelProviderCatalog::with_builtins()?;
729 for resource in resources.values() {
730 let alias = &resource.definition.agent.model;
731 providers.validate_definition(
732 alias,
733 config.models.get(alias).context("model alias not found")?,
734 )?;
735 }
736 println!("Validated {} executable agents.", resources.len());
737 Ok(())
738}
739
740fn validate_builtin_models<'a>(
741 config: &Config,
742 entries: impl IntoIterator<Item = &'a ProfileEntry>,
743) -> Result<()> {
744 let providers = crate::agent_model::ModelProviderCatalog::with_builtins()?;
745 for entry in entries {
746 let resource = entry.resource.as_ref().expect("agent resource entry");
747 let alias = &resource.definition.agent.model;
748 providers.validate_definition(
749 alias,
750 config.models.get(alias).context("model alias not found")?,
751 )?;
752 }
753 Ok(())
754}
755
756fn resource_catalog(
757 config: &Config,
758 directories: &[ResourceDirectory],
759) -> Result<BTreeMap<String, ProfileEntry>> {
760 Ok(load_resources(directories, config)?
761 .into_iter()
762 .map(|(name, resource)| {
763 let agent = &resource.definition.agent;
764 let model_config = inspect_model(config, &agent.model)
765 .expect("loaded agent has a validated model definition");
766 let entry = ProfileEntry {
767 name: name.clone(),
768 description: agent.description.clone(),
769 source: "resource".into(),
770 tools: agent.tools.clone(),
771 arguments: vec![],
772 system_prompt: Some(resource.definition.prompt.system.clone()),
773 resource: Some(resource),
774 model_config: Some(model_config),
775 };
776 (name, entry)
777 })
778 .collect())
779}
780
781fn inspect_model(config: &Config, alias: &str) -> Result<ModelInspection> {
782 let definition = config
783 .models
784 .get(alias)
785 .with_context(|| format!("unknown model alias '{alias}'"))?;
786 let ollama = definition.provider == "ollama";
787 let effective_ollama = ollama
788 .then(|| crate::agent_model::ollama::effective_config(definition))
789 .transpose()?;
790 Ok(ModelInspection {
791 alias: alias.into(),
792 provider: definition.provider.clone(),
793 model: definition.model.clone(),
794 base_url: effective_ollama.as_ref().map(|(url, _)| url.clone()),
795 timeout_seconds: effective_ollama.map(|(_, timeout)| timeout),
796 local_only: ollama.then_some(true),
797 })
798}