diff --git a/src/llm-coding-tools-agents/README.md b/src/llm-coding-tools-agents/README.md index e6d6bb85..274a6d48 100644 --- a/src/llm-coding-tools-agents/README.md +++ b/src/llm-coding-tools-agents/README.md @@ -1,21 +1,16 @@ # llm-coding-tools-agents -Load OpenCode agent markdown files into a typed Rust catalogue. +Load OpenCode agent markdown files into Rust. -This crate is a loader for the [OpenCode agent schema](https://opencode.ai/docs/agents/). +This crate reads agent definitions from markdown files with YAML frontmatter, +following the [OpenCode agent schema](https://opencode.ai/docs/agents/). -It is a drop-in replacement for OpenCode agent files: agents you create for -OpenCode should load here unchanged. +Agents you create for OpenCode work here unchanged. -## What it provides +## Loading agents -- [`AgentLoader`] for loading agent configs from directories, files, or - in-memory markdown. -- [`AgentCatalog`] for storing and looking up loaded [`AgentConfig`] entries. -- [`RulesetExt`] for converting frontmatter `permission` data into runtime - [`Ruleset`]s. - -## Quick start +Use [`AgentLoader`] to read agent files from a directory, then store them in +an [`AgentCatalog`] for lookup by name: ```rust,no_run use llm_coding_tools_agents::{AgentCatalog, AgentLoader}; @@ -51,6 +46,35 @@ For field behaviour, see OpenCode docs for [`model`](https://opencode.ai/docs/agents#model), and [`permissions`](https://opencode.ai/docs/agents#permissions). +## Building agents + +Framework adapters (like `llm-coding-tools-serdesai`) use [`AgentRuntime`] to +build runnable agents. An `AgentRuntime` bundles your loaded agents with default +settings and available tools: + +```rust,no_run +use llm_coding_tools_agents::{ + AgentCatalog, AgentDefaults, AgentLoader, AgentRuntimeBuilder, +}; + +let loader = AgentLoader::new(); +let mut catalog = AgentCatalog::new(); +loader.add_directory(&mut catalog, "/home/user/.opencode")?; + +let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults { + model: Some("openai/gpt-4o-mini".into()), + temperature: Some(0.2), + top_p: Some(0.95), + }) + // .tools(my_custom_tools) // optional; defaults to read/write/edit/glob/grep/bash/webfetch/todoread/todowrite + .build(); + +// Pass `runtime` to your framework adapter to build agents by name +# Ok::<(), llm_coding_tools_agents::AgentLoadError>(()) +``` + ## Compatibility notes This library does not provide interactive UX extensions (for example, TUI @@ -65,15 +89,3 @@ while settings with no runtime effect are accepted and ignored: ([docs](https://opencode.ai/docs/permissions#what-ask-does)). - [`hidden`](https://opencode.ai/docs/agents#hidden) is accepted for compatibility, but ignored at runtime. - -## Integration - -This crate only loads and validates agent configs. -Pass [`AgentCatalog`] to your runtime adapter (for example, -`llm-coding-tools-serdesai`) to build registries and Task tooling. - -If you want to validate `model` strings against a catalog, call -[`AgentConfig::model_parts`] and pass the returned `(provider, model)` into -your lookup layer. - -[`Ruleset`]: llm_coding_tools_core::permissions::Ruleset diff --git a/src/llm-coding-tools-agents/src/catalog.rs b/src/llm-coding-tools-agents/src/catalog.rs index 2a764b8e..82b70cfe 100644 --- a/src/llm-coding-tools-agents/src/catalog.rs +++ b/src/llm-coding-tools-agents/src/catalog.rs @@ -71,7 +71,7 @@ impl AgentCatalog { /// - [`Option::Some`] with the previous [`AgentConfig`] if the name already existed. /// - [`Option::None`] if the name was not present. pub(crate) fn insert(&mut self, config: AgentConfig) -> Option { - self.agents.insert(config.name.clone(), config) + self.agents.insert(config.name.to_string(), config) } /// Creates a catalog from an iterator of agent configurations. @@ -84,7 +84,10 @@ impl AgentCatalog { /// - If duplicate names exist, the last entry for each name is retained. pub fn from_entries(entries: impl IntoIterator) -> Self { Self { - agents: entries.into_iter().map(|c| (c.name.clone(), c)).collect(), + agents: entries + .into_iter() + .map(|c| (c.name.to_string(), c)) + .collect(), } } } @@ -100,31 +103,31 @@ mod tests { fn catalog_iter_and_by_name() { let mut catalog = AgentCatalog::new(); catalog.insert(AgentConfig { - name: "alpha".to_string(), + name: "alpha".into(), mode: AgentMode::Subagent, - description: String::new(), + description: Default::default(), model: None, hidden: false, temperature: None, top_p: None, permission: IndexMap::new(), options: AHashMap::new(), - prompt: String::new(), + prompt: Default::default(), }); catalog.insert(AgentConfig { - name: "beta".to_string(), + name: "beta".into(), mode: AgentMode::Subagent, - description: String::new(), + description: Default::default(), model: None, hidden: false, temperature: None, top_p: None, permission: IndexMap::new(), options: AHashMap::new(), - prompt: String::new(), + prompt: Default::default(), }); - let names: Vec<_> = catalog.iter().map(|config| config.name.as_str()).collect(); + let names: Vec<_> = catalog.iter().map(|config| &*config.name).collect(); assert!(names.contains(&"alpha")); assert!(names.contains(&"beta")); assert!(catalog.by_name("beta").is_some()); diff --git a/src/llm-coding-tools-agents/src/lib.rs b/src/llm-coding-tools-agents/src/lib.rs index 84c07887..875a1f5f 100644 --- a/src/llm-coding-tools-agents/src/lib.rs +++ b/src/llm-coding-tools-agents/src/lib.rs @@ -4,10 +4,17 @@ mod catalog; mod extensions; mod loader; mod parser; +mod runtime; mod types; pub use catalog::AgentCatalog; pub use extensions::RulesetExt; pub use loader::AgentLoader; pub use parser::AgentParseError; -pub use types::{AgentConfig, AgentLoadError, AgentLoadResult, AgentMode, PermissionRule}; +pub use runtime::{ + default_tools, resolve_model_with_catalog, AgentDefaults, AgentRuntime, AgentRuntimeBuilder, + ModelResolutionError, ResolvedModel, ToolCatalogEntry, ToolCatalogKind, +}; +pub use types::{ + parse_model_parts, AgentConfig, AgentLoadError, AgentLoadResult, AgentMode, PermissionRule, +}; diff --git a/src/llm-coding-tools-agents/src/loader.rs b/src/llm-coding-tools-agents/src/loader.rs index 1ad1c7a7..155391ad 100644 --- a/src/llm-coding-tools-agents/src/loader.rs +++ b/src/llm-coding-tools-agents/src/loader.rs @@ -115,7 +115,7 @@ impl AgentLoader { ) -> AgentLoadResult<()> { let dir = directory.into(); load_directory_with(&dir, |path, name| { - match load_agent_file(path, name.to_string()) { + match load_agent_file(path, name) { Ok(config) => { catalog.insert(config); } @@ -169,7 +169,7 @@ impl AgentLoader { &self, catalog: &mut AgentCatalog, path: impl Into, - name: impl Into, + name: impl Into>, ) -> AgentLoadResult<()> { let path = path.into(); let override_name = name.into(); @@ -179,7 +179,7 @@ impl AgentLoader { "agent name is empty", )); } - let mut config = load_agent_file(&path, String::new())?; + let mut config = load_agent_file(&path, Box::default())?; config.name = override_name; catalog.insert(config); Ok(()) @@ -221,7 +221,7 @@ impl AgentLoader { &self, catalog: &mut AgentCatalog, markdown: impl Into, - default_name: impl Into, + default_name: impl Into>, ) -> AgentLoadResult<()> { let config = config_from_str_strict(markdown, default_name)?; catalog.insert(config); @@ -242,7 +242,7 @@ impl AgentLoader { &self, catalog: &mut AgentCatalog, bytes: impl AsRef<[u8]>, - default_name: impl Into, + default_name: impl Into>, ) -> AgentLoadResult<()> { let content = std::str::from_utf8(bytes.as_ref()).map_err(|err| { AgentLoadError::schema_validation(None, format!("invalid UTF-8: {err}")) @@ -318,7 +318,7 @@ fn load_directory_with( /// Shared parse helper that reuses existing loader parsing. fn parse_agent_config( content: String, - default_name: String, + default_name: impl Into>, ) -> Result { let result = parse_agent::(content)?; Ok(AgentConfig::from_raw( @@ -338,21 +338,19 @@ fn map_parse_error(path: Option, err: AgentParseError) -> AgentLoadErro } /// Loads a single agent configuration from a file. -fn load_agent_file(path: &Path, name: String) -> AgentLoadResult { +fn load_agent_file(path: &Path, name: impl Into>) -> AgentLoadResult { let content = fs::read_to_string(path).map_err(|e| AgentLoadError::io(Some(path.to_path_buf()), e))?; - parse_agent_config(content, name).map_err(|err| map_parse_error(Some(path.to_path_buf()), err)) } /// Strict parser for catalog-only string loading (validates non-empty name). fn config_from_str_strict( markdown: impl Into, - default_name: impl Into, + default_name: impl Into>, ) -> AgentLoadResult { - let name = default_name.into(); - let config = - parse_agent_config(markdown.into(), name).map_err(|err| map_parse_error(None, err))?; + let config = parse_agent_config(markdown.into(), default_name) + .map_err(|err| map_parse_error(None, err))?; if config.name.is_empty() { return Err(AgentLoadError::schema_validation( None, @@ -492,8 +490,8 @@ mod tests { loader.add_directory(&mut catalog, dir.path()).unwrap(); assert!(catalog.by_name("test-agent").is_some()); - assert_eq!(catalog.by_name("test-agent").unwrap().description, "Test"); - assert_eq!(catalog.by_name("test-agent").unwrap().prompt, "Prompt"); + assert_eq!(&*catalog.by_name("test-agent").unwrap().description, "Test"); + assert_eq!(&*catalog.by_name("test-agent").unwrap().prompt, "Prompt"); } #[test] @@ -617,8 +615,8 @@ mod tests { loader.add_directory(&mut catalog, dir.path()).unwrap(); assert_eq!( - catalog.by_name("test").unwrap().model, - Some("provider/model:tag".to_string()) + catalog.by_name("test").unwrap().model.as_deref(), + Some("provider/model:tag") ); } @@ -672,16 +670,16 @@ mod tests { fn make_agent(name: &str, description: &str) -> AgentConfig { AgentConfig { - name: name.to_string(), + name: name.into(), mode: AgentMode::Subagent, - description: description.to_string(), + description: description.into(), model: None, hidden: false, temperature: None, top_p: None, permission: IndexMap::new(), options: AHashMap::new(), - prompt: String::new(), + prompt: Default::default(), } } @@ -772,7 +770,7 @@ mod tests { .add_config(&mut catalog, make_agent("agent", "Second")) .unwrap(); - assert_eq!(catalog.by_name("agent").unwrap().description, "Second"); + assert_eq!(&*catalog.by_name("agent").unwrap().description, "Second"); } #[test] @@ -811,7 +809,7 @@ mod tests { .unwrap(); let agent = catalog.by_name("explicit").unwrap(); - assert_eq!(agent.description, "Explicit"); + assert_eq!(&*agent.description, "Explicit"); } #[test] @@ -859,7 +857,7 @@ mod tests { .add_config(&mut catalog, make_agent("override", "new")) .unwrap(); - assert_eq!(catalog.by_name("override").unwrap().description, "new"); + assert_eq!(&*catalog.by_name("override").unwrap().description, "new"); } // ========== String/Bytes Tests ========== @@ -881,7 +879,7 @@ mod tests { .unwrap(); let agent = catalog.by_name("string-agent").unwrap(); - assert_eq!(agent.description, "From string"); + assert_eq!(&*agent.description, "From string"); } #[test] @@ -1013,7 +1011,7 @@ mod tests { let agent = catalog.by_name("no-mode").unwrap(); assert_eq!(agent.mode, AgentMode::All); - assert_eq!(agent.description, "Test agent"); + assert_eq!(&*agent.description, "Test agent"); } #[test] @@ -1161,6 +1159,6 @@ mod tests { .unwrap(); let agent = catalog.by_name("hidden-agent").unwrap(); assert!(agent.hidden); - assert_eq!(agent.description, "Hidden agent"); + assert_eq!(&*agent.description, "Hidden agent"); } } diff --git a/src/llm-coding-tools-agents/src/parser/mod.rs b/src/llm-coding-tools-agents/src/parser/mod.rs index 03919b1d..1cce8948 100644 --- a/src/llm-coding-tools-agents/src/parser/mod.rs +++ b/src/llm-coding-tools-agents/src/parser/mod.rs @@ -126,7 +126,7 @@ fn validate_headless_compatibility(frontmatter: &Value) -> Result<(), AgentParse return Ok(()); }; - // Reject "ask" — requires interactive user confirmation + // Reject "ask" - requires interactive user confirmation if task_rule_contains_ask(task_rule) { return Err(AgentParseError::SchemaValidation { message: "permission.task: ask is unsupported; use allow or deny".to_string(), @@ -262,7 +262,7 @@ mod tests { }; let result: AgentParseResult = parse_agent(input.to_string()).unwrap(); - assert_eq!(result.data.description, "Test agent".to_string()); + assert_eq!(&*result.data.description, "Test agent"); assert_eq!(result.content, "Prompt body here."); } @@ -409,7 +409,7 @@ mod tests { let result: AgentParseResult = parse_agent(input.to_string()).unwrap(); // Model should NOT have trailing newline - assert_eq!(result.data.model, Some("provider/model:tag".to_string())); + assert_eq!(result.data.model.as_deref(), Some("provider/model:tag")); } #[test] @@ -483,7 +483,7 @@ mod tests { body" }; let result: AgentParseResult = parse_agent(input.to_string()).unwrap(); - assert_eq!(result.data.description, "Test"); + assert_eq!(&*result.data.description, "Test"); } #[test] @@ -496,7 +496,7 @@ mod tests { body" }; let result: AgentParseResult = parse_agent(input.to_string()).unwrap(); - assert_eq!(result.data.description, "Test"); + assert_eq!(&*result.data.description, "Test"); assert!(result.data.hidden); } } diff --git a/src/llm-coding-tools-agents/src/runtime/builder.rs b/src/llm-coding-tools-agents/src/runtime/builder.rs new file mode 100644 index 00000000..0396cd57 --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/builder.rs @@ -0,0 +1,122 @@ +//! Builds an [`AgentRuntime`] from your agents, defaults, and tools. + +use super::state::{AgentDefaults, AgentRuntime}; +use super::tool_catalog::{default_tools, ToolCatalogEntry}; +use crate::AgentCatalog; + +/// Builds an [`AgentRuntime`] step by step. +#[derive(Debug, Clone)] +pub struct AgentRuntimeBuilder { + catalog: AgentCatalog, + defaults: AgentDefaults, + tools: Vec, +} + +impl Default for AgentRuntimeBuilder { + #[inline] + fn default() -> Self { + Self::new() + } +} + +impl AgentRuntimeBuilder { + /// Creates a builder with empty catalog, empty defaults, and the standard tool set. + #[inline] + pub fn new() -> Self { + Self { + catalog: AgentCatalog::new(), + defaults: AgentDefaults::default(), + tools: default_tools(), + } + } + + /// Sets the agent catalog. + #[inline] + pub fn catalog(mut self, catalog: AgentCatalog) -> Self { + self.catalog = catalog; + self + } + + /// Sets the default settings. + #[inline] + pub fn defaults(mut self, defaults: AgentDefaults) -> Self { + self.defaults = defaults; + self + } + + /// Sets the available tools. + #[inline] + pub fn tools(mut self, tools: Vec) -> Self { + self.tools = tools; + self + } + + /// Finishes building and returns the [`AgentRuntime`]. + #[inline] + pub fn build(self) -> AgentRuntime { + AgentRuntime::from_parts(self.catalog, self.defaults, self.tools) + } +} + +#[cfg(test)] +mod tests { + use super::AgentRuntimeBuilder; + use crate::runtime::tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind}; + use crate::runtime::AgentDefaults; + use crate::{AgentCatalog, AgentConfig, AgentMode}; + use llm_coding_tools_core::tool_names; + + fn sample_config(name: &str, model: Option<&str>) -> AgentConfig { + AgentConfig { + name: name.into(), + mode: AgentMode::Subagent, + description: format!("{name} description").into(), + model: model.map(Into::into), + hidden: false, + temperature: Some(0.3), + top_p: Some(0.8), + permission: Default::default(), + options: Default::default(), + prompt: format!("You are {name}.").into(), + } + } + + #[test] + fn builder_builds_runtime_from_owned_inputs() { + let catalog = AgentCatalog::from_entries([sample_config("planner", Some("openai/gpt-4o"))]); + let defaults = AgentDefaults { + model: Some("openai/gpt-4.1-mini".into()), + temperature: Some(0.2), + top_p: Some(0.95), + }; + let tools = vec![ + ToolCatalogEntry::new(tool_names::READ, ToolCatalogKind::Read), + ToolCatalogEntry::new(tool_names::GLOB, ToolCatalogKind::Glob), + ]; + + let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(defaults.clone()) + .tools(tools.clone()) + .build(); + + assert_eq!( + runtime + .catalog() + .by_name("planner") + .and_then(|config| config.model.as_deref()), + Some("openai/gpt-4o"), + ); + assert_eq!(runtime.defaults(), &defaults); + assert_eq!(runtime.tools(), tools.as_slice()); + } + + #[test] + fn builder_defaults_to_empty_catalog_defaults_and_default_tools() { + let runtime = AgentRuntimeBuilder::new().build(); + + assert_eq!(runtime.catalog().iter().count(), 0); + assert_eq!(runtime.defaults(), &AgentDefaults::default()); + assert_eq!(runtime.tools(), default_tools().as_slice()); + } +} diff --git a/src/llm-coding-tools-agents/src/runtime/mod.rs b/src/llm-coding-tools-agents/src/runtime/mod.rs new file mode 100644 index 00000000..1d0581aa --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -0,0 +1,48 @@ +//! Build agents with tools and default settings. +//! +//! This module holds everything you need to prepare agents for use: +//! loaded agent definitions, default settings, and available tools. +//! +//! # Public API +//! +//! Runtime construction: +//! - [`AgentRuntime`] - Your agents plus their default settings and tools +//! - [`AgentRuntimeBuilder`] - Builds an [`AgentRuntime`] +//! - [`AgentDefaults`] - Default model, temperature, and top-p when agents don't specify them +//! +//! Tools: +//! - [`ToolCatalogEntry`] - One tool the runtime can provide to agents +//! - [`ToolCatalogKind`] - Which tools are available +//! - [`default_tools()`] - The standard tool set (read, write, edit, glob, grep, bash, webfetch, todo) +//! +//! Model resolution: +//! - [`ResolvedModel`] - A model identifier that's been validated against your catalog +//! - [`resolve_model_with_catalog()`] - Picks which model an agent will use +//! - [`ModelResolutionError`] - Errors when model selection fails +//! +//! # Example +//! +//! ```no_run +//! use llm_coding_tools_agents::{AgentCatalog, AgentDefaults, AgentRuntimeBuilder}; +//! +//! let runtime = AgentRuntimeBuilder::new() +//! .catalog(AgentCatalog::new()) +//! .defaults(AgentDefaults { +//! model: Some("openai/gpt-4o".into()), +//! temperature: Some(0.7), +//! top_p: Some(0.9), +//! }) +//! .build(); +//! +//! assert!(runtime.catalog().iter().count() == 0); +//! ``` + +mod builder; +mod model; +mod state; +mod tool_catalog; + +pub use builder::AgentRuntimeBuilder; +pub use model::{resolve_model_with_catalog, ModelResolutionError, ResolvedModel}; +pub use state::{AgentDefaults, AgentRuntime}; +pub use tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind}; diff --git a/src/llm-coding-tools-agents/src/runtime/model.rs b/src/llm-coding-tools-agents/src/runtime/model.rs new file mode 100644 index 00000000..696fe7d0 --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/model.rs @@ -0,0 +1,511 @@ +//! Picks which model an agent uses. +//! +//! An agent can specify its own model, or fall back to the runtime default. +//! This module validates that the chosen model exists in your catalog. +//! +//! # Public API +//! +//! - [`resolve_model_with_catalog()`] - Picks which model an agent will use +//! - [`ResolvedModel`] - A model identifier that's been validated +//! - [`ModelResolutionError`] - Errors when model selection fails +//! +//! # Precedence +//! +//! 1. If the agent's markdown file specifies a model, use that +//! 2. Otherwise, use the default from [`AgentDefaults`] +//! 3. If neither is set, return [`ModelResolutionError::MissingEffectiveModel`] +//! +//! # Identifier Format +//! +//! Models use `provider/model-id` format, like `openai/gpt-4o` or +//! `openrouter/anthropic/claude-3-5-sonnet`. Invalid formats (missing `/` +//! or empty segments) produce [`ModelResolutionError::MalformedModelIdentifier`]. +//! +//! # Validation +//! +//! The resolved model is validated against a [`ModelCatalog`]: +//! - Unknown provider → [`ModelResolutionError::UnknownProvider`] +//! - Unknown model for that provider → [`ModelResolutionError::UnknownModel`] +//! +//! [`AgentDefaults`]: super::state::AgentDefaults +//! [`ModelCatalog`]: llm_coding_tools_core::models::ModelCatalog + +use crate::AgentConfig; +use llm_coding_tools_core::models::ModelCatalog; + +/// A model identifier that's been validated against your catalog. +/// +/// Use [`provider()`][`Self::provider()`] and [`model()`][`Self::model()`] to get the +/// parts, or [`slash_spec()`][`Self::slash_spec()`] for the combined `provider/model-id` string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedModel { + provider: Box, + model: Box, +} + +impl ResolvedModel { + /// Returns the provider (e.g., `openai`). + #[inline] + pub fn provider(&self) -> &str { + &self.provider + } + + /// Returns the model name within the provider. + #[inline] + pub fn model(&self) -> &str { + &self.model + } + + /// Returns `provider/model-id` format. + #[inline] + pub fn slash_spec(&self) -> String { + format!("{}/{}", self.provider, self.model) + } +} + +/// Errors when picking or validating a model. +#[derive(Debug)] +#[non_exhaustive] +pub enum ModelResolutionError { + /// Model string is malformed (missing `/` or empty parts). + MalformedModelIdentifier { + /// Agent name for error context. + agent: Box, + /// Where the bad model string came from. + location: &'static str, + /// The malformed model string. + model: Box, + }, + /// Neither the agent nor the runtime default specifies a model. + MissingEffectiveModel { + /// Agent name for error context. + agent: Box, + }, + /// The provider isn't in the catalog. + UnknownProvider { + /// Agent name for error context. + agent: Box, + /// Where the provider came from. + location: &'static str, + /// The unknown provider. + provider: Box, + }, + /// The model isn't in the catalog for this provider. + UnknownModel { + /// Agent name for error context. + agent: Box, + /// Where the model came from. + location: &'static str, + /// Provider. + provider: Box, + /// Model name within the provider. + model: Box, + }, +} + +impl core::fmt::Display for ModelResolutionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MalformedModelIdentifier { + agent, + location, + model, + } => write!( + f, + "agent `{agent}` has malformed {location} model `{model}`; expected `provider/model-id`", + ), + Self::MissingEffectiveModel { agent } => write!( + f, + "agent `{agent}` does not define a model override and runtime defaults do not define one either", + ), + Self::UnknownProvider { + agent: _, + location, + provider, + } => { + write!( + f, + "effective provider `{provider}` from {location} is not in catalog" + ) + } + Self::UnknownModel { + agent: _, + location, + provider, + model, + } => write!( + f, + "effective model `{provider}/{model}` from {location} is not in catalog", + ), + } + } +} + +impl std::error::Error for ModelResolutionError {} + +/// Picks which model an agent will use. +/// +/// Checks the agent's model first, then the runtime default. +/// Validates the result against the catalog. +/// +/// # Arguments +/// +/// * `catalog` - Your model catalog for validation +/// * `defaults` - Default settings (used if agent doesn't specify a model) +/// * `agent` - The agent configuration +/// +/// # Returns +/// +/// A [`ResolvedModel`] on success, or a [`ModelResolutionError`] if something's wrong. +pub fn resolve_model_with_catalog( + catalog: &ModelCatalog, + defaults: &super::state::AgentDefaults, + agent: &AgentConfig, +) -> Result { + let (provider, model, location) = get_provider_model(defaults, agent)?; + + if catalog.lookup_provider(provider).is_none() { + return Err(ModelResolutionError::UnknownProvider { + agent: agent.name.clone(), + location, + provider: provider.into(), + }); + } + + if catalog.lookup_provider_model(provider, model).is_none() { + return Err(ModelResolutionError::UnknownModel { + agent: agent.name.clone(), + location, + provider: provider.into(), + model: model.into(), + }); + } + + Ok(ResolvedModel { + provider: provider.into(), + model: model.into(), + }) +} + +/// Extracts provider and model parts, checking agent override first. +fn get_provider_model<'a>( + defaults: &'a super::state::AgentDefaults, + agent: &'a AgentConfig, +) -> Result<(&'a str, &'a str, &'static str), ModelResolutionError> { + if let Some(raw) = agent.model.as_deref() { + let (provider, model) = crate::parse_model_parts(raw).ok_or_else(|| { + ModelResolutionError::MalformedModelIdentifier { + agent: agent.name.clone(), + location: "agent override", + model: raw.into(), + } + })?; + return Ok((provider, model, "agent override")); + } + + if let Some(raw) = defaults.model.as_deref() { + let (provider, model) = crate::parse_model_parts(raw).ok_or_else(|| { + ModelResolutionError::MalformedModelIdentifier { + agent: agent.name.clone(), + location: "runtime default", + model: raw.into(), + } + })?; + return Ok((provider, model, "runtime default")); + } + + Err(ModelResolutionError::MissingEffectiveModel { + agent: agent.name.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::{resolve_model_with_catalog, ModelResolutionError}; + use crate::runtime::AgentDefaults; + use ahash::AHashMap; + use indexmap::IndexMap; + use llm_coding_tools_core::models::{ + Modality, ModelCatalog, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource, + ProviderSource, ProviderType, + }; + + fn config_with_model(name: &str, model: Option<&str>) -> crate::AgentConfig { + crate::AgentConfig { + name: name.into(), + mode: crate::AgentMode::All, + description: Default::default(), + model: model.map(Into::into), + hidden: false, + temperature: None, + top_p: None, + permission: IndexMap::new(), + options: AHashMap::new(), + prompt: Default::default(), + } + } + + fn provider(api_url: &str, env_vars: &[&str], api_type: ProviderType) -> ProviderInfo { + ProviderInfo { + api_url: api_url.to_string(), + env_vars: env_vars.iter().map(|value| (*value).to_string()).collect(), + api_type, + } + } + + fn model_info(max_input: u32, max_output: u32) -> ModelInfo { + ModelInfo { + modalities: Modality::TEXT, + max_input, + max_output, + temperature: Some(0.2), + top_p: Some(0.95), + } + } + + fn build_catalog( + providers: Vec<(&str, ProviderInfo)>, + provider_models: Vec<(&str, &str, ModelInfo)>, + ) -> ModelCatalog { + let provider_sources: Vec = providers + .into_iter() + .map(|(key, info)| ProviderSource::new(key, info)) + .collect(); + let provider_model_sources: Vec> = provider_models + .into_iter() + .map(|(provider_key, model_key, info)| { + let provider_idx = ProviderIdx::new( + provider_sources + .iter() + .position(|provider| provider.provider_key == provider_key) + .expect("provider key should exist") as u16, + ); + ProviderModelSource::new(provider_idx, model_key, info) + }) + .collect(); + + ModelCatalog::build(&provider_sources, &provider_model_sources) + .expect("catalog fixture should build") + } + + #[test] + fn resolves_runtime_default_when_agent_has_no_override() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4.1-mini", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults { + model: Some("openai/gpt-4.1-mini".into()), + temperature: None, + top_p: None, + }; + let agent = config_with_model("planner", None); + + let resolved = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect("runtime default should resolve"); + + assert_eq!(resolved.provider(), "openai"); + assert_eq!(resolved.model(), "gpt-4.1-mini"); + assert_eq!(resolved.slash_spec(), "openai/gpt-4.1-mini"); + } + + #[test] + fn agent_override_wins_over_runtime_default() { + let catalog = build_catalog( + vec![( + "openrouter", + provider( + "https://openrouter.ai/api/v1", + &["OPENROUTER_API_KEY"], + ProviderType::OpenRouter, + ), + )], + vec![ + ( + "openrouter", + "openai/gpt-4.1-mini", + model_info(128_000, 16_384), + ), + ("openrouter", "openai/gpt-4o", model_info(128_000, 16_384)), + ], + ); + let defaults = AgentDefaults { + model: Some("openrouter/openai/gpt-4.1-mini".into()), + temperature: None, + top_p: None, + }; + let agent = config_with_model("planner", Some("openrouter/openai/gpt-4o")); + + let resolved = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect("override should resolve"); + + assert_eq!(resolved.provider(), "openrouter"); + assert_eq!(resolved.model(), "openai/gpt-4o"); + } + + #[test] + fn malformed_agent_override_does_not_fall_back_to_defaults() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4.1-mini", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults { + model: Some("openai/gpt-4.1-mini".into()), + temperature: None, + top_p: None, + }; + let agent = config_with_model("planner", Some("openai-only")); + + let err = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect_err("malformed override should fail"); + + match err { + ModelResolutionError::MalformedModelIdentifier { + location, model, .. + } => { + assert_eq!(location, "agent override"); + assert_eq!(&*model, "openai-only"); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn unknown_provider_returns_specific_error() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4o", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults::default(); + let agent = config_with_model("planner", Some("anthropic/claude-3-5-sonnet")); + + let err = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect_err("missing provider should fail"); + + match err { + ModelResolutionError::UnknownProvider { + location, provider, .. + } => { + assert_eq!(location, "agent override"); + assert_eq!(&*provider, "anthropic"); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn unknown_model_returns_specific_error() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4o", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults::default(); + let agent = config_with_model("planner", Some("openai/gpt-4.1-mini")); + + let err = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect_err("missing provider/model pair should fail"); + + match err { + ModelResolutionError::UnknownModel { + location, + provider, + model, + .. + } => { + assert_eq!(location, "agent override"); + assert_eq!(&*provider, "openai"); + assert_eq!(&*model, "gpt-4.1-mini"); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn missing_agent_override_and_runtime_default_returns_dedicated_error() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4o", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults::default(); + let agent = config_with_model("planner", None); + + let err = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect_err("missing effective model should fail"); + + match err { + ModelResolutionError::MissingEffectiveModel { agent } => { + assert_eq!(&*agent, "planner"); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn malformed_runtime_default_returns_clear_error() { + let catalog = build_catalog( + vec![( + "openai", + provider( + "https://api.openai.com/v1", + &["OPENAI_API_KEY"], + ProviderType::OpenAiResponses, + ), + )], + vec![("openai", "gpt-4o", model_info(128_000, 16_384))], + ); + let defaults = AgentDefaults { + model: Some("openai-only".into()), + temperature: None, + top_p: None, + }; + let agent = config_with_model("planner", None); + + let err = resolve_model_with_catalog(&catalog, &defaults, &agent) + .expect_err("malformed runtime default should fail"); + + match err { + ModelResolutionError::MalformedModelIdentifier { + location, model, .. + } => { + assert_eq!(location, "runtime default"); + assert_eq!(&*model, "openai-only"); + } + other => panic!("unexpected error: {other}"), + } + } +} diff --git a/src/llm-coding-tools-agents/src/runtime/state.rs b/src/llm-coding-tools-agents/src/runtime/state.rs new file mode 100644 index 00000000..79f79355 --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/state.rs @@ -0,0 +1,61 @@ +//! Holds your loaded agents, default settings, and available tools. +//! +//! ## Public API +//! +//! - [`AgentRuntime`] — Container for loaded agents, defaults, and tools. +//! - [`AgentDefaults`] — Fallback settings when an agent doesn't specify them. + +use super::tool_catalog::ToolCatalogEntry; +use crate::AgentCatalog; + +/// Default settings used when an agent doesn't specify them. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AgentDefaults { + /// Default model in `provider/model-id` format. + pub model: Option>, + /// Default sampling temperature. + pub temperature: Option, + /// Default nucleus sampling top-p. + pub top_p: Option, +} + +/// Your loaded agents plus their default settings and available tools. +#[derive(Debug, Clone)] +pub struct AgentRuntime { + catalog: AgentCatalog, + defaults: AgentDefaults, + tools: Vec, +} + +impl AgentRuntime { + #[inline] + pub(super) fn from_parts( + catalog: AgentCatalog, + defaults: AgentDefaults, + tools: Vec, + ) -> Self { + Self { + catalog, + defaults, + tools, + } + } + + /// Returns the loaded agent definitions. + #[inline] + pub fn catalog(&self) -> &AgentCatalog { + &self.catalog + } + + /// Returns the default settings. + #[inline] + pub fn defaults(&self) -> &AgentDefaults { + &self.defaults + } + + /// Returns the tools available to agents. + #[inline] + pub fn tools(&self) -> &[ToolCatalogEntry] { + &self.tools + } +} diff --git a/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs b/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs new file mode 100644 index 00000000..9aa8c464 --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs @@ -0,0 +1,108 @@ +//! Lists which tools your agents can use. +//! +//! Each [`ToolCatalogEntry`] pairs a tool name with its type ([`ToolCatalogKind`]). +//! +//! # Public API +//! +//! - [`ToolCatalogEntry`] - One tool the runtime can provide to agents +//! - [`ToolCatalogKind`] - The tools your agents can use +//! - [`default_tools()`] - The standard tool set +//! +//! The default tools are: read, write, edit, glob, grep, bash, webfetch, todoread, +//! todowrite. The "task" tool is excluded since it's handled separately. + +use llm_coding_tools_core::tool_names; + +/// One tool the runtime can provide to agents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolCatalogEntry { + /// Tool name exposed to models. + pub name: &'static str, + /// Which tool this is. + pub kind: ToolCatalogKind, +} + +impl ToolCatalogEntry { + /// Creates a tool entry from its name and kind. + pub const fn new(name: &'static str, kind: ToolCatalogKind) -> Self { + Self { name, kind } + } +} + +/// The tools your agents can use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ToolCatalogKind { + /// Read file contents. + Read, + /// Write file contents. + Write, + /// Edit file contents. + Edit, + /// Glob file pattern matching. + Glob, + /// Grep text search. + Grep, + /// Bash command execution. + Bash, + /// Web fetch for HTTP requests. + WebFetch, + /// Read todo items. + TodoRead, + /// Create and update todo items. + TodoWrite, +} + +const DEFAULT_TOOLS: [ToolCatalogEntry; 9] = [ + ToolCatalogEntry::new(tool_names::READ, ToolCatalogKind::Read), + ToolCatalogEntry::new(tool_names::WRITE, ToolCatalogKind::Write), + ToolCatalogEntry::new(tool_names::EDIT, ToolCatalogKind::Edit), + ToolCatalogEntry::new(tool_names::GLOB, ToolCatalogKind::Glob), + ToolCatalogEntry::new(tool_names::GREP, ToolCatalogKind::Grep), + ToolCatalogEntry::new(tool_names::BASH, ToolCatalogKind::Bash), + ToolCatalogEntry::new(tool_names::WEBFETCH, ToolCatalogKind::WebFetch), + ToolCatalogEntry::new(tool_names::TODO_READ, ToolCatalogKind::TodoRead), + ToolCatalogEntry::new(tool_names::TODO_WRITE, ToolCatalogKind::TodoWrite), +]; + +/// Returns the standard tool set. +pub fn default_tools() -> Vec { + DEFAULT_TOOLS.to_vec() +} + +#[cfg(test)] +mod tests { + use super::{default_tools, ToolCatalogEntry, ToolCatalogKind}; + use llm_coding_tools_core::tool_names; + use std::collections::BTreeSet; + + #[test] + fn default_tools_match_expected_catalog() { + assert_eq!( + default_tools(), + vec![ + ToolCatalogEntry::new(tool_names::READ, ToolCatalogKind::Read), + ToolCatalogEntry::new(tool_names::WRITE, ToolCatalogKind::Write), + ToolCatalogEntry::new(tool_names::EDIT, ToolCatalogKind::Edit), + ToolCatalogEntry::new(tool_names::GLOB, ToolCatalogKind::Glob), + ToolCatalogEntry::new(tool_names::GREP, ToolCatalogKind::Grep), + ToolCatalogEntry::new(tool_names::BASH, ToolCatalogKind::Bash), + ToolCatalogEntry::new(tool_names::WEBFETCH, ToolCatalogKind::WebFetch), + ToolCatalogEntry::new(tool_names::TODO_READ, ToolCatalogKind::TodoRead), + ToolCatalogEntry::new(tool_names::TODO_WRITE, ToolCatalogKind::TodoWrite), + ], + ); + } + + #[test] + fn default_tools_exclude_task_and_keep_names_unique() { + let tools = default_tools(); + assert!(tools.iter().all(|entry| entry.name != tool_names::TASK)); + + let unique_names = tools + .iter() + .map(|entry| entry.name) + .collect::>(); + assert_eq!(unique_names.len(), tools.len()); + } +} diff --git a/src/llm-coding-tools-agents/src/types/config.rs b/src/llm-coding-tools-agents/src/types/config.rs index 1d4a74ab..d12f43fc 100644 --- a/src/llm-coding-tools-agents/src/types/config.rs +++ b/src/llm-coding-tools-agents/src/types/config.rs @@ -72,21 +72,21 @@ impl Default for PermissionRule { #[derive(Debug, Clone, Deserialize)] pub(crate) struct RawFrontmatter { #[serde(default)] - pub name: Option, + pub name: Option>, #[serde(default)] pub mode: AgentMode, - pub description: String, + pub description: Box, #[serde(default)] - pub model: Option, + pub model: Option>, /// Legacy visibility flag accepted for compatibility only. /// /// Runtime behavior in headless mode ignores this field. #[serde(default)] pub hidden: bool, #[serde(default)] - pub temperature: Option, + pub temperature: Option, #[serde(default)] - pub top_p: Option, + pub top_p: Option, #[serde(default)] pub permission: IndexMap, #[serde(default)] @@ -100,18 +100,18 @@ pub struct AgentConfig { /// /// This comes from frontmatter `name` when present; otherwise a loader- /// provided default (for example, derived from a file path) is used. - pub name: String, + pub name: Box, /// Execution mode. #[serde(default)] pub mode: AgentMode, /// Human-readable description. #[serde(default)] - pub description: String, + pub description: Box, /// Optional model override (format: "provider/model-id"). /// - /// Use [`AgentConfig::model_parts`] before catalog lookup. + /// Use [`AgentConfig::get_provider_model`] before catalog lookup. #[serde(default)] - pub model: Option, + pub model: Option>, /// Legacy visibility flag accepted for compatibility only. /// /// Runtime behavior in headless mode ignores this field. @@ -119,10 +119,10 @@ pub struct AgentConfig { pub hidden: bool, /// Temperature for sampling. #[serde(default)] - pub temperature: Option, + pub temperature: Option, /// Top-p for nucleus sampling. #[serde(default)] - pub top_p: Option, + pub top_p: Option, /// Tool permissions map. #[serde(default)] pub permission: IndexMap, @@ -134,26 +134,37 @@ pub struct AgentConfig { /// The parser stores this with LF line endings and trims surrounding ASCII /// whitespace. #[serde(skip)] - pub prompt: String, + pub prompt: Box, } impl AgentConfig { - /// Returns the configured model split into `(provider, model)` parts. + /// Returns the provider and model identifier from [`AgentConfig::model`]. + /// + /// Delegates to [`parse_model_parts`] for parsing. + /// + /// ## Expected Format + /// `"provider/model-id"` (e.g., `"openai/gpt-4"`, `"synthetic/hf:moonshotai/Kimi-K2.5"`). + /// + /// ## Returns + /// - `Some(("provider", "model-id"))` on valid input + /// - `None` if [`AgentConfig::model`] is unset or malformed (missing `/` or empty segments) #[inline] - pub fn model_parts(&self) -> Option<(&str, &str)> { - let value = self.model.as_deref()?; - let (provider, model) = value.split_once('/')?; - if provider.is_empty() || model.is_empty() { - return None; - } - - Some((provider, model)) + pub fn get_provider_model(&self) -> Option<(&str, &str)> { + self.model.as_deref().and_then(parse_model_parts) } /// Creates an [`AgentConfig`] from raw frontmatter and parsed prompt body. - pub(crate) fn from_raw(default_name: String, raw: RawFrontmatter, prompt: String) -> Self { + pub(crate) fn from_raw( + default_name: impl Into>, + raw: RawFrontmatter, + prompt: impl Into>, + ) -> Self { + let name = match raw.name { + Some(s) => s, + None => default_name.into(), + }; Self { - name: raw.name.unwrap_or(default_name), + name, mode: raw.mode, description: raw.description, model: raw.model, @@ -162,11 +173,28 @@ impl AgentConfig { top_p: raw.top_p, permission: raw.permission, options: raw.options, - prompt, + prompt: prompt.into(), } } } +/// Parses a model identifier string into `(provider, model)` parts. +/// +/// ## Expected Format +/// `"provider/model-id"` (e.g., `"openai/gpt-4"`, `"synthetic/hf:moonshotai/Kimi-K2.5"`). +/// +/// ## Returns +/// - `Some(("provider", "model-id"))` on valid input +/// - `None` if the value lacks a `/` separator or has empty segments +#[inline] +pub fn parse_model_parts(value: &str) -> Option<(&str, &str)> { + let (provider, model) = value.split_once('/')?; + if provider.is_empty() || model.is_empty() { + return None; + } + Some((provider, model)) +} + #[cfg(test)] mod tests { use super::{AgentConfig, AgentMode}; @@ -175,40 +203,40 @@ mod tests { fn config_with_model(model: Option<&str>) -> AgentConfig { AgentConfig { - name: "example".to_string(), + name: "example".into(), mode: AgentMode::All, - description: String::new(), - model: model.map(str::to_string), + description: Default::default(), + model: model.map(Into::into), hidden: false, temperature: None, top_p: None, permission: IndexMap::new(), options: AHashMap::new(), - prompt: String::new(), + prompt: Default::default(), } } #[test] - fn model_parts_returns_provider_and_model() { + fn get_provider_model_returns_provider_and_model() { let config = config_with_model(Some("synthetic/hf:moonshotai/Kimi-K2.5")); assert_eq!( - config.model_parts(), + config.get_provider_model(), Some(("synthetic", "hf:moonshotai/Kimi-K2.5")) ); } #[test] - fn model_parts_rejects_missing_separator() { + fn get_provider_model_rejects_missing_separator() { let config = config_with_model(Some("synthetic-only")); - assert_eq!(config.model_parts(), None); + assert_eq!(config.get_provider_model(), None); } #[test] - fn model_parts_handles_absent_model() { + fn get_provider_model_handles_absent_model() { let config = config_with_model(None); - assert_eq!(config.model_parts(), None); + assert_eq!(config.get_provider_model(), None); } } diff --git a/src/llm-coding-tools-agents/src/types/mod.rs b/src/llm-coding-tools-agents/src/types/mod.rs index 24fd665b..a2af7b74 100644 --- a/src/llm-coding-tools-agents/src/types/mod.rs +++ b/src/llm-coding-tools-agents/src/types/mod.rs @@ -3,13 +3,13 @@ //! Central module for types used across loading and catalog operations. //! //! ## Re-exports -//! - Config types: [`AgentConfig`], [`AgentMode`], [`PermissionRule`] +//! - Config types: [`AgentConfig`], [`AgentMode`], [`PermissionRule`], [`parse_model_parts`] //! - Load errors: [`AgentLoadError`], [`AgentLoadResult`] mod config; mod error; -pub use config::{AgentConfig, AgentMode, PermissionRule}; +pub use config::{parse_model_parts, AgentConfig, AgentMode, PermissionRule}; pub use error::{AgentLoadError, AgentLoadResult}; pub(crate) use config::RawFrontmatter; diff --git a/src/llm-coding-tools-models-dev/src/fs/blocking_impl.rs b/src/llm-coding-tools-models-dev/src/fs/blocking_impl.rs index 01252a9b..3dc6f62c 100644 --- a/src/llm-coding-tools-models-dev/src/fs/blocking_impl.rs +++ b/src/llm-coding-tools-models-dev/src/fs/blocking_impl.rs @@ -9,7 +9,7 @@ use std::path::Path; /// /// We snapshot file length then call `read_exact`, which would miss data appended after /// the metadata call if the file grew mid-read. However, within this codebase all -/// writes go to a temp file first, then rename to target — so files are never +/// writes go to a temp file first, then rename to target - so files are never /// appended to in place. /// Therefore this race cannot occur. #[inline] diff --git a/src/llm-coding-tools-models-dev/src/fs/tokio_impl.rs b/src/llm-coding-tools-models-dev/src/fs/tokio_impl.rs index 29d04d2c..92bca908 100644 --- a/src/llm-coding-tools-models-dev/src/fs/tokio_impl.rs +++ b/src/llm-coding-tools-models-dev/src/fs/tokio_impl.rs @@ -10,7 +10,7 @@ use tokio::io::AsyncReadExt as _; /// /// We snapshot file length then call `read_exact`, which would miss data appended after /// the metadata call if the file grew mid-read. However, within this codebase all -/// writes go to a temp file first, then rename to target — so files are never +/// writes go to a temp file first, then rename to target - so files are never /// appended to in place. /// Therefore this race cannot occur. #[inline]