diff --git a/src/llm-coding-tools-agents/README.md b/src/llm-coding-tools-agents/README.md index 91bfb8b3..0f0fdd18 100644 --- a/src/llm-coding-tools-agents/README.md +++ b/src/llm-coding-tools-agents/README.md @@ -64,7 +64,8 @@ loader.add_directory(&mut catalog, "/home/user/.opencode")?; let runtime = AgentRuntimeBuilder::new() .catalog(catalog) .defaults(AgentDefaults::with_model("openai/gpt-4o-mini")) - // .tools(my_custom_tools) // optional; defaults to read/write/edit/glob/grep/bash/webfetch/todoread/todowrite + // .max_task_depth(5) // optional; defaults to 3 Task hops + // .tools(my_custom_tools) // optional; defaults to read/write/edit/glob/grep/bash/webfetch/todoread/todowrite/task .build(); // Pass `runtime` to your framework adapter to build agents by name @@ -79,6 +80,9 @@ approval flows). To avoid false expectations, settings that require interaction are rejected, while settings with no runtime effect are accepted and ignored: +- Unspecified permissions default to `deny` for normal tools. `permission.task` + is special: if omitted, Task still allows delegation to callable + `mode: all` / `mode: subagent` targets for OpenCode compatibility. - [`permission.task`](https://opencode.ai/docs/agents#task-permissions): `ask` is rejected with a schema validation error (`allow`/`deny` only), because `ask` is an interactive approval mode in OpenCode diff --git a/src/llm-coding-tools-agents/src/extensions.rs b/src/llm-coding-tools-agents/src/extensions.rs index 2d534618..34abc44e 100644 --- a/src/llm-coding-tools-agents/src/extensions.rs +++ b/src/llm-coding-tools-agents/src/extensions.rs @@ -3,12 +3,10 @@ //! Helpers for converting agent permission config into runtime [`Ruleset`] values. //! //! ## What This Module Provides -//! - [`RulesetExt`] trait for building a [`Ruleset`] from frontmatter data and -//! filtering tool entries by permission. +//! - [`RulesetExt`] trait for building a [`Ruleset`] from frontmatter data. //! - Support for scalar (`allow`/`deny`) and pattern-map permission rules. //! - Iteration-order preservation via [`IndexMap`] (important for precedence). -use crate::runtime::ToolCatalogEntry; use crate::types::PermissionRule; use indexmap::IndexMap; use llm_coding_tools_core::permissions::{Rule, Ruleset}; @@ -40,37 +38,6 @@ pub trait RulesetExt: Sized { /// assert!(ruleset.is_allowed("bash", "*")); /// ``` fn from_permission_config(config: &IndexMap) -> Self; - - /// Filters tool entries to those allowed by this ruleset. - /// - /// Returns only entries whose `name` passes `is_allowed(name, "*")`. - /// - /// # Arguments - /// - /// * `tools` - Slice of tool entries to filter. - /// - /// # Returns - /// - /// A vector containing only the tool entries allowed by this ruleset, - /// preserving the original order. - /// - /// # Example - /// - /// ``` - /// use llm_coding_tools_agents::{ - /// default_tools, PermissionRule, RulesetExt, - /// }; - /// use llm_coding_tools_core::permissions::{PermissionAction, Ruleset}; - /// use indexmap::IndexMap; - /// - /// let mut config = IndexMap::new(); - /// config.insert("read".to_string(), PermissionRule::Action(PermissionAction::Allow)); - /// - /// let ruleset = Ruleset::from_permission_config(&config); - /// let allowed = ruleset.filter_allowed_tools(&default_tools()); - /// assert!(allowed.iter().any(|t| t.name == "read")); - /// ``` - fn filter_allowed_tools(&self, tools: &[ToolCatalogEntry]) -> Vec; } impl RulesetExt for Ruleset { @@ -92,20 +59,11 @@ impl RulesetExt for Ruleset { ruleset } - - fn filter_allowed_tools(&self, tools: &[ToolCatalogEntry]) -> Vec { - tools - .iter() - .copied() - .filter(|entry| self.is_allowed(entry.name, "*")) - .collect() - } } #[cfg(test)] mod tests { use super::*; - use crate::default_tools; use llm_coding_tools_core::permissions::PermissionAction; #[test] @@ -144,24 +102,4 @@ mod tests { PermissionAction::Deny ); } - - #[test] - fn filter_allowed_tools_returns_allowed_entries() { - let mut config = IndexMap::new(); - config.insert( - "read".to_string(), - PermissionRule::Action(PermissionAction::Allow), - ); - config.insert( - "glob".to_string(), - PermissionRule::Action(PermissionAction::Allow), - ); - - let ruleset = Ruleset::from_permission_config(&config); - let allowed = ruleset.filter_allowed_tools(&default_tools()); - - assert!(allowed.iter().any(|t| t.name == "read")); - assert!(allowed.iter().any(|t| t.name == "glob")); - assert!(!allowed.iter().any(|t| t.name == "bash")); - } } diff --git a/src/llm-coding-tools-agents/src/lib.rs b/src/llm-coding-tools-agents/src/lib.rs index 875a1f5f..e027ebcc 100644 --- a/src/llm-coding-tools-agents/src/lib.rs +++ b/src/llm-coding-tools-agents/src/lib.rs @@ -12,8 +12,9 @@ pub use extensions::RulesetExt; pub use loader::AgentLoader; pub use parser::AgentParseError; pub use runtime::{ - default_tools, resolve_model_with_catalog, AgentDefaults, AgentRuntime, AgentRuntimeBuilder, - ModelResolutionError, ResolvedModel, ToolCatalogEntry, ToolCatalogKind, + callable_targets, default_tools, resolve_model_with_catalog, summarize_callable_targets, + AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, + TaskSettings, TaskTargetSummary, ToolCatalogEntry, ToolCatalogKind, }; pub use types::{ parse_model_parts, AgentConfig, AgentLoadError, AgentLoadResult, AgentMode, PermissionRule, diff --git a/src/llm-coding-tools-agents/src/runtime/builder.rs b/src/llm-coding-tools-agents/src/runtime/builder.rs index 82f23dfb..ac31f3a1 100644 --- a/src/llm-coding-tools-agents/src/runtime/builder.rs +++ b/src/llm-coding-tools-agents/src/runtime/builder.rs @@ -3,12 +3,14 @@ use super::state::{AgentDefaults, AgentRuntime}; use super::tool_catalog::{default_tools, ToolCatalogEntry}; use crate::AgentCatalog; +use llm_coding_tools_core::TaskSettings; /// Builds an [`AgentRuntime`] step by step. #[derive(Debug, Clone)] pub struct AgentRuntimeBuilder { catalog: AgentCatalog, defaults: AgentDefaults, + task_settings: TaskSettings, tools: Vec, } @@ -20,12 +22,13 @@ impl Default for AgentRuntimeBuilder { } impl AgentRuntimeBuilder { - /// Creates a builder with empty catalog, empty defaults, and the standard tool set. + /// Creates a builder with empty catalog, empty defaults, default Task settings, and the standard tool set. #[inline] pub fn new() -> Self { Self { catalog: AgentCatalog::new(), defaults: AgentDefaults::default(), + task_settings: TaskSettings::default(), tools: default_tools(), } } @@ -44,6 +47,20 @@ impl AgentRuntimeBuilder { self } + /// Sets the shared Task delegation settings. + #[inline] + pub fn task_settings(mut self, task_settings: TaskSettings) -> Self { + self.task_settings = task_settings; + self + } + + /// Sets the maximum number of Task delegation hops. + #[inline] + pub fn max_task_depth(mut self, max_depth: u8) -> Self { + self.task_settings = TaskSettings::with_max_depth(max_depth); + self + } + /// Sets the available tools. #[inline] pub fn tools(mut self, tools: Vec) -> Self { @@ -54,7 +71,7 @@ impl AgentRuntimeBuilder { /// Finishes building and returns the [`AgentRuntime`]. #[inline] pub fn build(self) -> AgentRuntime { - AgentRuntime::from_parts(self.catalog, self.defaults, self.tools) + AgentRuntime::from_parts(self.catalog, self.defaults, self.task_settings, self.tools) } } @@ -65,6 +82,7 @@ mod tests { use crate::runtime::AgentDefaults; use crate::{AgentCatalog, AgentConfig, AgentMode}; use llm_coding_tools_core::tool_names; + use llm_coding_tools_core::TaskSettings; fn sample_config(name: &str, model: Option<&str>) -> AgentConfig { AgentConfig { @@ -108,15 +126,24 @@ mod tests { Some("openai/gpt-4o"), ); assert_eq!(runtime.defaults(), &defaults); + assert_eq!(runtime.task_settings(), TaskSettings::default()); assert_eq!(runtime.tools(), tools.as_slice()); } + #[test] + fn builder_overrides_task_settings() { + let runtime = AgentRuntimeBuilder::new().max_task_depth(5).build(); + + assert_eq!(runtime.task_settings(), TaskSettings::with_max_depth(5)); + } + #[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.task_settings(), TaskSettings::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 index 45539b5b..cbbe40cf 100644 --- a/src/llm-coding-tools-agents/src/runtime/mod.rs +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -9,11 +9,17 @@ //! - [`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 +//! - [`TaskSettings`] - Shared Task delegation limits for all integrations using the runtime //! //! 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) +//! - [`default_tools()`] - The standard tool set (read, write, edit, glob, grep, bash, webfetch, todo, task) +//! +//! Task delegation: +//! - [`summarize_callable_targets()`] - Builds target summaries with names and descriptions +//! - [`callable_targets()`] - Returns the agents the active agent may delegate to +//! - [`TaskTargetSummary`] - Metadata for a callable Task target //! //! Model resolution: //! - [`ResolvedModel`] - A model identifier that's been validated against your catalog @@ -36,9 +42,12 @@ mod builder; mod model; mod state; +mod task; mod tool_catalog; pub use builder::AgentRuntimeBuilder; +pub use llm_coding_tools_core::TaskSettings; pub use model::{resolve_model_with_catalog, ModelResolutionError, ResolvedModel}; pub use state::{AgentDefaults, AgentRuntime}; +pub use task::{callable_targets, summarize_callable_targets, TaskTargetSummary}; pub use tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind}; diff --git a/src/llm-coding-tools-agents/src/runtime/state.rs b/src/llm-coding-tools-agents/src/runtime/state.rs index 487bc245..d35c2b31 100644 --- a/src/llm-coding-tools-agents/src/runtime/state.rs +++ b/src/llm-coding-tools-agents/src/runtime/state.rs @@ -1,12 +1,14 @@ -//! Holds your loaded agents, default settings, and available tools. +//! Holds your loaded agents, default settings, Task settings, and available tools. //! //! ## Public API //! -//! - [`AgentRuntime`] — Container for loaded agents, defaults, and tools. +//! - [`AgentRuntime`] — Container for loaded agents, defaults, Task settings, and tools. //! - [`AgentDefaults`] — Fallback settings when an agent doesn't specify them. +use super::task::resolve_allowed_tools; use super::tool_catalog::ToolCatalogEntry; use crate::AgentCatalog; +use llm_coding_tools_core::TaskSettings; /// Default settings used when an agent doesn't specify them. #[derive(Debug, Clone, Default, PartialEq)] @@ -31,11 +33,12 @@ impl AgentDefaults { } } -/// Your loaded agents plus their default settings and available tools. +/// Your loaded agents plus their default settings, Task settings, and available tools. #[derive(Debug, Clone)] pub struct AgentRuntime { catalog: AgentCatalog, defaults: AgentDefaults, + task_settings: TaskSettings, tools: Vec, } @@ -44,11 +47,13 @@ impl AgentRuntime { pub(super) fn from_parts( catalog: AgentCatalog, defaults: AgentDefaults, + task_settings: TaskSettings, tools: Vec, ) -> Self { Self { catalog, defaults, + task_settings, tools, } } @@ -65,9 +70,25 @@ impl AgentRuntime { &self.defaults } + /// Returns the shared Task delegation settings. + #[inline] + pub fn task_settings(&self) -> TaskSettings { + self.task_settings + } + /// Returns the tools available to agents. #[inline] pub fn tools(&self) -> &[ToolCatalogEntry] { &self.tools } + + /// Returns the tool entries exposed to the named caller. + /// + /// Most tools use the standard wildcard permission check (`permission -> "*"`). + /// `task` is only included when at least one `mode: all` or `mode: subagent` + /// target remains callable after applying `permission.task`. + #[inline] + pub fn allowed_tools(&self, caller_name: &str) -> Vec { + resolve_allowed_tools(self, caller_name) + } } diff --git a/src/llm-coding-tools-agents/src/runtime/task.rs b/src/llm-coding-tools-agents/src/runtime/task.rs new file mode 100644 index 00000000..ac94d7bd --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/task.rs @@ -0,0 +1,520 @@ +//! Task delegation helpers backed by [`AgentCatalog`]. +//! +//! # Public API +//! - [`summarize_callable_targets`] - Builds summary rows with stable names and descriptions. +//! - [`callable_targets`] - Returns the agents an active agent may delegate to via Task. +//! - [`TaskTargetSummary`] - Stable Task UI metadata for a callable target. + +use super::state::AgentRuntime; +use super::tool_catalog::{ToolCatalogEntry, ToolCatalogKind}; +use crate::{AgentCatalog, AgentConfig, AgentMode, RulesetExt}; +use llm_coding_tools_core::permissions::Ruleset; +use llm_coding_tools_core::tool_names; + +/// Compact metadata used to describe one callable Task target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskTargetSummary { + /// Stable target name. + pub name: Box, + /// Human-readable target description. + pub description: Box, +} + +/// For each agent `caller_name` can delegate to, returns its name and description. +/// Results are in consistent alphabetical order. +/// +/// # Params +/// - `catalog` - All registered agents. +/// - `caller_name` - Name of the agent that wants to delegate. +/// +/// # Returns +/// One [`TaskTargetSummary`] per callable target, sorted by name. Empty if +/// `caller_name` is not in the catalog or no non-primary targets are available. +pub fn summarize_callable_targets( + catalog: &AgentCatalog, + caller_name: &str, +) -> Vec { + let callable = callable_targets(catalog, caller_name); + let mut summaries = Vec::with_capacity(callable.len()); + + // Copy stable Task metadata into owned summaries. + for target in callable { + summaries.push(TaskTargetSummary { + name: target.name.clone(), + description: target.description.clone(), + }); + } + + summaries +} + +/// Returns the agents that `caller_name` (the currently running agent) may delegate to via Task. +/// +/// # Params +/// - `catalog` - All registered agents. +/// - `caller_name` - Name of the agent that wants to delegate. +/// +/// # Returns +/// Agents the caller may delegate to, sorted alphabetically. Empty if `caller_name` +/// is not in the catalog or no non-primary targets are available. +/// +/// When the caller does not define `permission.task`, Task defaults to all +/// `mode: all` and `mode: subagent` targets for OpenCode compatibility. When +/// `permission.task` is present, its rules filter target names with the normal +/// last-match-wins permission semantics. +pub fn callable_targets<'a>(catalog: &'a AgentCatalog, caller_name: &str) -> Vec<&'a AgentConfig> { + let Some(caller) = catalog.by_name(caller_name) else { + return Vec::new(); + }; + + let agents = sorted_agents(catalog); + let task_rules = Ruleset::from_permission_config(&caller.permission); + let has_explicit_task_permission = caller.permission.contains_key(tool_names::TASK); + let mut targets = Vec::with_capacity(agents.len()); + + // Keep only non-primary targets that survive `permission.task` filtering. + for target in agents { + if target_is_callable(target, &task_rules, has_explicit_task_permission) { + targets.push(target); + } + } + + targets +} + +fn sorted_agents(catalog: &AgentCatalog) -> Vec<&AgentConfig> { + let mut agents: Vec<_> = catalog.iter().collect(); + agents.sort_unstable_by(|left, right| left.name.as_ref().cmp(right.name.as_ref())); + agents +} + +fn target_is_callable( + target: &AgentConfig, + task_rules: &Ruleset, + has_explicit_task_permission: bool, +) -> bool { + matches!(target.mode, AgentMode::All | AgentMode::Subagent) + && (!has_explicit_task_permission + || task_rules.is_allowed(tool_names::TASK, target.name.as_ref())) +} + +pub(super) fn resolve_allowed_tools( + runtime: &AgentRuntime, + caller_name: &str, +) -> Vec { + let Some(caller) = runtime.catalog().by_name(caller_name) else { + return Vec::new(); + }; + + let agents = sorted_agents(runtime.catalog()); + let task_rules = Ruleset::from_permission_config(&caller.permission); + let has_explicit_task_permission = caller.permission.contains_key(tool_names::TASK); + let mut task_is_callable = false; + + // Expose `task` only when at least one delegated target remains callable. + for target in agents { + if target_is_callable(target, &task_rules, has_explicit_task_permission) { + task_is_callable = true; + break; + } + } + + collect_allowed_tools(runtime.tools(), &task_rules, task_is_callable) +} + +fn collect_allowed_tools( + tools: &[ToolCatalogEntry], + task_rules: &Ruleset, + task_is_callable: bool, +) -> Vec { + let mut allowed = Vec::with_capacity(tools.len()); + + for entry in tools { + let is_allowed = match entry.kind { + // Task is target-scoped, so wildcard tool filtering alone is not enough. + ToolCatalogKind::Task => task_is_callable, + _ => task_rules.is_allowed(entry.name, "*"), + }; + + if is_allowed { + allowed.push(*entry); + } + } + + allowed +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::PermissionRule; + use crate::{AgentConfig, AgentMode, AgentRuntimeBuilder}; + use ahash::AHashMap; + use indexmap::IndexMap; + use llm_coding_tools_core::permissions::PermissionAction; + + fn agent( + name: &str, + mode: AgentMode, + description: &str, + permission: IndexMap, + ) -> AgentConfig { + AgentConfig { + name: name.into(), + mode, + description: description.into(), + model: None, + hidden: false, + temperature: None, + top_p: None, + permission, + options: AHashMap::new(), + prompt: Default::default(), + } + } + + fn allow_tools(names: &[&str]) -> IndexMap { + names + .iter() + .map(|n| ((*n).into(), PermissionRule::Action(PermissionAction::Allow))) + .collect() + } + + fn pattern_task(patterns: &[(&str, PermissionAction)]) -> IndexMap { + let mut map = IndexMap::new(); + for (pattern, action) in patterns { + map.insert(pattern.to_string(), *action); + } + IndexMap::from([("task".into(), PermissionRule::Pattern(map))]) + } + + fn deny_task() -> IndexMap { + IndexMap::from([( + "task".into(), + PermissionRule::Action(PermissionAction::Deny), + )]) + } + + /// Unknown callers yield no targets. + #[test] + fn callable_targets_returns_empty_for_unknown_caller() { + let catalog = AgentCatalog::from_entries([agent( + "agent-a", + AgentMode::All, + "Agent A", + allow_tools(&[tool_names::TASK]), + )]); + + let targets = callable_targets(&catalog, "nonexistent"); + assert!(targets.is_empty()); + } + + /// Primary-mode agents are excluded from callable targets. + #[test] + fn callable_targets_filters_primary_targets_even_when_allowed() { + let catalog = AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + "Caller", + allow_tools(&[tool_names::TASK]), + ), + agent("all-target", AgentMode::All, "All Target", IndexMap::new()), + agent( + "subagent-target", + AgentMode::Subagent, + "Subagent Target", + IndexMap::new(), + ), + agent( + "primary-target", + AgentMode::Primary, + "Primary Target", + IndexMap::new(), + ), + ]); + + let targets = callable_targets(&catalog, "caller"); + let names: Vec<_> = targets.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"all-target")); + assert!(names.contains(&"subagent-target")); + assert!(!names.contains(&"primary-target")); + assert!(names.contains(&"caller")); + } + + /// Self-delegation is allowed when mode and permission both permit. + #[test] + fn callable_targets_keeps_self_when_mode_and_permission_allow_it() { + let catalog = AgentCatalog::from_entries([agent( + "self-agent", + AgentMode::All, + "Self Agent", + allow_tools(&[tool_names::TASK]), + )]); + + let targets = callable_targets(&catalog, "self-agent"); + assert!(targets.iter().any(|t| t.name.as_ref() == "self-agent")); + } + + /// Without explicit `permission.task`, Task defaults to all non-primary targets. + #[test] + fn callable_targets_default_to_all_non_primary_when_task_permission_is_absent() { + let catalog = AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + "Caller", + allow_tools(&[tool_names::READ]), + ), + agent("all-target", AgentMode::All, "All Target", IndexMap::new()), + agent( + "subagent-target", + AgentMode::Subagent, + "Subagent Target", + IndexMap::new(), + ), + agent( + "primary-target", + AgentMode::Primary, + "Primary Target", + IndexMap::new(), + ), + ]); + + let targets = callable_targets(&catalog, "caller"); + let names: Vec<_> = targets.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["all-target", "subagent-target"]); + } + + /// Wildcard patterns are evaluated; specific patterns override wildcards. + #[test] + fn callable_targets_honor_wildcard_and_specific_rules_in_order() { + let catalog = AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + "Caller", + pattern_task(&[ + ("*", PermissionAction::Deny), + ("review-*", PermissionAction::Allow), + ]), + ), + agent( + "review-agent", + AgentMode::All, + "Review Agent", + IndexMap::new(), + ), + agent( + "other-agent", + AgentMode::All, + "Other Agent", + IndexMap::new(), + ), + ]); + + let targets = callable_targets(&catalog, "caller"); + let names: Vec<_> = targets.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"review-agent")); + assert!(!names.contains(&"other-agent")); + } + + /// Later patterns take precedence (last-match-wins). + #[test] + fn callable_targets_use_last_match_wins_precedence() { + let catalog = AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + "Caller", + pattern_task(&[ + ("review-*", PermissionAction::Allow), + ("*", PermissionAction::Deny), + ]), + ), + agent( + "review-agent", + AgentMode::All, + "Review Agent", + IndexMap::new(), + ), + agent( + "other-agent", + AgentMode::All, + "Other Agent", + IndexMap::new(), + ), + ]); + + let targets = callable_targets(&catalog, "caller"); + let names: Vec<_> = targets.iter().map(|t| t.name.as_ref()).collect(); + assert!(!names.contains(&"review-agent")); + assert!(!names.contains(&"other-agent")); + } + + /// OpenCode-style task allowlists support both exact names and wildcards. + #[test] + fn callable_targets_support_opencode_style_allowlists() { + let catalog = AgentCatalog::from_entries([ + agent( + "orchestrator", + AgentMode::Primary, + "Orchestrator", + pattern_task(&[ + ("*", PermissionAction::Deny), + ("commit", PermissionAction::Allow), + ("coderabbit", PermissionAction::Allow), + ("orchestrator-*", PermissionAction::Allow), + ]), + ), + agent("commit", AgentMode::Subagent, "Commit", IndexMap::new()), + agent( + "coderabbit", + AgentMode::Subagent, + "CodeRabbit", + IndexMap::new(), + ), + agent( + "orchestrator-runner", + AgentMode::Subagent, + "Runner", + IndexMap::new(), + ), + agent( + "orchestrator-builder", + AgentMode::Subagent, + "Builder", + IndexMap::new(), + ), + agent("general", AgentMode::Subagent, "General", IndexMap::new()), + agent( + "primary-only", + AgentMode::Primary, + "Primary Only", + IndexMap::new(), + ), + ]); + + let targets = callable_targets(&catalog, "orchestrator"); + let names: Vec<_> = targets.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!( + names, + vec![ + "coderabbit", + "commit", + "orchestrator-builder", + "orchestrator-runner", + ] + ); + } + + /// Summaries are alphabetically sorted and preserve target descriptions. + #[test] + fn summaries_are_sorted_and_preserve_target_descriptions() { + let catalog = AgentCatalog::from_entries([ + agent( + "zebra", + AgentMode::All, + "Zebra description", + allow_tools(&[tool_names::READ, tool_names::BASH]), + ), + agent( + "alpha", + AgentMode::All, + "Alpha description", + allow_tools(&[tool_names::WRITE]), + ), + agent( + "caller", + AgentMode::All, + "Caller", + allow_tools(&[tool_names::TASK]), + ), + ]); + + let summaries = summarize_callable_targets(&catalog, "caller"); + + let names: Vec<&str> = summaries.iter().map(|s| s.name.as_ref()).collect(); + assert_eq!(names, vec!["alpha", "caller", "zebra"]); + + let alpha_summary = summaries + .iter() + .find(|s| s.name.as_ref() == "alpha") + .unwrap(); + assert_eq!(alpha_summary.description.as_ref(), "Alpha description"); + + let zebra_summary = summaries + .iter() + .find(|s| s.name.as_ref() == "zebra") + .unwrap(); + assert_eq!(zebra_summary.description.as_ref(), "Zebra description"); + } + + /// Explicit deny on `permission.task` suppresses all task targets. + #[test] + fn summaries_return_empty_when_task_is_explicitly_denied() { + let catalog = AgentCatalog::from_entries([ + agent("caller", AgentMode::All, "Caller", deny_task()), + agent("target", AgentMode::All, "Target", IndexMap::new()), + ]); + + let summaries = summarize_callable_targets(&catalog, "caller"); + assert!(summaries.is_empty()); + } + + #[test] + fn allowed_tools_keeps_task_when_a_target_is_callable() { + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + "Caller", + pattern_task(&[ + ("*", PermissionAction::Deny), + ("reader", PermissionAction::Allow), + ]), + ), + agent("reader", AgentMode::Subagent, "Reader", IndexMap::new()), + agent("writer", AgentMode::Subagent, "Writer", IndexMap::new()), + ])) + .build(); + + let tool_names: Vec<_> = runtime + .allowed_tools("caller") + .iter() + .map(|t| t.name) + .collect(); + + assert_eq!(tool_names, vec![tool_names::TASK]); + } + + #[test] + fn allowed_tools_omits_task_when_no_targets_are_callable() { + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + "Caller", + allow_tools(&[tool_names::TASK, tool_names::READ]), + ), + agent( + "primary-target", + AgentMode::Primary, + "Primary", + IndexMap::new(), + ), + ])) + .build(); + + let tool_names: Vec<_> = runtime + .allowed_tools("caller") + .iter() + .map(|t| t.name) + .collect(); + + assert!(tool_names.contains(&tool_names::READ)); + assert!(!tool_names.contains(&tool_names::TASK)); + } +} diff --git a/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs b/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs index 9aa8c464..b40adf55 100644 --- a/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs +++ b/src/llm-coding-tools-agents/src/runtime/tool_catalog.rs @@ -9,7 +9,7 @@ //! - [`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. +//! todowrite, task. use llm_coding_tools_core::tool_names; @@ -51,9 +51,11 @@ pub enum ToolCatalogKind { TodoRead, /// Create and update todo items. TodoWrite, + /// Delegate to subagent via Task tool. + Task, } -const DEFAULT_TOOLS: [ToolCatalogEntry; 9] = [ +const DEFAULT_TOOLS: [ToolCatalogEntry; 10] = [ ToolCatalogEntry::new(tool_names::READ, ToolCatalogKind::Read), ToolCatalogEntry::new(tool_names::WRITE, ToolCatalogKind::Write), ToolCatalogEntry::new(tool_names::EDIT, ToolCatalogKind::Edit), @@ -63,6 +65,7 @@ const DEFAULT_TOOLS: [ToolCatalogEntry; 9] = [ ToolCatalogEntry::new(tool_names::WEBFETCH, ToolCatalogKind::WebFetch), ToolCatalogEntry::new(tool_names::TODO_READ, ToolCatalogKind::TodoRead), ToolCatalogEntry::new(tool_names::TODO_WRITE, ToolCatalogKind::TodoWrite), + ToolCatalogEntry::new(tool_names::TASK, ToolCatalogKind::Task), ]; /// Returns the standard tool set. @@ -74,7 +77,6 @@ pub fn default_tools() -> Vec { 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() { @@ -90,19 +92,8 @@ mod tests { ToolCatalogEntry::new(tool_names::WEBFETCH, ToolCatalogKind::WebFetch), ToolCatalogEntry::new(tool_names::TODO_READ, ToolCatalogKind::TodoRead), ToolCatalogEntry::new(tool_names::TODO_WRITE, ToolCatalogKind::TodoWrite), + ToolCatalogEntry::new(tool_names::TASK, ToolCatalogKind::Task), ], ); } - - #[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-core/README.md b/src/llm-coding-tools-core/README.md index fd86b305..f9684729 100644 --- a/src/llm-coding-tools-core/README.md +++ b/src/llm-coding-tools-core/README.md @@ -45,7 +45,7 @@ Canonical tool names are defined in [`tool_names`] ([`read`], [`write`], [`edit` - [`webfetch`] ([`fetch_url`]) - Fetch URL content as text, markdown, or html (requires `tokio` or `blocking`). - [`todoread`] ([`read_todos`]) - Read shared todo state. - [`todowrite`] ([`write_todos`]) - Write and validate shared todo state. -- [`task`] ([`TaskInput`], [`TaskOutput`]) - Standard task payload types used by delegation wrappers. +- [`task`] ([`TaskInput`], [`TaskOutput`], [`TaskSettings`]) - Standard task payload types and shared delegation limits used by runtime wrappers. ### Path safety and sandboxing @@ -238,6 +238,7 @@ let key = resolver.resolve("OPENAI_API_KEY"); [`write_todos`]: crate::write_todos [`TaskInput`]: crate::TaskInput [`TaskOutput`]: crate::TaskOutput +[`TaskSettings`]: crate::TaskSettings [`SystemPromptBuilder`]: crate::SystemPromptBuilder [`track(&mut self, tool: T)`]: crate::SystemPromptBuilder::track [`working_directory(self, path)`]: crate::SystemPromptBuilder::working_directory diff --git a/src/llm-coding-tools-core/src/context/mod.rs b/src/llm-coding-tools-core/src/context/mod.rs index 2fb12246..35989f05 100644 --- a/src/llm-coding-tools-core/src/context/mod.rs +++ b/src/llm-coding-tools-core/src/context/mod.rs @@ -44,6 +44,9 @@ pub const TODO_READ: &str = include_str!("todoread.txt"); /// `todowrite` tool context - managing task lists. pub const TODO_WRITE: &str = include_str!("todowrite.txt"); +/// `task` tool context - delegation guidance. +pub const TASK: &str = include_str!("task.txt"); + /// `webfetch` tool context - URL content retrieval. pub const WEBFETCH: &str = include_str!("webfetch.txt"); @@ -134,6 +137,7 @@ mod tests { !TODO_WRITE.is_empty(), "TODO_WRITE context should not be empty" ); + assert!(!TASK.is_empty(), "TASK context should not be empty"); assert!(!WEBFETCH.is_empty(), "WEBFETCH context should not be empty"); // Path-based tools (absolute variants) @@ -252,4 +256,11 @@ mod tests { "BASH should not contain GitHub CLI section" ); } + + #[test] + fn task_context_mentions_delegation_details() { + assert!(TASK.contains("description")); + assert!(TASK.contains("prompt")); + assert!(TASK.contains("subagent")); + } } diff --git a/src/llm-coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt new file mode 100644 index 00000000..8bfd2e80 --- /dev/null +++ b/src/llm-coding-tools-core/src/context/task.txt @@ -0,0 +1,57 @@ +Launch a new agent to handle complex, multistep tasks autonomously. + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. + +### When to Use the Task Tool +- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(subagent_type="your_agent", description="Check the file", prompt="/check-file path/to/file.py") + +### When NOT to Use the Task Tool +- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly +- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly +- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- Other tasks that are not related to the agent descriptions above + + +### Usage Notes +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. +3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +4. The agent's outputs should generally be trusted +5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. + +### Examples + + +"code-reviewer": use this agent after you are done writing a significant piece of code +"greeting-responder": use this agent when to respond to user greetings with a friendly joke + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the Write tool to write a function that checks if a number is prime +assistant: I'm going to use the Write tool to write the following code: + +function isPrime(n) { + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) { + if (n % i === 0) return false + } + return true +} + + +Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code + +assistant: Now let me use the code-reviewer agent to review the code +assistant: Uses the Task tool to launch the code-reviewer agent + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" + diff --git a/src/llm-coding-tools-core/src/lib.rs b/src/llm-coding-tools-core/src/lib.rs index 0599fccf..1addafc9 100644 --- a/src/llm-coding-tools-core/src/lib.rs +++ b/src/llm-coding-tools-core/src/lib.rs @@ -33,7 +33,7 @@ pub use system_prompt::SystemPromptBuilder; pub use tools::{ edit_file, execute_command, glob_files, grep_search, read_file, read_todos, write_file, write_todos, BashOutput, EditError, GlobOutput, GrepFileMatches, GrepLineMatch, GrepOutput, - TaskInput, TaskOutput, Todo, TodoPriority, TodoState, TodoStatus, + TaskInput, TaskOutput, TaskSettings, Todo, TodoPriority, TodoState, TodoStatus, }; // Re-export webfetch tools (requires tokio or blocking feature) diff --git a/src/llm-coding-tools-core/src/permissions.rs b/src/llm-coding-tools-core/src/permissions.rs index 1a7399a9..0c3d8f56 100644 --- a/src/llm-coding-tools-core/src/permissions.rs +++ b/src/llm-coding-tools-core/src/permissions.rs @@ -205,29 +205,6 @@ impl Ruleset { self.evaluate(permission, subject) == PermissionAction::Allow } - /// Returns only the tool names that are allowed by this ruleset. - /// - /// Each tool is checked with `is_allowed(tool_name, "*")` - the tool name - /// as the permission key and `"*"` as the subject. - /// - /// **Note:** Because this uses `"*"` as the subject, tools with only - /// pattern-specific allow rules (e.g., `Rule::new("bash", "specific-*", Allow)`) - /// won't be included unless there's also a `"*"` pattern allow rule for that tool. - /// - /// # Arguments - /// - /// * `tool_names` - Iterator of tool names to filter - pub fn allowed_tools<'a, I>(&self, tool_names: I) -> Vec - where - I: IntoIterator, - { - tool_names - .into_iter() - .filter(|name| self.is_allowed(name, "*")) - .map(|s| s.to_string()) - .collect() - } - /// Merges another ruleset into this one. /// /// Rules from `other` are appended in order, giving them higher priority @@ -508,30 +485,6 @@ mod tests { assert!(!ruleset.is_allowed("task", "any")); } - #[test] - fn ruleset_allowed_tools_filters_correctly() { - let mut rules = Ruleset::new(); - rules.push(Rule::new("bash", "*", PermissionAction::Allow)); - rules.push(Rule::new("read", "*", PermissionAction::Allow)); - rules.push(Rule::new("write", "*", PermissionAction::Deny)); - - let tools = ["bash", "read", "write", "edit"]; - let allowed = rules.allowed_tools(tools.iter().copied()); - - assert_eq!(allowed.len(), 2); - assert!(allowed.contains(&"bash".to_string())); - assert!(allowed.contains(&"read".to_string())); - } - - #[test] - fn ruleset_allowed_tools_default_deny() { - let rules = Ruleset::new(); - let tools = ["bash", "read"]; - let allowed = rules.allowed_tools(tools.iter().copied()); - - assert!(allowed.is_empty()); - } - #[test] fn ruleset_merge() { let mut base = Ruleset::new(); @@ -558,22 +511,6 @@ mod tests { assert_eq!(combined.evaluate("a", "x"), PermissionAction::Allow); } - #[test] - fn allowed_tools_preserves_original_casing() { - let mut rules = Ruleset::new(); - rules.push(Rule::new("Bash", "*", PermissionAction::Allow)); - rules.push(Rule::new("READ", "*", PermissionAction::Allow)); - - // Input with mixed case - let tools = ["Bash", "READ", "Write"]; - let allowed = rules.allowed_tools(tools.iter().copied()); - - // Output should preserve original casing - assert_eq!(allowed.len(), 2); - assert!(allowed.contains(&"Bash".to_string())); - assert!(allowed.contains(&"READ".to_string())); - } - #[test] fn ruleset_precedence_specific_overrides_wildcard_when_specific_is_last() { let mut ruleset = Ruleset::new(); diff --git a/src/llm-coding-tools-core/src/tools/mod.rs b/src/llm-coding-tools-core/src/tools/mod.rs index 6aa5d4b8..3b4d1bbe 100644 --- a/src/llm-coding-tools-core/src/tools/mod.rs +++ b/src/llm-coding-tools-core/src/tools/mod.rs @@ -19,7 +19,7 @@ pub use edit::{edit_file, EditError}; pub use glob::{glob_files, GlobOutput}; pub use grep::{grep_search, GrepFileMatches, GrepLineMatch, GrepOutput, DEFAULT_MAX_LINE_LENGTH}; pub use read::read_file; -pub use task::{TaskInput, TaskOutput}; +pub use task::{TaskInput, TaskOutput, TaskSettings}; pub use todo::{read_todos, write_todos, Todo, TodoPriority, TodoState, TodoStatus}; pub use write::write_file; diff --git a/src/llm-coding-tools-core/src/tools/task.rs b/src/llm-coding-tools-core/src/tools/task.rs index 2fec95cc..24c5d929 100644 --- a/src/llm-coding-tools-core/src/tools/task.rs +++ b/src/llm-coding-tools-core/src/tools/task.rs @@ -9,6 +9,63 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Shared runtime settings for Task delegation. +/// +/// # Delegation depth +/// +/// `current_depth` starts at `0` for the root agent and increments by `1` for +/// each Task hop. With the default [`TaskSettings::DEFAULT_MAX_DEPTH`] of `3`, three +/// delegated hops are allowed before Task must stop delegating further. +/// +/// | `current_depth` | Allowed? | +/// |-----------------|----------| +/// | `0` | yes | +/// | `1` | yes | +/// | `2` | yes | +/// | `3` | no | +/// +/// This prevents unbounded recursion (e.g. `A -> A -> A -> …`) without +/// rejecting legitimate self-delegation or diamond-shaped call graphs +/// (e.g. `A -> B -> A`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TaskSettings { + max_depth: u8, +} + +impl Default for TaskSettings { + #[inline] + fn default() -> Self { + Self { + max_depth: Self::DEFAULT_MAX_DEPTH, + } + } +} + +impl TaskSettings { + /// Default maximum number of Task delegation hops. + pub const DEFAULT_MAX_DEPTH: u8 = 3; + + /// Creates settings with a custom maximum delegation depth. + /// + /// A value of `0` disables further Task delegation. + #[inline] + pub const fn with_max_depth(max_depth: u8) -> Self { + Self { max_depth } + } + + /// Returns the maximum number of Task delegation hops. + #[inline] + pub const fn max_depth(self) -> u8 { + self.max_depth + } + + /// Returns whether another Task hop is allowed at `current_depth`. + #[inline] + pub const fn allows_delegation(self, current_depth: u8) -> bool { + current_depth < self.max_depth + } +} + /// Input for task execution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskInput { @@ -60,3 +117,17 @@ impl TaskOutput { self } } + +#[cfg(test)] +mod tests { + use super::TaskSettings; + + #[test] + fn task_settings_allow_delegation_only_below_max_depth() { + let settings = TaskSettings::with_max_depth(3); + + assert!(settings.allows_delegation(0)); + assert!(settings.allows_delegation(2)); + assert!(!settings.allows_delegation(3)); + } +} diff --git a/src/llm-coding-tools-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md index 35fc2548..54c49d60 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -92,23 +92,69 @@ Use `SystemPromptBuilder` to track tools and generate context-aware prompts. Con ## Agent Runtime -For catalog-based agent configuration, use `AgentRuntimeExt` to build agents from an [`AgentRuntime`](https://docs.rs/llm-coding-tools-agents/latest/llm_coding_tools_agents/struct.AgentRuntime.html): +For OpenCode-style agent support, use `AgentRuntimeExt` to build agents from an [`AgentRuntime`](https://docs.rs/llm-coding-tools-agents/latest/llm_coding_tools_agents/struct.AgentRuntime.html): ```rust,no_run use llm_coding_tools_serdesai::AgentRuntimeExt; use llm_coding_tools_agents::AgentRuntimeBuilder; -use llm_coding_tools_core::models::ModelCatalog; +use llm_coding_tools_core::{CredentialResolver, models::ModelCatalog}; # fn main() -> Result<(), Box> { # fn get_catalog() -> ModelCatalog { unimplemented!() } let runtime = AgentRuntimeBuilder::new().build(); let catalog = get_catalog(); // Load from models-dev, config file, etc. -let _agent = runtime.build("planner", &catalog)?; +let credentials = CredentialResolver::new(); +let _agent = runtime.build("planner", &catalog, &credentials)?; # Ok(()) # } ``` -This requires the `llm-coding-tools-agents` crate and a `ModelCatalog` for model resolution. +This requires the `llm-coding-tools-agents` crate, a `ModelCatalog` for model resolution, and an application-owned credential resolver. + +### Task Tool + +Use [`AgentRuntimeTaskExt::build_with_task`] to build an agent that can delegate one-shot work to subagents via the Task tool. + +```rust,no_run +use llm_coding_tools_agents::{AgentCatalog, AgentLoader, AgentRuntimeBuilder}; +use llm_coding_tools_core::CredentialResolver; +use llm_coding_tools_models_dev::ModelsDevCatalog; +use llm_coding_tools_serdesai::{AgentDefaults, AgentRuntimeTaskExt}; +use std::{path::PathBuf, sync::Arc}; + +# #[tokio::main] +# async fn main() -> Result<(), Box> { +let examples_root = PathBuf::from("/path/to/your/project/examples"); +let load_result = ModelsDevCatalog::load().await?; + +let mut catalog = AgentCatalog::new(); +AgentLoader::new().add_directory(&mut catalog, &examples_root)?; + +let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults::with_model("synthetic/hf:zai-org/GLM-4.7")) + // .max_task_depth(5) // Optional: defaults to 3 Task hops + .build(); + +let credentials = Arc::new(CredentialResolver::new()); +let agent = runtime.build_with_task( + "orchestrator", + Arc::new(load_result.catalog), + credentials, +)?; +# Ok(()) +# } +``` + +This requires the `llm-coding-tools-models-dev` crate; the example uses `ModelsDevCatalog::load()` to obtain a `ModelCatalog` for model resolution. + +Each Task call builds and runs the subagent once, and rejects `session_id`. + +Normal tools default to `deny` when omitted, but omitted `permission.task` +is auto-enabled if any task is callable for OpenCode compatibility. + +Use [`build_agent_with_credentials_and_task`] for the lower-level helper. +See [examples/serdesai-task.rs](examples/serdesai-task.rs). ## Examples @@ -118,6 +164,12 @@ cargo run --example serdesai-basic -p llm-coding-tools-serdesai # Sandboxed file access with allowed::* tools cargo run --example serdesai-sandboxed -p llm-coding-tools-serdesai + +# Markdown agent runtime (no delegation) +cargo run --example serdesai-agents -p llm-coding-tools-serdesai + +# Stateless single-hop Task delegation +cargo run --example serdesai-task -p llm-coding-tools-serdesai ``` ## License diff --git a/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md b/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md new file mode 100644 index 00000000..00ebe167 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md @@ -0,0 +1,22 @@ +--- +name: orchestrator +mode: primary +description: Delegates one stateless read-only job to the reader specialist. +permission: + read: deny + write: deny + edit: deny + glob: deny + grep: deny + bash: deny + webfetch: deny + todoread: deny + todowrite: deny + task: + "*": deny + "reader": allow +--- + +Use the `task` tool exactly once to delegate requested file reads to `reader`. +Answer only from the delegated result. Do not read files yourself, do not invent file contents, +and do not continue prior task sessions. diff --git a/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md b/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md new file mode 100644 index 00000000..7fa6248b --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md @@ -0,0 +1,19 @@ +--- +name: reader +mode: subagent +description: Reads repository files and summarizes the important details. +permission: + read: allow + write: deny + edit: deny + glob: deny + grep: deny + bash: deny + webfetch: deny + todoread: deny + todowrite: deny + task: deny +--- + +Use the `read` tool to inspect every requested file before summarizing it. +If a file cannot be read, say so instead of guessing. Do not delegate. diff --git a/src/llm-coding-tools-serdesai/examples/serdesai-agents.rs b/src/llm-coding-tools-serdesai/examples/serdesai-agents.rs index a2eacc79..9aec9d9e 100644 --- a/src/llm-coding-tools-serdesai/examples/serdesai-agents.rs +++ b/src/llm-coding-tools-serdesai/examples/serdesai-agents.rs @@ -10,6 +10,7 @@ //! cargo run --example serdesai-agents -p llm-coding-tools-serdesai use llm_coding_tools_agents::{AgentCatalog, AgentLoader, AgentRuntimeBuilder}; +use llm_coding_tools_core::CredentialResolver; use llm_coding_tools_models_dev::ModelsDevCatalog; use llm_coding_tools_serdesai::{AgentDefaults, AgentRuntimeExt}; use std::path::PathBuf; @@ -21,10 +22,12 @@ const API_KEY_VALUE: &str = ""; // <-- Set your API key here #[tokio::main] async fn main() -> Result<(), Box> { - unsafe { std::env::set_var(API_KEY_NAME, API_KEY_VALUE) }; - let examples_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"); let readme_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("README.md"); + let mut credentials = CredentialResolver::without_env(); + if !API_KEY_VALUE.is_empty() { + credentials.set_override(API_KEY_NAME, API_KEY_VALUE); + } // Load model catalog from models.dev (online-first with local cache fallback) let load_result = ModelsDevCatalog::load().await?; @@ -45,7 +48,7 @@ async fn main() -> Result<(), Box> { "Loading named agent `{AGENT_NAME}` from {}", examples_root.display() ); - let agent = runtime.build(AGENT_NAME, &load_result.catalog)?; + let agent = runtime.build(AGENT_NAME, &load_result.catalog, &credentials)?; println!( "Built `{AGENT_NAME}` on demand with {} tools.", agent.tools().len() diff --git a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs new file mode 100644 index 00000000..d30de4e8 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs @@ -0,0 +1,268 @@ +//! Stateless Task delegation example using the models.dev catalog. +//! +//! Loads markdown agents from `examples/agents/task-demo/`, builds the primary +//! orchestrator through [`AgentRuntimeTaskExt::build_with_task`], and runs one +//! prompt that should delegate exactly once to `reader`. +//! +//! Run: Edit the API_KEY_NAME and API_KEY_VALUE constants below, then: +//! cargo run --example serdesai-task -p llm-coding-tools-serdesai + +use futures::StreamExt; +use llm_coding_tools_agents::{AgentCatalog, AgentLoader, AgentRuntimeBuilder}; +use llm_coding_tools_core::{CredentialResolver, TaskInput}; +use llm_coding_tools_models_dev::ModelsDevCatalog; +use llm_coding_tools_serdesai::{AgentDefaults, AgentRuntimeTaskExt}; +use serdes_ai::{AgentStreamEvent, UserContent}; +use std::{ + fmt::Write, + io::{self, Write as IoWrite}, + path::PathBuf, + sync::Arc, +}; + +const AGENT_NAME: &str = "orchestrator"; +const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7"; +const API_KEY_NAME: &str = "SYNTHETIC_API_KEY"; +const API_KEY_VALUE: &str = ""; // <-- Set your API key here + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agents_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("agents") + .join("task-demo"); + let readme_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("README.md"); + let mut credentials = CredentialResolver::without_env(); + if !API_KEY_VALUE.is_empty() { + credentials.set_override(API_KEY_NAME, API_KEY_VALUE); + } + + let load_result = ModelsDevCatalog::load().await?; + println!( + "Loaded model catalog from models.dev (source: {:?})", + load_result.source + ); + + let mut catalog = AgentCatalog::new(); + let loader = AgentLoader::new(); + loader.add_file(&mut catalog, agents_dir.join("orchestrator.md"))?; + loader.add_file(&mut catalog, agents_dir.join("reader.md"))?; + + let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults::with_model(MODEL_ID)) + .build(); + + println!( + "Loading named agent `{AGENT_NAME}` from {}", + agents_dir.display() + ); + let agent = runtime.build_with_task( + AGENT_NAME, + Arc::new(load_result.catalog), + Arc::new(credentials), + )?; + println!( + "Built `{AGENT_NAME}` on demand with {} tools.", + agent.tools().len() + ); + + let prompt = format!( + "If the model supports visible reasoning output, think briefly before acting, then ask `reader` to give a short summary of {}.", + readme_path.display(), + ); + let prompt = UserContent::text(prompt); + let prompt_text = render_user_content(&prompt); + + println!("\n=== Transcript (message ids, streamed where possible) ==="); + log_xml(0, "user", &prompt_text); + + let mut stream = agent.run_stream(prompt, ()).await?; + let mut current_message_id = 0u32; + let mut request_count = 0u32; + let mut tool_call_count = 0u32; + // Tracks the currently-open streaming XML tag so we can append deltas without reopening. + let mut open_tag: Option = None; + let mut pending_tool_calls = Vec::with_capacity(4); + + while let Some(event) = stream.next().await { + match event? { + AgentStreamEvent::RequestStart { step } => { + close_stream_xml(&mut open_tag); + current_message_id = step; + request_count = request_count.saturating_add(1); + } + AgentStreamEvent::ThinkingDelta { text } => { + write_stream_delta(&mut open_tag, current_message_id, "thinking", &text); + } + AgentStreamEvent::TextDelta { text } => { + write_stream_delta(&mut open_tag, current_message_id, "assistant", &text); + } + AgentStreamEvent::ToolCallStart { + tool_name, + tool_call_id, + } => { + close_stream_xml(&mut open_tag); + log_xml(current_message_id, "tool", &tool_name); + pending_tool_calls.push(PendingToolCall { + message_id: current_message_id, + tool_name, + tool_call_id, + args: String::new(), + }); + } + AgentStreamEvent::ToolCallDelta { + delta, + tool_call_id, + } => { + // Accumulate streamed JSON args into the matching pending call. + if let Some(call) = + find_pending_tool_call_mut(&mut pending_tool_calls, tool_call_id.as_deref()) + { + call.args.push_str(&delta); + } + } + AgentStreamEvent::ToolCallComplete { tool_call_id, .. } => { + tool_call_count = tool_call_count.saturating_add(1); + if let Some(call) = + take_pending_tool_call(&mut pending_tool_calls, tool_call_id.as_deref()) + { + let tag = if call.tool_name == "task" { + "task-input" + } else { + "tool-input" + }; + let content = render_tool_input(&call.tool_name, &call.args); + log_xml(call.message_id, tag, &content); + } + } + AgentStreamEvent::ResponseComplete { .. } => { + close_stream_xml(&mut open_tag); + } + AgentStreamEvent::RunComplete { .. } => { + close_stream_xml(&mut open_tag); + } + _ => {} + } + } + + close_stream_xml(&mut open_tag); + + println!( + "Root agent activity: {} model requests, {} tool calls", + request_count, tool_call_count + ); + + Ok(()) +} + +fn render_user_content(content: &UserContent) -> String { + match content { + UserContent::Text(text) => text.clone(), + UserContent::Parts(_) => serde_json::to_string_pretty(content) + .expect("user content serialization should succeed"), + } +} + +fn log_xml(message_id: u32, tag: &str, content: &str) { + // Long or multiline content gets block-style tags; short content fits on one line. + if content.contains('\n') || content.len() > 120 { + println!(""); + println!("{content}"); + println!(""); + return; + } + + let mut line = String::with_capacity(content.len() + tag.len() * 2 + 18); + let _ = write!(line, "{content}"); + println!("{line}"); +} + +fn close_stream_xml(open_tag: &mut Option) { + if let Some(tag) = open_tag.take() { + println!(); + println!("", tag.tag); + } +} + +fn write_stream_delta( + open_tag: &mut Option, + message_id: u32, + tag: &'static str, + text: &str, +) { + if text.is_empty() { + return; + } + + // If the message or tag changed, close the previous open tag and start a new one. + let is_same = open_tag + .as_ref() + .is_some_and(|t| t.message_id == message_id && t.tag == tag); + if !is_same { + close_stream_xml(open_tag); + println!(""); + *open_tag = Some(OpenStreamTag { message_id, tag }); + } + + print!("{text}"); + let _ = io::stdout().flush(); +} + +struct OpenStreamTag { + message_id: u32, + tag: &'static str, +} + +struct PendingToolCall { + message_id: u32, + tool_name: String, + tool_call_id: Option, + args: String, +} + +fn find_pending_tool_call_mut<'a>( + pending: &'a mut [PendingToolCall], + tool_call_id: Option<&str>, +) -> Option<&'a mut PendingToolCall> { + // Most providers include a tool_call_id; fall back to the last pending call otherwise. + match tool_call_id { + Some(tool_call_id) => pending + .iter_mut() + .rev() + .find(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), + None => pending.last_mut(), + } +} + +fn take_pending_tool_call( + pending: &mut Vec, + tool_call_id: Option<&str>, +) -> Option { + let index = match tool_call_id { + Some(tool_call_id) => pending + .iter() + .rposition(|call| call.tool_call_id.as_deref() == Some(tool_call_id)), + None => pending.len().checked_sub(1), + }?; + Some(pending.remove(index)) +} + +fn render_tool_input(tool_name: &str, args_text: &str) -> String { + match serde_json::from_str::(args_text) { + Ok(args) if tool_name == "task" => render_task_input(&args), + Ok(args) => { + serde_json::to_string_pretty(&args).expect("tool args serialization should succeed") + } + Err(_) => args_text.to_string(), + } +} + +fn render_task_input(args: &serde_json::Value) -> String { + // Try to decode into the typed TaskInput shape; fall back to raw JSON. + serde_json::from_value::(args.clone()) + .and_then(|input| serde_json::to_string_pretty(&input)) + .unwrap_or_else(|_| { + serde_json::to_string_pretty(args).expect("task args serialization should succeed") + }) +} diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs index faab05d0..b4a64a9a 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs @@ -1,21 +1,21 @@ //! Build SerdesAI agents from an [`AgentRuntime`] catalog. //! -//! Use [`AgentRuntimeExt::build`] for the default environment-backed path, or -//! [`build_agent_with_credentials`] when you want to provide explicit credential -//! overrides. The builder resolves the model, filters tools by permissions, and -//! sets up the system prompt. +//! Use [`AgentRuntimeExt::build`] or [`build_agent_with_credentials`] with an +//! application-provided credential resolver. The builder resolves the model, +//! filters tools by permissions, and sets up the system prompt. use super::model::resolve_model; use super::provider_bridge::build_serdes_model; use crate::agent_ext::AgentBuilderExt; +use crate::task::{TaskHandle, TaskTool}; use crate::{ BashTool, EditTool, GlobTool, GrepTool, ReadTool, SystemPromptBuilder, WebFetchTool, WriteTool, create_todo_tools, }; use llm_coding_tools_agents::{ - AgentRuntime, ModelResolutionError, RulesetExt, ToolCatalogEntry, ToolCatalogKind, + AgentRuntime, ModelResolutionError, TaskTargetSummary, ToolCatalogEntry, ToolCatalogKind, + summarize_callable_targets, }; -use llm_coding_tools_core::permissions::Ruleset; use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; use serdes_ai::{Agent, AgentBuilder}; use serdes_ai_models::BoxedModel; @@ -23,21 +23,27 @@ use serdes_ai_models::BoxedModel; /// SerdesAI-specific runtime extension methods. pub trait AgentRuntimeExt { /// Builds a runnable SerdesAI agent for the named catalog entry. - fn build( + fn build( &self, name: &str, model_catalog: &ModelCatalog, - ) -> Result, AgentBuildError>; + credentials: &C, + ) -> Result, AgentBuildError> + where + C: CredentialLookup; } impl AgentRuntimeExt for AgentRuntime { - fn build( + fn build( &self, name: &str, model_catalog: &ModelCatalog, - ) -> Result, AgentBuildError> { - let credentials = CredentialResolver::new(); - let prepared = prepare_build(self, name, model_catalog, &credentials)?; + credentials: &C, + ) -> Result, AgentBuildError> + where + C: CredentialLookup, + { + let prepared = prepare_build(self, name, model_catalog, credentials)?; let builder = AgentBuilder::<(), String>::from_arc(prepared.model.clone()); Ok(finish_builder(builder, &prepared)?.build()) } @@ -53,12 +59,15 @@ impl AgentRuntimeExt for AgentRuntime { /// Returns [`AgentBuildError`] when the named agent is missing, model selection /// fails, the adapter cannot create one of the requested tools, or the model /// backend rejects the resolved credentials or provider settings. -pub fn build_agent_with_credentials( +pub fn build_agent_with_credentials( runtime: &AgentRuntime, name: &str, model_catalog: &ModelCatalog, - credentials: &impl CredentialLookup, -) -> Result, AgentBuildError> { + credentials: &C, +) -> Result, AgentBuildError> +where + C: CredentialLookup, +{ let prepared = prepare_build(runtime, name, model_catalog, credentials)?; let builder = AgentBuilder::<(), String>::from_arc(prepared.model.clone()); Ok(finish_builder(builder, &prepared)?.build()) @@ -66,7 +75,7 @@ pub fn build_agent_with_credentials( /// Resolved build parameters ready for agent construction. #[derive(Clone)] -struct PreparedBuild { +pub(super) struct PreparedBuild { /// Agent name for [`AgentBuilder::name`]. agent_name: Box, /// Concrete SerdesAI model. @@ -82,21 +91,43 @@ struct PreparedBuild { top_p: Option, /// Permission-filtered tool entries to materialize. tools: Vec, + /// Pre-computed callable Task target summaries for the Task tool description. + callable_target_summaries: Vec, +} + +impl PreparedBuild { + /// Returns the resolved SerdesAI model for builder construction. + #[inline] + pub(super) fn model(&self) -> &BoxedModel { + &self.model + } + + /// Returns the resolved callable Task target summaries. + #[inline] + pub(super) fn callable_target_summaries(&self) -> &[TaskTargetSummary] { + &self.callable_target_summaries + } } /// Resolves model configuration and collects build parameters for an agent. -fn prepare_build( +pub(super) fn prepare_build( runtime: &AgentRuntime, name: &str, model_catalog: &ModelCatalog, - credentials: &impl CredentialLookup, -) -> Result { + credentials: &C, +) -> Result +where + C: CredentialLookup, +{ let agent = runtime .catalog() .by_name(name) .ok_or_else(|| AgentBuildError::UnknownAgent { name: name.into() })?; let resolved = resolve_model(model_catalog, runtime.defaults(), agent)?; let serdes_model = build_serdes_model(model_catalog, &resolved, credentials)?; + let tools = runtime.allowed_tools(name); + let callable_target_summaries = summarize_callable_targets(runtime.catalog(), name); + Ok(PreparedBuild { agent_name: agent.name.clone(), model: serdes_model.model, @@ -107,18 +138,38 @@ fn prepare_build( .or(runtime.defaults().temperature) .map(f64::from), top_p: agent.top_p.or(runtime.defaults().top_p).map(f64::from), - tools: Ruleset::from_permission_config(&agent.permission) - .filter_allowed_tools(runtime.tools()), + tools, + callable_target_summaries, }) } -/// Configures an [`AgentBuilder`] with name, prompt, tools, and sampling parameters. -/// -/// Returns [`AgentBuildError::UnsupportedToolKind`] if a tool kind cannot be materialized. -fn finish_builder( +/// Resolves build parameters for a Task-enabled build at `current_depth`. +pub(super) fn prepare_task_build( + runtime: &AgentRuntime, + name: &str, + model_catalog: &ModelCatalog, + credentials: &C, + current_depth: u8, +) -> Result +where + C: CredentialLookup, +{ + let mut prepared = prepare_build(runtime, name, model_catalog, credentials)?; + if !runtime.task_settings().allows_delegation(current_depth) { + prepared.callable_target_summaries.clear(); + } + Ok(prepared) +} + +/// Attaches the standard runtime tools and prompt contexts without finalizing the builder. +pub(super) fn attach_standard_tools( mut builder: AgentBuilder<(), String>, prepared: &PreparedBuild, -) -> Result, AgentBuildError> { + task_handle: Option<&TaskHandle>, +) -> Result<(AgentBuilder<(), String>, SystemPromptBuilder), AgentBuildError> +where + C: CredentialLookup + Send + Sync + 'static, +{ let mut prompt_builder = SystemPromptBuilder::new().system_prompt(prepared.prompt.as_ref()); let (todo_read, todo_write, _todo_state) = create_todo_tools(); @@ -153,6 +204,17 @@ fn finish_builder( ToolCatalogKind::TodoWrite => { builder = builder.tool(prompt_builder.track(todo_write.clone())) } + ToolCatalogKind::Task => { + if let Some(task_handle) = task_handle + && !prepared.callable_target_summaries().is_empty() + { + builder = builder.tool(prompt_builder.track(TaskTool::new( + prepared.agent_name.as_ref(), + prepared.callable_target_summaries().to_vec(), + (*task_handle).clone(), + ))); + } + } _ => { return Err(AgentBuildError::UnsupportedToolKind { name: entry.name.into(), @@ -161,6 +223,18 @@ fn finish_builder( } } + Ok((builder, prompt_builder)) +} + +/// Configures an [`AgentBuilder`] with name, prompt, tools, and sampling parameters. +/// +/// Returns [`AgentBuildError::UnsupportedToolKind`] if a tool kind cannot be materialized. +fn finish_builder( + builder: AgentBuilder<(), String>, + prepared: &PreparedBuild, +) -> Result, AgentBuildError> { + let (builder, prompt_builder) = + attach_standard_tools::(builder, prepared, None)?; Ok(builder.system_prompt(prompt_builder.build())) } @@ -227,7 +301,7 @@ mod tests { ) -> AgentConfig { AgentConfig { name: name.into(), - mode: AgentMode::All, + mode: AgentMode::Primary, description: format!("{name} description").into(), model: None, hidden: false, @@ -408,4 +482,65 @@ mod tests { assert_eq!(agent.tools().len(), 1); assert_eq!(agent.tools()[0].name(), tool_names::GLOB); } + + /// Creates a subagent config with no model or sampling overrides. + fn subagent( + name: &str, + permission: IndexMap, + prompt: &str, + ) -> AgentConfig { + let mut config = agent(name, permission, prompt); + config.mode = AgentMode::Subagent; + config + } + + #[test] + fn plain_build_omits_task_tool_without_task_handle() { + let credentials = credentials(); + let catalog = catalog(); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + allow_tools(&[tool_names::TASK, tool_names::READ]), + "prompt", + ), + subagent("sub-target", IndexMap::new(), "subagent prompt"), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let prepared = + prepare_build(&runtime, "caller", &catalog, &credentials).expect("should succeed"); + assert!(!prepared.callable_target_summaries.is_empty()); + + let agent = build_with_mock(&prepared, "caller"); + let names: Vec<&str> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(names.contains(&tool_names::READ)); + assert!(!names.contains(&tool_names::TASK)); + } + + #[test] + fn build_skips_task_tool_when_no_callable_targets() { + let credentials = credentials(); + let catalog = catalog(); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([agent( + "solo", + allow_tools(&[tool_names::TASK]), + "prompt", + )])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let prepared = + prepare_build(&runtime, "solo", &catalog, &credentials).expect("should succeed"); + assert!(prepared.callable_target_summaries.is_empty()); + + let agent = build_with_mock(&prepared, "solo"); + let names: Vec<&str> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(!names.contains(&tool_names::TASK)); + } } diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/mod.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/mod.rs index fa3ecbe6..cde3a0ef 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/mod.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/mod.rs @@ -3,14 +3,23 @@ //! The data-only runtime foundation lives in [`llm_coding_tools_agents`]. This //! module re-exports those generic types and adds SerdesAI-specific agent //! building through [`AgentRuntimeExt`] and [`build_agent_with_credentials`], -//! both of which accept a caller-provided [`llm_coding_tools_core::models::ModelCatalog`]. +//! both of which accept caller-provided model catalogs and credential lookups. +//! +//! # Public API +//! - [`AgentRuntimeExt`] - Builds a runnable SerdesAI agent for the named catalog entry. +//! - [`build_agent_with_credentials`] - Builds with explicit caller-provided credentials. +//! - [`AgentRuntimeTaskExt`] - Builds with conditional Task support. +//! - [`build_agent_with_credentials_and_task`] - Task-enabled build with explicit credentials. mod build; mod model; mod provider_bridge; +mod task; pub use build::{AgentBuildError, AgentRuntimeExt, build_agent_with_credentials}; pub use llm_coding_tools_agents::{ AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, ToolCatalogEntry, ToolCatalogKind, default_tools, resolve_model_with_catalog, }; +pub use task::{AgentRuntimeTaskExt, build_agent_with_credentials_and_task}; +pub(crate) use task::{TaskBuildContext, build_task_enabled_agent}; diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs new file mode 100644 index 00000000..637f67aa --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs @@ -0,0 +1,425 @@ +//! Task-enabled SerdesAI runtime builders. +//! +//! # Public API +//! - [`AgentRuntimeTaskExt`] - Builds a runnable agent with conditional Task support. +//! - [`build_agent_with_credentials_and_task`] - Same build path with explicit shared credentials. + +use super::build::{AgentBuildError, attach_standard_tools, prepare_task_build}; +use llm_coding_tools_agents::AgentRuntime; +use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; +use serdes_ai::{Agent, AgentBuilder}; +use std::sync::Arc; + +use crate::task::TaskHandle; + +/// SerdesAI-specific task-enabled runtime extension methods. +pub trait AgentRuntimeTaskExt { + /// Builds a runnable SerdesAI agent that conditionally includes Task delegation. + fn build_with_task( + &self, + name: &str, + model_catalog: Arc, + credentials: Arc, + ) -> Result, AgentBuildError> + where + C: CredentialLookup + Send + Sync + 'static; +} + +impl AgentRuntimeTaskExt for AgentRuntime { + fn build_with_task( + &self, + name: &str, + model_catalog: Arc, + credentials: Arc, + ) -> Result, AgentBuildError> + where + C: CredentialLookup + Send + Sync + 'static, + { + build_agent_with_credentials_and_task(self, name, model_catalog, credentials) + } +} + +/// Shared owned state for builds that may happen later during Task delegation. +#[derive(Clone)] +pub(crate) struct TaskBuildContext +{ + runtime: AgentRuntime, + model_catalog: Arc, + credentials: Arc, +} + +impl TaskBuildContext +where + C: CredentialLookup + Send + Sync + 'static, +{ + /// Returns a reference to the runtime. + #[inline] + pub(crate) fn runtime(&self) -> &AgentRuntime { + &self.runtime + } +} + +#[cfg(test)] +impl TaskBuildContext +where + C: CredentialLookup + Send + Sync + 'static, +{ + /// Creates a new task build context for testing. + pub fn new_for_test( + runtime: AgentRuntime, + model_catalog: Arc, + credentials: Arc, + ) -> Self { + Self { + runtime, + model_catalog, + credentials, + } + } +} + +/// Builds a runnable SerdesAI agent with conditional Task support using shared credentials. +pub fn build_agent_with_credentials_and_task( + runtime: &AgentRuntime, + name: &str, + model_catalog: Arc, + credentials: Arc, +) -> Result, AgentBuildError> +where + C: CredentialLookup + Send + Sync + 'static, +{ + let context = Arc::new(TaskBuildContext { + runtime: runtime.clone(), + model_catalog, + credentials, + }); + build_task_enabled_agent(context, name, 0) +} + +/// Builds one runnable agent using the shared task-enabled build context. +pub(crate) fn build_task_enabled_agent( + context: Arc>, + name: &str, + current_depth: u8, +) -> Result, AgentBuildError> +where + C: CredentialLookup + Send + Sync + 'static, +{ + let prepared = prepare_task_build( + &context.runtime, + name, + context.model_catalog.as_ref(), + context.credentials.as_ref(), + current_depth, + )?; + let builder = AgentBuilder::<(), String>::from_arc(prepared.model().clone()); + let task_handle = TaskHandle::new(context, current_depth); + let (builder, prompt_builder) = attach_standard_tools(builder, &prepared, Some(&task_handle))?; + Ok(builder.system_prompt(prompt_builder.build()).build()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ahash::AHashMap; + use indexmap::IndexMap; + use llm_coding_tools_agents::{ + AgentCatalog, AgentConfig, AgentDefaults, AgentMode, AgentRuntimeBuilder, PermissionRule, + }; + use llm_coding_tools_core::CredentialResolver; + use llm_coding_tools_core::models::{ + Modality, ModelCatalog, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource, + ProviderSource, ProviderType, + }; + use llm_coding_tools_core::permissions::PermissionAction; + use llm_coding_tools_core::tool_names; + + fn agent( + name: &str, + mode: AgentMode, + permission: IndexMap, + prompt: &str, + ) -> AgentConfig { + AgentConfig { + name: name.into(), + mode, + description: format!("{name} description").into(), + model: None, + hidden: false, + temperature: None, + top_p: None, + permission, + options: AHashMap::new(), + prompt: prompt.into(), + } + } + + fn allow_tools(names: &[&str]) -> IndexMap { + names + .iter() + .map(|n| ((*n).into(), PermissionRule::Action(PermissionAction::Allow))) + .collect() + } + + fn pattern_task(patterns: &[(&str, PermissionAction)]) -> IndexMap { + let mut map = IndexMap::new(); + for (pattern, action) in patterns { + map.insert(pattern.to_string(), *action); + } + IndexMap::from([(tool_names::TASK.into(), PermissionRule::Pattern(map))]) + } + + fn catalog() -> ModelCatalog { + let providers = vec![ProviderSource::new( + "openrouter", + ProviderInfo { + api_url: "https://openrouter.ai/api/v1".into(), + env_vars: vec!["OPENROUTER_API_KEY".into()], + api_type: ProviderType::OpenRouter, + }, + )]; + let info = ModelInfo { + modalities: Modality::TEXT, + max_input: 128_000, + max_output: 16_384, + temperature: Some(1.0), + top_p: Some(0.95), + }; + let models: Vec> = + [("openai/gpt-4.1-mini", info), ("openai/gpt-4o", info)] + .into_iter() + .map(|(key, i)| ProviderModelSource::new(ProviderIdx::new(0), key, i)) + .collect(); + ModelCatalog::build(&providers, &models).expect("catalog fixture should build") + } + + fn credentials() -> Arc> { + let mut resolver = CredentialResolver::without_env(); + resolver.set_override("OPENROUTER_API_KEY", "test-key"); + Arc::new(resolver) + } + + #[test] + fn build_task_enabled_agent_skips_task_tool_when_no_targets_are_callable() { + let credentials = credentials(); + let model_catalog = Arc::new(catalog()); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + allow_tools(&[tool_names::READ]), + "prompt", + ), + agent("other", AgentMode::Primary, allow_tools(&[]), "prompt"), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let context = Arc::new(TaskBuildContext { + runtime, + model_catalog, + credentials, + }); + + let agent = build_task_enabled_agent(context, "caller", 0).expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(!tool_names.contains(&tool_names::TASK)); + } + + #[test] + fn build_task_enabled_agent_attaches_task_when_callable_targets_exist() { + let credentials = credentials(); + let model_catalog = Arc::new(catalog()); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + allow_tools(&[tool_names::TASK, tool_names::READ]), + "prompt", + ), + agent( + "target", + AgentMode::All, + allow_tools(&[tool_names::WRITE]), + "prompt", + ), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let context = Arc::new(TaskBuildContext { + runtime, + model_catalog, + credentials, + }); + + let agent = build_task_enabled_agent(context, "caller", 0).expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(tool_names.contains(&tool_names::TASK)); + assert!(tool_names.contains(&tool_names::READ)); + } + + #[test] + fn build_task_enabled_agent_attaches_task_when_task_permission_is_target_scoped() { + let credentials = credentials(); + let model_catalog = Arc::new(catalog()); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + pattern_task(&[ + ("*", PermissionAction::Deny), + ("reader", PermissionAction::Allow), + ]), + "prompt", + ), + agent("reader", AgentMode::Subagent, allow_tools(&[]), "prompt"), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let context = Arc::new(TaskBuildContext { + runtime, + model_catalog, + credentials, + }); + + let agent = build_task_enabled_agent(context, "caller", 0).expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert_eq!(tool_names, vec![tool_names::TASK]); + } + + #[test] + fn build_task_enabled_agent_attaches_task_when_permission_task_is_absent() { + let credentials = credentials(); + let model_catalog = Arc::new(catalog()); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + allow_tools(&[tool_names::READ]), + "prompt", + ), + agent("reader", AgentMode::Subagent, allow_tools(&[]), "prompt"), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let context = Arc::new(TaskBuildContext { + runtime, + model_catalog, + credentials, + }); + + // OpenCode-compatible default: omitting `permission.task` still exposes Task. + // Any non-primary callable target keeps delegation available to the caller. + let agent = build_task_enabled_agent(context, "caller", 0).expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(tool_names.contains(&tool_names::READ)); + assert!(tool_names.contains(&tool_names::TASK)); + } + + #[test] + fn build_with_task_omits_task_tool_when_no_targets_are_callable() { + let model_catalog = Arc::new(catalog()); + let credentials = credentials(); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::Primary, + allow_tools(&[tool_names::READ]), + "prompt", + ), + agent("other", AgentMode::Primary, allow_tools(&[]), "prompt"), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .build(); + + let agent = runtime + .build_with_task("caller", model_catalog, credentials) + .expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(!tool_names.contains(&tool_names::TASK)); + } + + #[test] + fn build_task_enabled_agent_omits_task_tool_at_max_depth() { + // Mid-chain: an already-delegated agent (depth=1) at max_task_depth=1 + // must not receive the Task tool. + let credentials = credentials(); + let model_catalog = Arc::new(catalog()); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + allow_tools(&[tool_names::TASK, tool_names::READ]), + "prompt", + ), + agent( + "target", + AgentMode::All, + allow_tools(&[tool_names::WRITE]), + "prompt", + ), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .max_task_depth(1) + .build(); + + let context = Arc::new(TaskBuildContext { + runtime, + model_catalog, + credentials, + }); + + let agent = build_task_enabled_agent(context, "caller", 1).expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(!tool_names.contains(&tool_names::TASK)); + assert!(tool_names.contains(&tool_names::READ)); + } + + #[test] + fn build_with_task_omits_task_tool_when_max_depth_is_zero() { + // Root agent: max_task_depth=0 disables delegation entirely from the start. + let model_catalog = Arc::new(catalog()); + let credentials = credentials(); + + let runtime = AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries([ + agent( + "caller", + AgentMode::All, + allow_tools(&[tool_names::TASK, tool_names::READ]), + "prompt", + ), + agent( + "target", + AgentMode::All, + allow_tools(&[tool_names::WRITE]), + "prompt", + ), + ])) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .max_task_depth(0) + .build(); + + let agent = runtime + .build_with_task("caller", model_catalog, credentials) + .expect("build should succeed"); + let tool_names: Vec<_> = agent.tools().iter().map(|t| t.name()).collect(); + assert!(!tool_names.contains(&tool_names::TASK)); + assert!(tool_names.contains(&tool_names::READ)); + } +} diff --git a/src/llm-coding-tools-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs index 9c9e5f90..cb5ee3b4 100644 --- a/src/llm-coding-tools-serdesai/src/lib.rs +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -8,6 +8,7 @@ pub mod allowed; pub mod bash; mod common; pub mod convert; +pub mod task; pub mod todo; pub mod webfetch; @@ -46,11 +47,14 @@ pub use llm_coding_tools_core::{ }; // Re-export standalone tools and runtime helpers -pub use agent_runtime::{AgentBuildError, AgentRuntimeExt, build_agent_with_credentials}; +pub use agent_runtime::{ + AgentBuildError, AgentRuntimeExt, AgentRuntimeTaskExt, build_agent_with_credentials, + build_agent_with_credentials_and_task, +}; pub use bash::BashTool; pub use llm_coding_tools_agents::{ AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, - ToolCatalogEntry, ToolCatalogKind, default_tools, resolve_model_with_catalog, + TaskSettings, ToolCatalogEntry, ToolCatalogKind, default_tools, resolve_model_with_catalog, }; pub use todo::{TodoReadTool, TodoWriteTool, create_todo_tools}; pub use webfetch::WebFetchTool; diff --git a/src/llm-coding-tools-serdesai/src/task/definition.rs b/src/llm-coding-tools-serdesai/src/task/definition.rs new file mode 100644 index 00000000..abfbae84 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/definition.rs @@ -0,0 +1,126 @@ +//! SerdesAI Task definition helpers. +//! +//! # Public API +//! - [`render_task_targets`] - Renders callable targets for Task tool descriptions. +//! - [`task_tool_definition`] - Builds the adapter-facing Task tool definition. + +use llm_coding_tools_agents::TaskTargetSummary; +use llm_coding_tools_core::tool_names; +use serdes_ai::tools::{SchemaBuilder, ToolDefinition}; + +/// Renders callable target summaries in a stable, user-facing format. +pub fn render_task_targets(targets: &[TaskTargetSummary]) -> String { + if targets.is_empty() { + return "No callable subagents are available.".to_string(); + } + + let mut ordered: Vec<_> = targets.iter().collect(); + ordered.sort_unstable_by(|left, right| left.name.as_ref().cmp(right.name.as_ref())); + + let mut rendered = String::with_capacity(32 + ordered.len() * 64); + rendered.push_str("Available subagents:\n"); + for target in ordered { + rendered.push_str("- "); + rendered.push_str(target.name.as_ref()); + rendered.push_str(": "); + rendered.push_str(target.description.as_ref()); + rendered.push('\n'); + } + rendered +} + +/// Builds a SerdesAI Task definition using the shared target summaries. +pub fn task_tool_definition(targets: &[TaskTargetSummary]) -> ToolDefinition { + let description = format!( + "Delegate a focused job to one of the callable subagents.\n\n{}", + render_task_targets(targets) + ); + let schema = SchemaBuilder::new() + .string("description", "Short 3-5 word task label.", true) + .string("prompt", "Complete task instructions for the delegated agent.", true) + .string("subagent_type", "Name of the subagent to invoke.", true) + .string( + "session_id", + "Optional task session identifier for shared Task compatibility; current delegated requests remain stateless.", + false, + ) + .string( + "command", + "Optional command that triggered this task.", + false, + ) + .build() + .expect("task schema should be valid"); + + ToolDefinition::new(tool_names::TASK, description).with_parameters(schema) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(name: &str, description: &str) -> TaskTargetSummary { + TaskTargetSummary { + name: name.into(), + description: description.into(), + } + } + + #[test] + fn render_task_targets_sorts_output_by_name() { + let targets = vec![ + summary("zebra", "Last alphabetically"), + summary("alpha", "First alphabetically"), + summary("mike", "Middle"), + ]; + + let rendered = render_task_targets(&targets); + let lines: Vec<_> = rendered.lines().skip(1).collect(); // Skip "Available subagents:" + + assert!(lines[0].starts_with("- alpha")); + assert!(lines[1].starts_with("- mike")); + assert!(lines[2].starts_with("- zebra")); + } + + #[test] + fn render_task_targets_shows_only_name_and_description() { + let targets = vec![ + summary("with-task", "Can delegate"), + summary("no-task", "Cannot delegate"), + ]; + + let rendered = render_task_targets(&targets); + + assert!(rendered.contains("- with-task: Can delegate")); + assert!(rendered.contains("- no-task: Cannot delegate")); + assert!(!rendered.contains("tools:")); + } + + #[test] + fn render_task_targets_handles_empty_input_cleanly() { + let targets: Vec = vec![]; + let rendered = render_task_targets(&targets); + assert_eq!(rendered, "No callable subagents are available."); + } + + #[test] + fn task_tool_definition_uses_task_name_and_expected_parameters() { + let targets = vec![summary("test", "Test agent")]; + let definition = task_tool_definition(&targets); + + assert_eq!(definition.name(), tool_names::TASK); + + // Verify description includes all expected parameters + let desc = definition.description(); + assert!(!desc.is_empty()); + } + + #[test] + fn task_tool_definition_keeps_session_id_description_compatibility_only() { + let targets = vec![]; + let definition = task_tool_definition(&targets); + + // The definition should be valid and include task + assert_eq!(definition.name(), tool_names::TASK); + } +} diff --git a/src/llm-coding-tools-serdesai/src/task/handle.rs b/src/llm-coding-tools-serdesai/src/task/handle.rs new file mode 100644 index 00000000..fa0517e7 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -0,0 +1,433 @@ +//! Runs delegated Task requests inside SerdesAI. +//! +//! [`TaskHandle`] checks that the caller is allowed to reach the target agent, +//! then builds and runs that agent with the caller's prompt. +//! Each call is independent — no session state is kept between runs. + +use crate::agent_runtime::{TaskBuildContext, build_task_enabled_agent}; +use llm_coding_tools_agents::{AgentMode, RulesetExt}; +use llm_coding_tools_core::permissions::Ruleset; +use llm_coding_tools_core::{ + CredentialLookup, CredentialResolver, TaskInput, TaskOutput, tool_names, +}; +use serdes_ai::tools::ToolError; +use std::sync::Arc; + +/// Shared Task executor used by the concrete SerdesAI tool. +pub(crate) struct TaskHandle { + context: Arc>, + current_depth: u8, +} + +impl Clone for TaskHandle +where + C: CredentialLookup + Send + Sync + 'static, +{ + fn clone(&self) -> Self { + Self { + context: Arc::clone(&self.context), + current_depth: self.current_depth, + } + } +} + +impl TaskHandle +where + C: CredentialLookup + Send + Sync + 'static, +{ + /// Creates a new handle over the shared task-enabled build context. + #[inline] + pub(crate) fn new(context: Arc>, current_depth: u8) -> Self { + Self { + context, + current_depth, + } + } + + /// Validates the delegation request, builds a task-scoped agent, and runs it. + /// + /// # Params + /// + /// - `caller_name` — name of the initiating agent (must exist in the catalog). + /// - `input` — task payload including the [`subagent_type`](TaskInput::subagent_type) + /// and prompt. + /// + /// # Returns + /// + /// A [`TaskOutput`] wrapping the sub-agent's text response. + /// + /// # Errors + /// + /// Returns [`ToolError::ValidationFailed`] when: + /// - `session_id` is present (task sessions are unsupported). + /// - The caller is already at the configured maximum Task delegation depth. + /// - The caller or target agent is missing from the catalog. + /// - The target uses [`AgentMode::Primary`]. + /// - The caller lacks permission to delegate to the target. + /// + /// Returns [`ToolError::ExecutionFailed`] when the sub-agent fails to build or + /// produce a response. + pub(crate) async fn execute( + &self, + caller_name: &str, + input: TaskInput, + ) -> Result { + if input.session_id.is_some() { + return Err(ToolError::validation_error( + tool_names::TASK, + Some("session_id".to_string()), + "task sessions are not supported by this runtime; omit `session_id`", + )); + } + + let target_name = input.subagent_type.clone(); + let task_settings = self.context.runtime().task_settings(); + if !task_settings.allows_delegation(self.current_depth) { + return Err(ToolError::validation_error( + tool_names::TASK, + None, + format!( + "task delegation depth {} reached runtime max_task_depth {}; cannot delegate to `{}`", + self.current_depth, + task_settings.max_depth(), + target_name, + ), + )); + } + + self.validate_target(caller_name, &target_name)?; + let agent = build_task_enabled_agent::( + self.context.clone(), + target_name.as_str(), + self.current_depth.saturating_add(1), + ) + .map_err(|err| { + ToolError::execution_failed(format!( + "failed to build delegated agent `{}`: {err}", + target_name + )) + })?; + let response = agent.run(input.prompt.as_str(), ()).await.map_err(|err| { + ToolError::execution_failed(format!("delegated agent `{}` failed: {err}", target_name)) + })?; + Ok(TaskOutput::new(response.into_output())) + } + + fn validate_target(&self, caller_name: &str, target_name: &str) -> Result<(), ToolError> { + let catalog = self.context.runtime().catalog(); + let caller = catalog.by_name(caller_name).ok_or_else(|| { + ToolError::execution_failed(format!( + "delegating agent `{caller_name}` disappeared from the runtime catalog" + )) + })?; + let target = catalog.by_name(target_name).ok_or_else(|| { + ToolError::validation_error( + tool_names::TASK, + Some("subagent_type".to_string()), + format!("unknown delegated agent `{target_name}`"), + ) + })?; + + if matches!(target.mode, AgentMode::Primary) { + return Err(ToolError::validation_error( + tool_names::TASK, + Some("subagent_type".to_string()), + format!( + "agent `{target_name}` uses `mode: primary` and cannot be called with task" + ), + )); + } + + // `validate_target` only applies `Ruleset` filtering when `caller.permission` + // explicitly defines `tool_names::TASK`; without that opt-in, non-Primary + // targets remain callable for compatibility, while `AgentMode::Primary` + // targets are always denied above. + let has_explicit_task_permission = caller.permission.contains_key(tool_names::TASK); + if has_explicit_task_permission + && !Ruleset::from_permission_config(&caller.permission) + .is_allowed(tool_names::TASK, target_name) + { + return Err(ToolError::validation_error( + tool_names::TASK, + Some("subagent_type".to_string()), + format!("caller `{caller_name}` is not allowed to delegate to `{target_name}`"), + )); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_runtime::TaskBuildContext; + use ahash::AHashMap; + use indexmap::IndexMap; + use llm_coding_tools_agents::{ + AgentCatalog, AgentConfig, AgentDefaults, AgentMode, AgentRuntimeBuilder, PermissionRule, + }; + use llm_coding_tools_core::CredentialResolver; + use llm_coding_tools_core::models::{ + Modality, ModelCatalog, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource, + ProviderSource, ProviderType, + }; + use llm_coding_tools_core::permissions::PermissionAction; + use llm_coding_tools_core::tool_names; + + fn agent( + name: &str, + mode: AgentMode, + permission: IndexMap, + ) -> AgentConfig { + AgentConfig { + name: name.into(), + mode, + description: format!("{name} description").into(), + model: None, + hidden: false, + temperature: None, + top_p: None, + permission, + options: AHashMap::new(), + prompt: Default::default(), + } + } + + fn allow_tools(names: &[&str]) -> IndexMap { + names + .iter() + .map(|n| ((*n).into(), PermissionRule::Action(PermissionAction::Allow))) + .collect() + } + + fn pattern_task(patterns: &[(&str, PermissionAction)]) -> IndexMap { + let mut map = IndexMap::new(); + for (pattern, action) in patterns { + map.insert(pattern.to_string(), *action); + } + IndexMap::from([("task".into(), PermissionRule::Pattern(map))]) + } + + fn catalog() -> ModelCatalog { + let providers = vec![ProviderSource::new( + "openrouter", + ProviderInfo { + api_url: "https://openrouter.ai/api/v1".into(), + env_vars: vec!["OPENROUTER_API_KEY".into()], + api_type: ProviderType::OpenRouter, + }, + )]; + let info = ModelInfo { + modalities: Modality::TEXT, + max_input: 128_000, + max_output: 16_384, + temperature: Some(1.0), + top_p: Some(0.95), + }; + let models: Vec> = + [("openai/gpt-4.1-mini", info), ("openai/gpt-4o", info)] + .into_iter() + .map(|(key, i)| ProviderModelSource::new(ProviderIdx::new(0), key, i)) + .collect(); + ModelCatalog::build(&providers, &models).expect("catalog fixture should build") + } + + fn credentials() -> Arc> { + let mut resolver = CredentialResolver::without_env(); + resolver.set_override("OPENROUTER_API_KEY", "test-key"); + Arc::new(resolver) + } + + fn runtime_with_agents(agents: Vec) -> AgentRuntimeBuilder { + AgentRuntimeBuilder::new() + .catalog(AgentCatalog::from_entries(agents)) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + } + + fn build_test_context( + runtime: llm_coding_tools_agents::AgentRuntime, + ) -> Arc>> { + Arc::new(TaskBuildContext::new_for_test( + runtime, + Arc::new(catalog()), + credentials(), + )) + } + + #[tokio::test] + async fn validate_target_rejects_unknown_target() { + let runtime = runtime_with_agents(vec![agent( + "caller", + AgentMode::All, + allow_tools(&[tool_names::TASK]), + )]) + .build(); + let context = build_test_context(runtime); + let handle = TaskHandle::new(context, 0); + + let input = TaskInput { + description: "test".into(), + prompt: "test prompt".into(), + subagent_type: "nonexistent".into(), + session_id: None, + command: None, + }; + + let result = handle.execute("caller", input).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + match &err { + ToolError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "task"); + assert!(!errors.is_empty()); + let error_message = &errors[0].message; + assert!(error_message.contains("nonexistent")); + assert!(error_message.contains("unknown")); + } + _ => panic!("Expected ValidationFailed error, got: {:?}", err), + } + } + + #[tokio::test] + async fn validate_target_rejects_primary_target() { + let runtime = runtime_with_agents(vec![ + agent("caller", AgentMode::All, allow_tools(&[tool_names::TASK])), + agent("primary-agent", AgentMode::Primary, allow_tools(&[])), + ]) + .build(); + let context = build_test_context(runtime); + let handle = TaskHandle::new(context, 0); + + let input = TaskInput { + description: "test".into(), + prompt: "test prompt".into(), + subagent_type: "primary-agent".into(), + session_id: None, + command: None, + }; + + let result = handle.execute("caller", input).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + match &err { + ToolError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "task"); + assert!(!errors.is_empty()); + let error_message = &errors[0].message; + assert!(error_message.contains("primary")); + assert!(error_message.contains("mode")); + } + _ => panic!("Expected ValidationFailed error, got: {:?}", err), + } + } + + #[tokio::test] + async fn validate_target_rejects_permission_denied_target() { + let runtime = runtime_with_agents(vec![ + agent( + "caller", + AgentMode::All, + pattern_task(&[("*", PermissionAction::Deny)]), + ), + agent("target", AgentMode::All, allow_tools(&[])), + ]) + .build(); + let context = build_test_context(runtime); + let handle = TaskHandle::new(context, 0); + + let input = TaskInput { + description: "test".into(), + prompt: "test prompt".into(), + subagent_type: "target".into(), + session_id: None, + command: None, + }; + + let result = handle.execute("caller", input).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + match &err { + ToolError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "task"); + assert!(!errors.is_empty()); + let error_message = &errors[0].message; + assert!(error_message.contains("not allowed")); + assert!(error_message.contains("caller")); + } + _ => panic!("Expected ValidationFailed error, got: {:?}", err), + } + } + + #[tokio::test] + async fn execute_rejects_session_id() { + let runtime = runtime_with_agents(vec![ + agent("caller", AgentMode::All, allow_tools(&[tool_names::TASK])), + agent("target", AgentMode::All, allow_tools(&[])), + ]) + .build(); + let context = build_test_context(runtime); + let handle = TaskHandle::new(context, 0); + + let input = TaskInput { + description: "test".into(), + prompt: "test prompt".into(), + subagent_type: "target".into(), + session_id: Some("session-123".into()), + command: None, + }; + + let result = handle.execute("caller", input).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + match &err { + ToolError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "task"); + assert!(!errors.is_empty()); + let error_field = errors[0].field.as_ref().expect("Expected field"); + assert_eq!(error_field, "session_id"); + let error_message = &errors[0].message; + assert!(error_message.contains("not supported")); + assert!(error_message.contains("omit")); + } + _ => panic!("Expected ValidationFailed error, got: {:?}", err), + } + } + + #[tokio::test] + async fn execute_rejects_calls_at_max_task_depth() { + // Defense-in-depth: even if the Task tool were somehow present at max depth, + // execute() rejects the call. + let runtime = runtime_with_agents(vec![ + agent("caller", AgentMode::All, allow_tools(&[tool_names::TASK])), + agent("target", AgentMode::All, allow_tools(&[])), + ]) + .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) + .max_task_depth(0) + .build(); + let context = build_test_context(runtime); + let handle = TaskHandle::new(context, 0); + + let input = TaskInput { + description: "test".into(), + prompt: "test prompt".into(), + subagent_type: "target".into(), + session_id: None, + command: None, + }; + + let result = handle.execute("caller", input).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + match &err { + ToolError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "task"); + assert!(!errors.is_empty()); + let error_message = &errors[0].message; + assert!(error_message.contains("max_task_depth")); + assert!(error_message.contains("target")); + } + _ => panic!("Expected ValidationFailed error, got: {:?}", err), + } + } +} diff --git a/src/llm-coding-tools-serdesai/src/task/mod.rs b/src/llm-coding-tools-serdesai/src/task/mod.rs new file mode 100644 index 00000000..59ee80a3 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/mod.rs @@ -0,0 +1,17 @@ +//! Adapter-facing Task helpers and runtime glue for SerdesAI. +//! +//! # Public API +//! - [`task_tool_definition`] - Builds the Task tool definition and schema. +//! - [`render_task_targets`] - Renders callable targets for Task descriptions. +//! +//! The concrete runtime pieces that execute delegated work stay crate-private so +//! callers use the public task-enabled build APIs instead of constructing Task +//! tools by hand. + +mod definition; +mod handle; +mod tool; + +pub use definition::{render_task_targets, task_tool_definition}; +pub(crate) use handle::TaskHandle; +pub(crate) use tool::TaskTool; diff --git a/src/llm-coding-tools-serdesai/src/task/tool.rs b/src/llm-coding-tools-serdesai/src/task/tool.rs new file mode 100644 index 00000000..d2b752d6 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/tool.rs @@ -0,0 +1,123 @@ +//! Concrete [`Tool`] implementation that exposes the Task tool to the SerdesAI +//! runtime. +//! +//! [`TaskTool`] is constructed per-caller with a set of callable targets and a +//! shared [`TaskHandle`]. Each invocation deserialises a [`TaskInput`], delegates +//! to the handle, and returns the [`TaskOutput`] as JSON. +//! +//! [`Tool`]: serdes_ai::tools::Tool +//! [`TaskHandle`]: crate::task::TaskHandle +//! [`TaskInput`]: llm_coding_tools_core::TaskInput +//! [`TaskOutput`]: llm_coding_tools_core::TaskOutput + +use crate::task::{TaskHandle, task_tool_definition}; +use async_trait::async_trait; +use llm_coding_tools_agents::TaskTargetSummary; +use llm_coding_tools_core::context::ToolContext; +use llm_coding_tools_core::{CredentialLookup, CredentialResolver, TaskInput, tool_names}; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn}; + +/// One-shot Task tool wired into the SerdesAI runtime. +#[derive(Clone)] +pub(crate) struct TaskTool { + caller_name: Box, + targets: Vec, + handle: TaskHandle, +} + +impl TaskTool +where + C: CredentialLookup + Send + Sync + 'static, +{ + /// Creates a new Task tool for one caller and its callable targets. + pub(crate) fn new( + caller_name: impl Into>, + targets: Vec, + handle: TaskHandle, + ) -> Self { + Self { + caller_name: caller_name.into(), + targets, + handle, + } + } +} + +#[async_trait] +impl Tool for TaskTool +where + C: CredentialLookup + Send + Sync + 'static, +{ + fn definition(&self) -> ToolDefinition { + task_tool_definition(&self.targets) + } + + /// Deserialises `args` as [`TaskInput`], delegates to [`TaskHandle::execute`], + /// and returns the result as JSON. + /// + /// # Errors + /// + /// - Returns [`ToolError::ValidationFailed`] when `args` cannot be parsed as + /// a [`TaskInput`]. + /// - Propagates any error from [`TaskHandle::execute`] (validation or + /// execution failures). + /// + /// [`TaskHandle::execute`]: crate::task::TaskHandle::execute + /// [`TaskInput`]: llm_coding_tools_core::TaskInput + /// [`ToolError::ValidationFailed`]: serdes_ai::tools::ToolError + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let input: TaskInput = serde_json::from_value(args) + .map_err(|err| ToolError::validation_error(tool_names::TASK, None, err.to_string()))?; + let output = self + .handle + .execute(self.caller_name.as_ref(), input) + .await?; + let payload = + serde_json::to_value(output).expect("TaskOutput serialization should never fail"); + Ok(ToolReturn::json(payload)) + } +} + +impl ToolContext for TaskTool +where + C: CredentialLookup + Send + Sync + 'static, +{ + const NAME: &'static str = tool_names::TASK; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::TASK + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_coding_tools_core::tool_names; + + fn summary(name: &str, description: &str) -> TaskTargetSummary { + TaskTargetSummary { + name: name.into(), + description: description.into(), + } + } + + #[test] + fn task_tool_definition_matches_target_set() { + let targets = vec![ + summary("alpha", "Alpha agent"), + summary("beta", "Beta agent"), + ]; + + let definition = task_tool_definition(&targets); + assert_eq!(definition.name(), tool_names::TASK); + assert!(!definition.description().is_empty()); + assert!(definition.description().contains("alpha")); + assert!(definition.description().contains("beta")); + } + + #[test] + fn task_tool_name_and_context_match() { + assert_eq!(TaskTool::::NAME, tool_names::TASK); + assert!(!llm_coding_tools_core::context::TASK.is_empty()); + } +}