Skip to main content

context_harness/
agent_resource.rs

1//! Standalone executable-agent declarations. Loading is explicit: existing MCP
2//! prompt registries and unrelated commands do not discover these resources.
3
4use 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/// A declarative model alias. Credentials are referenced, never read here.
15#[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    /// Replaces a lower-precedence standalone definition as a whole.
69    #[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/// Explicit agent targets that the runtime may resolve for `agent.invoke`.
80/// Empty settings are omitted from version snapshots for backwards compatibility.
81#[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/// Omission means read-only. An explicit allow list replaces that default.
166/// Capability enforcement belongs to the runtime, not prompt projection.
167#[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    /// Hash parsed, default-expanded content; formatting/comments/path do not
262    /// affect the version. Resolved model configuration is a separate snapshot.
263    pub fn version(&self) -> Result<String> {
264        Ok(format!(
265            "sha256:{:x}",
266            Sha256::digest(serde_json::to_vec(self)?)
267        ))
268    }
269
270    /// Adapt the static prompt to the profile trait without changing its
271    /// contract. This does not grant runtime permissions to an external client.
272    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    /// Deprecated compatibility name for [`Self::prompt_profile`].
282    #[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
303/// Choose resource paths using the same explicit-config isolation as settings.
304/// The caller supplies the workspace root, never inferring it from a DB path.
305pub 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
359/// Read each layer in sorted order and replace whole resources only with an
360/// explicit override. A duplicate name within one layer is always an error.
361pub 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    // Validate references after replacement: a global agent overridden locally
425    // may legitimately refer to a model alias absent from the effective config.
426    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/// Combined profile discovery view. Profiles remain prompt-only and need no model.
442#[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/// Sanitized effective model settings for static inspection.
458#[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    // Preserve targeted profile resolution: an unrelated broken Lua script or
626    // resource must not prevent testing the selected profile.
627    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
653/// List executable standalone agents only. Prompt-only profiles are exposed by
654/// the separate `ctx profile` command.
655pub 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
679/// Show one executable standalone agent.
680pub 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
725/// Validate executable agent resources and their model references.
726pub 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}