From e954887ef258e3f22cab7ce9fe60b869fa508d40 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 15 Mar 2026 03:36:49 +0000 Subject: [PATCH 01/18] Added: Stateless Task delegation foundations across three crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core infrastructure for stateless task delegation, enabling agents to discover callable targets and render task messages without maintaining runtime state. Changes: - llm-coding-tools-core: Added TASK context with delegation guidance text - llm-coding-tools-agents: Created task.rs with callable-target discovery and caching - llm-coding-tools-serdesai: Added task module with definition and message helpers - Added TaskTargetSummary struct with name, description, tools, and has_task - Used caching to avoid O(n²) complexity in has_task computation - Added 14 inline tests covering edge cases for all three crates Benefits: - Enables stateless task delegation without runtime state management - Provides consistent callable-target discovery with O(n) complexity - Follows existing context pattern for TASK constant - All new code includes comprehensive test coverage --- src/llm-coding-tools-agents/src/lib.rs | 5 +- .../src/runtime/mod.rs | 7 + .../src/runtime/task.rs | 415 ++++++++++++++++++ src/llm-coding-tools-core/src/context/mod.rs | 11 + .../src/context/task.txt | 57 +++ src/llm-coding-tools-serdesai/src/lib.rs | 1 + .../src/task/definition.rs | 126 ++++++ .../src/task/message.rs | 76 ++++ src/llm-coding-tools-serdesai/src/task/mod.rs | 12 + 9 files changed, 708 insertions(+), 2 deletions(-) create mode 100644 src/llm-coding-tools-agents/src/runtime/task.rs create mode 100644 src/llm-coding-tools-core/src/context/task.txt create mode 100644 src/llm-coding-tools-serdesai/src/task/definition.rs create mode 100644 src/llm-coding-tools-serdesai/src/task/message.rs create mode 100644 src/llm-coding-tools-serdesai/src/task/mod.rs diff --git a/src/llm-coding-tools-agents/src/lib.rs b/src/llm-coding-tools-agents/src/lib.rs index 875a1f5f..ea58bba7 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, + TaskTargetSummary, ToolCatalogEntry, ToolCatalogKind, }; pub use types::{ parse_model_parts, AgentConfig, AgentLoadError, AgentLoadResult, AgentMode, PermissionRule, diff --git a/src/llm-coding-tools-agents/src/runtime/mod.rs b/src/llm-coding-tools-agents/src/runtime/mod.rs index 45539b5b..95396f9e 100644 --- a/src/llm-coding-tools-agents/src/runtime/mod.rs +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -15,6 +15,11 @@ //! - [`ToolCatalogKind`] - Which tools are available //! - [`default_tools()`] - The standard tool set (read, write, edit, glob, grep, bash, webfetch, todo) //! +//! Task delegation: +//! - [`callable_targets()`] - Returns the agents the active agent may delegate to +//! - [`summarize_callable_targets()`] - Builds target summaries with names and descriptions +//! - [`TaskTargetSummary`] - Metadata for a callable Task target +//! //! Model resolution: //! - [`ResolvedModel`] - A model identifier that's been validated against your catalog //! - [`resolve_model_with_catalog()`] - Picks which model an agent will use @@ -36,9 +41,11 @@ mod builder; mod model; mod state; +mod task; mod tool_catalog; pub use builder::AgentRuntimeBuilder; 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/task.rs b/src/llm-coding-tools-agents/src/runtime/task.rs new file mode 100644 index 00000000..5a2cb21e --- /dev/null +++ b/src/llm-coding-tools-agents/src/runtime/task.rs @@ -0,0 +1,415 @@ +//! Task delegation helpers backed by [`AgentCatalog`]. +//! +//! # Public API +//! - [`callable_targets`] - Returns the agents an active agent may delegate to via Task. +//! - [`TaskTargetSummary`] - Stable Task UI metadata for a callable target. +//! - [`summarize_callable_targets`] - Builds summary rows with stable names and descriptions. + +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, +} + +/// 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); + collect_callable_targets(&agents, caller) +} + +/// 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()); + + for target in callable { + summaries.push(TaskTargetSummary { + name: target.name.clone(), + description: target.description.clone(), + }); + } + + summaries +} + +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 collect_callable_targets<'a>( + agents: &[&'a AgentConfig], + caller: &AgentConfig, +) -> Vec<&'a AgentConfig> { + 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()); + for target in agents { + if !matches!(target.mode, AgentMode::All | AgentMode::Subagent) { + continue; + } + + if !has_explicit_task_permission + || task_rules.is_allowed(tool_names::TASK, target.name.as_ref()) + { + targets.push(*target); + } + } + targets +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::PermissionRule; + use crate::{AgentConfig, AgentMode}; + 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()); + } +} diff --git a/src/llm-coding-tools-core/src/context/mod.rs b/src/llm-coding-tools-core/src/context/mod.rs index 2fb12246..7a485352 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 - stateless 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_stateless_delegation() { + assert!(TASK.contains("stateless")); + assert!(TASK.contains("description")); + assert!(TASK.contains("prompt")); + } } 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..49d0960a --- /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(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-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs index 9c9e5f90..e3b4ca20 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; 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/message.rs b/src/llm-coding-tools-serdesai/src/task/message.rs new file mode 100644 index 00000000..23f2363a --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/message.rs @@ -0,0 +1,76 @@ +//! Delegated-message helpers for SerdesAI Task execution. +//! +//! # Public API +//! - [`build_delegated_message`] - Builds the one-shot message sent to a delegated agent. + +use llm_coding_tools_core::TaskInput; + +/// Builds the stateless delegated message body for [`TaskInput`]. +/// +/// # Stateless Design +/// +/// This helper intentionally omits `session_id` from the rendered message because +/// delegated requests in this implementation are explicitly stateless. +/// Include all necessary context in `prompt` instead. +pub fn build_delegated_message(input: &TaskInput) -> String { + let extra = input + .command + .as_ref() + .map_or(0, |command| command.len() + 32); + let mut message = + String::with_capacity(input.description.len() + input.prompt.len() + extra + 160); + message.push_str("This is a delegated task. Treat it as a stateless, one-shot request.\n"); + message.push_str("Do not assume any prior conversation history or shared working state.\n\n"); + message.push_str("Task summary: "); + message.push_str(&input.description); + if let Some(command) = &input.command { + message.push_str("\nTriggering command: "); + message.push_str(command); + } + message.push_str("\n\nTask prompt:\n"); + message.push_str(&input.prompt); + message +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(description: &str, prompt: &str, command: Option<&str>) -> TaskInput { + TaskInput { + description: description.into(), + prompt: prompt.into(), + subagent_type: "test-agent".into(), + session_id: None, + command: command.map(|c| c.into()), + } + } + + #[test] + fn build_delegated_message_includes_stateless_header_description_and_prompt() { + let input = input("Fix bug", "Please fix the memory leak", None); + let message = build_delegated_message(&input); + + assert!(message.contains("stateless")); + assert!(message.contains("one-shot")); + assert!(message.contains("Task summary: Fix bug")); + assert!(message.contains("Task prompt:")); + assert!(message.contains("Please fix the memory leak")); + } + + #[test] + fn build_delegated_message_omits_triggering_command_when_absent() { + let input = input("Do work", "Work content", None); + let message = build_delegated_message(&input); + + assert!(!message.contains("Triggering command:")); + } + + #[test] + fn build_delegated_message_includes_triggering_command_when_present() { + let input = input("Do work", "Work content", Some("fix --urgent")); + let message = build_delegated_message(&input); + + assert!(message.contains("Triggering command: fix --urgent")); + } +} 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..95178faa --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/mod.rs @@ -0,0 +1,12 @@ +//! Adapter-facing Task helpers for SerdesAI. +//! +//! # Public API +//! - [`task_tool_definition`] - Builds the Task definition and schema. +//! - [`render_task_targets`] - Renders callable targets for Task descriptions. +//! - [`build_delegated_message`] - Builds the stateless delegated prompt body. + +mod definition; +mod message; + +pub use definition::{render_task_targets, task_tool_definition}; +pub use message::build_delegated_message; From 91c3e0855f6275d9157bd48cd28a25435a86b565 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 15 Mar 2026 20:33:07 +0000 Subject: [PATCH 02/18] Added: Wire Task tool into default tool catalog and agent build path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task is now a first-class catalog entry flowing through the normal permission filter. The build path attaches a stub executor that returns a "not yet implemented" error until the real executor lands. Changes: - Added `Task` variant to `ToolCatalogKind` and `DEFAULT_TOOLS` - Added `callable_target_summaries` to `PreparedBuild`, computed in `prepare_build` - Added `ToolCatalogKind::Task` arm in `finish_builder` — skips when no callable targets - Added `StubTaskExecutor` placeholder returning execution_failed error - Removed redundant `default_tools_exclude_task_and_keep_names_unique` test - Fixed `task.txt` to include "stateless, one-shot delegation" in first line Benefits: - Task flows through the same permission pipeline as all other tools - Agents automatically get the Task tool when allowed and callable targets exist --- .../src/runtime/tool_catalog.rs | 21 +--- .../src/agent_runtime/build.rs | 104 +++++++++++++++++- 2 files changed, 105 insertions(+), 20 deletions(-) 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-serdesai/src/agent_runtime/build.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs index faab05d0..cca510f5 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs @@ -12,11 +12,15 @@ use crate::{ BashTool, EditTool, GlobTool, GrepTool, ReadTool, SystemPromptBuilder, WebFetchTool, WriteTool, create_todo_tools, }; +use crate::task::task_tool_definition; use llm_coding_tools_agents::{ - AgentRuntime, ModelResolutionError, RulesetExt, ToolCatalogEntry, ToolCatalogKind, + summarize_callable_targets, AgentRuntime, ModelResolutionError, RulesetExt, TaskTargetSummary, + ToolCatalogEntry, ToolCatalogKind, }; use llm_coding_tools_core::permissions::Ruleset; -use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; +use llm_coding_tools_core::{models::ModelCatalog, CredentialLookup, CredentialResolver}; +use serdes_ai::agent::{RunContext as AgentRunContext, ToolExecutor}; +use serdes_ai::tools::{ToolError, ToolReturn}; use serdes_ai::{Agent, AgentBuilder}; use serdes_ai_models::BoxedModel; @@ -82,6 +86,8 @@ 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, } /// Resolves model configuration and collects build parameters for an agent. @@ -109,6 +115,7 @@ fn prepare_build( 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()), + callable_target_summaries: summarize_callable_targets(runtime.catalog(), name), }) } @@ -153,6 +160,13 @@ fn finish_builder( ToolCatalogKind::TodoWrite => { builder = builder.tool(prompt_builder.track(todo_write.clone())) } + ToolCatalogKind::Task => { + if !prepared.callable_target_summaries.is_empty() { + let definition = + task_tool_definition(&prepared.callable_target_summaries); + builder = builder.tool_with_executor(definition, StubTaskExecutor); + } + } _ => { return Err(AgentBuildError::UnsupportedToolKind { name: entry.name.into(), @@ -164,6 +178,21 @@ fn finish_builder( Ok(builder.system_prompt(prompt_builder.build())) } +struct StubTaskExecutor; + +#[async_trait::async_trait] +impl ToolExecutor<()> for StubTaskExecutor { + async fn execute( + &self, + _args: serde_json::Value, + _ctx: &AgentRunContext<()>, + ) -> Result { + Err(ToolError::execution_failed( + "task tool execution is not yet implemented", + )) + } +} + /// Error returned when a build cannot produce a SerdesAI agent. #[derive(Debug, thiserror::Error)] pub enum AgentBuildError { @@ -189,19 +218,19 @@ pub enum AgentBuildError { #[cfg(test)] mod tests { - use super::{AgentBuildError, prepare_build}; + use super::{prepare_build, AgentBuildError}; 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; + use llm_coding_tools_core::CredentialResolver; use serdes_ai::AgentBuilder; use serdes_ai_models::MockModel; use std::collections::HashSet; @@ -227,7 +256,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 +437,69 @@ 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 build_attaches_task_tool_when_allowed_and_targets_exist() { + 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)); + } } From cc9e8dce2569a3370fe60288907a2d4cb3894242 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 15 Mar 2026 20:39:18 +0000 Subject: [PATCH 03/18] Removed: Delete build_delegated_message wrapper The delegated agent already runs in a fresh session with its own system prompt, so wrapping the prompt with "This is a delegated task..." headers was redundant and wasted tokens. Callers should use input.prompt directly. Changes: - Deleted task/message.rs and build_delegated_message function - Removed re-export from task/mod.rs - Fixed context test that asserted "stateless" on task.txt (never present) Benefits: - Aligns delegation behavior with opencode reference implementation - Eliminates unnecessary prompt wrapping and token overhead --- src/llm-coding-tools-core/src/context/mod.rs | 6 +- .../src/task/message.rs | 76 ------------------- src/llm-coding-tools-serdesai/src/task/mod.rs | 3 - 3 files changed, 3 insertions(+), 82 deletions(-) delete mode 100644 src/llm-coding-tools-serdesai/src/task/message.rs diff --git a/src/llm-coding-tools-core/src/context/mod.rs b/src/llm-coding-tools-core/src/context/mod.rs index 7a485352..35989f05 100644 --- a/src/llm-coding-tools-core/src/context/mod.rs +++ b/src/llm-coding-tools-core/src/context/mod.rs @@ -44,7 +44,7 @@ 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 - stateless delegation guidance. +/// `task` tool context - delegation guidance. pub const TASK: &str = include_str!("task.txt"); /// `webfetch` tool context - URL content retrieval. @@ -258,9 +258,9 @@ mod tests { } #[test] - fn task_context_mentions_stateless_delegation() { - assert!(TASK.contains("stateless")); + 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-serdesai/src/task/message.rs b/src/llm-coding-tools-serdesai/src/task/message.rs deleted file mode 100644 index 23f2363a..00000000 --- a/src/llm-coding-tools-serdesai/src/task/message.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Delegated-message helpers for SerdesAI Task execution. -//! -//! # Public API -//! - [`build_delegated_message`] - Builds the one-shot message sent to a delegated agent. - -use llm_coding_tools_core::TaskInput; - -/// Builds the stateless delegated message body for [`TaskInput`]. -/// -/// # Stateless Design -/// -/// This helper intentionally omits `session_id` from the rendered message because -/// delegated requests in this implementation are explicitly stateless. -/// Include all necessary context in `prompt` instead. -pub fn build_delegated_message(input: &TaskInput) -> String { - let extra = input - .command - .as_ref() - .map_or(0, |command| command.len() + 32); - let mut message = - String::with_capacity(input.description.len() + input.prompt.len() + extra + 160); - message.push_str("This is a delegated task. Treat it as a stateless, one-shot request.\n"); - message.push_str("Do not assume any prior conversation history or shared working state.\n\n"); - message.push_str("Task summary: "); - message.push_str(&input.description); - if let Some(command) = &input.command { - message.push_str("\nTriggering command: "); - message.push_str(command); - } - message.push_str("\n\nTask prompt:\n"); - message.push_str(&input.prompt); - message -} - -#[cfg(test)] -mod tests { - use super::*; - - fn input(description: &str, prompt: &str, command: Option<&str>) -> TaskInput { - TaskInput { - description: description.into(), - prompt: prompt.into(), - subagent_type: "test-agent".into(), - session_id: None, - command: command.map(|c| c.into()), - } - } - - #[test] - fn build_delegated_message_includes_stateless_header_description_and_prompt() { - let input = input("Fix bug", "Please fix the memory leak", None); - let message = build_delegated_message(&input); - - assert!(message.contains("stateless")); - assert!(message.contains("one-shot")); - assert!(message.contains("Task summary: Fix bug")); - assert!(message.contains("Task prompt:")); - assert!(message.contains("Please fix the memory leak")); - } - - #[test] - fn build_delegated_message_omits_triggering_command_when_absent() { - let input = input("Do work", "Work content", None); - let message = build_delegated_message(&input); - - assert!(!message.contains("Triggering command:")); - } - - #[test] - fn build_delegated_message_includes_triggering_command_when_present() { - let input = input("Do work", "Work content", Some("fix --urgent")); - let message = build_delegated_message(&input); - - assert!(message.contains("Triggering command: fix --urgent")); - } -} diff --git a/src/llm-coding-tools-serdesai/src/task/mod.rs b/src/llm-coding-tools-serdesai/src/task/mod.rs index 95178faa..a77a416d 100644 --- a/src/llm-coding-tools-serdesai/src/task/mod.rs +++ b/src/llm-coding-tools-serdesai/src/task/mod.rs @@ -3,10 +3,7 @@ //! # Public API //! - [`task_tool_definition`] - Builds the Task definition and schema. //! - [`render_task_targets`] - Renders callable targets for Task descriptions. -//! - [`build_delegated_message`] - Builds the stateless delegated prompt body. mod definition; -mod message; pub use definition::{render_task_targets, task_tool_definition}; -pub use message::build_delegated_message; From 996005a30e797207f1dc8365a9a226c6246862d8 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 15 Mar 2026 22:22:23 +0000 Subject: [PATCH 04/18] Added: Stateless Task tool execution with credential-injected agent build Replaces the stub Task executor with a working implementation that validates delegation targets and builds sub-agents on demand. The build path now requires callers to provide their own credential source instead of silently reading from the process environment. Changes: - Added TaskTool and TaskHandle for real one-shot task delegation - Added AgentRuntimeTaskExt and task-enabled build API - Changed AgentRuntimeExt::build to require caller-provided credentials - Generalized credential parameters to accept any CredentialLookup - Extracted prepare_build and attach_standard_tools as shared internals - Made PreparedBuild pub(super) with accessor methods for reuse - Replaced StubTaskExecutor with target validation and agent construction - Updated example to use explicit credential overrides instead of env vars Benefits: - Agents can now actually delegate work to sub-agents via the Task tool - Callers retain full control over credential sourcing - Build internals are reusable for both plain and task-enabled paths --- src/llm-coding-tools-serdesai/README.md | 7 +- .../examples/serdesai-agents.rs | 9 +- .../src/agent_runtime/build.rs | 141 ++++--- .../src/agent_runtime/mod.rs | 11 +- .../src/agent_runtime/task.rs | 280 +++++++++++++ src/llm-coding-tools-serdesai/src/lib.rs | 5 +- .../src/task/handle.rs | 368 ++++++++++++++++++ src/llm-coding-tools-serdesai/src/task/mod.rs | 12 +- .../src/task/tool.rs | 123 ++++++ 9 files changed, 888 insertions(+), 68 deletions(-) create mode 100644 src/llm-coding-tools-serdesai/src/agent_runtime/task.rs create mode 100644 src/llm-coding-tools-serdesai/src/task/handle.rs create mode 100644 src/llm-coding-tools-serdesai/src/task/tool.rs diff --git a/src/llm-coding-tools-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md index 35fc2548..d1206a18 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -97,18 +97,19 @@ For catalog-based agent configuration, use `AgentRuntimeExt` to build agents fro ```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. ## Examples 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/src/agent_runtime/build.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs index cca510f5..a0883bf9 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs @@ -1,47 +1,50 @@ //! 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 crate::task::task_tool_definition; use llm_coding_tools_agents::{ - summarize_callable_targets, AgentRuntime, ModelResolutionError, RulesetExt, TaskTargetSummary, - ToolCatalogEntry, ToolCatalogKind, + AgentRuntime, ModelResolutionError, RulesetExt, TaskTargetSummary, ToolCatalogEntry, + ToolCatalogKind, summarize_callable_targets, }; use llm_coding_tools_core::permissions::Ruleset; -use llm_coding_tools_core::{models::ModelCatalog, CredentialLookup, CredentialResolver}; -use serdes_ai::agent::{RunContext as AgentRunContext, ToolExecutor}; -use serdes_ai::tools::{ToolError, ToolReturn}; +use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; use serdes_ai::{Agent, AgentBuilder}; 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()) } @@ -57,12 +60,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()) @@ -70,7 +76,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. @@ -90,19 +96,40 @@ struct PreparedBuild { 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 ruleset = Ruleset::from_permission_config(&agent.permission); + let tools = ruleset.filter_allowed_tools(runtime.tools()); + let callable_target_summaries = summarize_callable_targets(runtime.catalog(), name); + Ok(PreparedBuild { agent_name: agent.name.clone(), model: serdes_model.model, @@ -113,19 +140,20 @@ 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()), - callable_target_summaries: summarize_callable_targets(runtime.catalog(), name), + 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( +/// 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(); @@ -161,10 +189,14 @@ fn finish_builder( builder = builder.tool(prompt_builder.track(todo_write.clone())) } ToolCatalogKind::Task => { - if !prepared.callable_target_summaries.is_empty() { - let definition = - task_tool_definition(&prepared.callable_target_summaries); - builder = builder.tool_with_executor(definition, StubTaskExecutor); + 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(), + ))); } } _ => { @@ -175,22 +207,19 @@ fn finish_builder( } } - Ok(builder.system_prompt(prompt_builder.build())) + Ok((builder, prompt_builder)) } -struct StubTaskExecutor; - -#[async_trait::async_trait] -impl ToolExecutor<()> for StubTaskExecutor { - async fn execute( - &self, - _args: serde_json::Value, - _ctx: &AgentRunContext<()>, - ) -> Result { - Err(ToolError::execution_failed( - "task tool execution is not yet implemented", - )) - } +/// 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())) } /// Error returned when a build cannot produce a SerdesAI agent. @@ -218,19 +247,19 @@ pub enum AgentBuildError { #[cfg(test)] mod tests { - use super::{prepare_build, AgentBuildError}; + use super::{AgentBuildError, prepare_build}; 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; - use llm_coding_tools_core::CredentialResolver; use serdes_ai::AgentBuilder; use serdes_ai_models::MockModel; use std::collections::HashSet; @@ -450,7 +479,7 @@ mod tests { } #[test] - fn build_attaches_task_tool_when_allowed_and_targets_exist() { + fn plain_build_omits_task_tool_without_task_handle() { let credentials = credentials(); let catalog = catalog(); @@ -461,11 +490,7 @@ mod tests { allow_tools(&[tool_names::TASK, tool_names::READ]), "prompt", ), - subagent( - "sub-target", - IndexMap::new(), - "subagent prompt", - ), + subagent("sub-target", IndexMap::new(), "subagent prompt"), ])) .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) .build(); @@ -477,7 +502,7 @@ mod tests { 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)); + assert!(!names.contains(&tool_names::TASK)); } #[test] 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..ca5c18e1 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs @@ -0,0 +1,280 @@ +//! 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_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) +} + +/// Builds one runnable agent using the shared task-enabled build context. +pub(crate) fn build_task_enabled_agent( + context: Arc>, + name: &str, +) -> Result, AgentBuildError> +where + C: CredentialLookup + Send + Sync + 'static, +{ + let prepared = prepare_build( + &context.runtime, + name, + context.model_catalog.as_ref(), + context.credentials.as_ref(), + )?; + let builder = AgentBuilder::<(), String>::from_arc(prepared.model().clone()); + let task_handle = TaskHandle::new(context); + 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 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::All, + allow_tools(&[tool_names::READ]), + "prompt", + ), + agent("other", AgentMode::All, 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").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").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_no_targets_are_callable() { + 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::READ]), + "prompt", + ), + agent("other", AgentMode::All, 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)); + } +} diff --git a/src/llm-coding-tools-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs index e3b4ca20..5f276a75 100644 --- a/src/llm-coding-tools-serdesai/src/lib.rs +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -47,7 +47,10 @@ 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, 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..0cb12cdd --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -0,0 +1,368 @@ +//! 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>, +} + +impl Clone for TaskHandle +where + C: CredentialLookup + Send + Sync + 'static, +{ + fn clone(&self) -> Self { + Self { + context: Arc::clone(&self.context), + } + } +} + +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>) -> Self { + Self { context } + } + + /// 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 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`", + )); + } + + self.validate_target(caller_name, &input.subagent_type)?; + let target_name = input.subagent_type.clone(); + let agent = build_task_enabled_agent::(self.context.clone(), target_name.as_str()) + .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" + ), + )); + } + + 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); + + 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); + + 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); + + 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); + + 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), + } + } +} diff --git a/src/llm-coding-tools-serdesai/src/task/mod.rs b/src/llm-coding-tools-serdesai/src/task/mod.rs index a77a416d..59ee80a3 100644 --- a/src/llm-coding-tools-serdesai/src/task/mod.rs +++ b/src/llm-coding-tools-serdesai/src/task/mod.rs @@ -1,9 +1,17 @@ -//! Adapter-facing Task helpers for SerdesAI. +//! Adapter-facing Task helpers and runtime glue for SerdesAI. //! //! # Public API -//! - [`task_tool_definition`] - Builds the Task definition and schema. +//! - [`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()); + } +} From efd5b57ac174f581f0b05dee4ef9b7ace96bd54b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 15 Mar 2026 23:49:58 +0000 Subject: [PATCH 05/18] Added: Task tool examples and README documentation Ports commit 8792813a onto the current branch with API adaptations for the newer credential-injected build_with_task() signature. Changes: - Added examples/serdesai-task.rs runnable example - Added examples/agents/orchestrator.md primary agent - Added examples/agents/reader.md subagent with read-only tools - Added Task Tool section to README with usage example - Added serdesai-agents and serdesai-task to README Examples list - Reworded Agent Runtime intro to reference OpenCode-style agents Benefits: - End-to-end example of the Task delegation pattern - Clearer naming (orchestrator/reader vs task/file-reader) --- src/llm-coding-tools-serdesai/README.md | 44 ++++++++++++- .../examples/agents/orchestrator.md | 15 +++++ .../examples/agents/reader.md | 15 +++++ .../examples/serdesai-task.rs | 66 +++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/llm-coding-tools-serdesai/examples/agents/orchestrator.md create mode 100644 src/llm-coding-tools-serdesai/examples/agents/reader.md create mode 100644 src/llm-coding-tools-serdesai/examples/serdesai-task.rs diff --git a/src/llm-coding-tools-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md index d1206a18..568c5ddd 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -92,7 +92,7 @@ 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; @@ -111,6 +111,42 @@ let _agent = runtime.build("planner", &catalog, &credentials)?; 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")) + .build(); + +let credentials = Arc::new(CredentialResolver::new()); +let agent = runtime.build_with_task( + "orchestrator", + Arc::new(load_result.catalog), + credentials, +)?; +# Ok(()) +# } +``` + +Each Task call builds and runs the subagent once; `session_id` is rejected. Use [`build_agent_with_credentials_and_task`] for the lower-level helper. See [examples/serdesai-task.rs](examples/serdesai-task.rs). + ## Examples ```bash @@ -119,6 +155,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/orchestrator.md b/src/llm-coding-tools-serdesai/examples/agents/orchestrator.md new file mode 100644 index 00000000..57f697f6 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/orchestrator.md @@ -0,0 +1,15 @@ +--- +name: orchestrator +mode: primary +description: Delegates one stateless read-only job to the reader specialist. +permission: + task: + "*": deny + "reader": allow +--- + +You are the `orchestrator` agent. +Delegate exactly one focused file-inspection task to `reader` when the user needs repository facts. +Pass all required context in that single task call, then answer directly with a concise final summary. +Do not call `task` more than once. +Do not assume session state, continuation, or prior delegated context. diff --git a/src/llm-coding-tools-serdesai/examples/agents/reader.md b/src/llm-coding-tools-serdesai/examples/agents/reader.md new file mode 100644 index 00000000..d76e6ea6 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/reader.md @@ -0,0 +1,15 @@ +--- +name: reader +mode: subagent +description: Reads requested repository files and returns the important details. +permission: + read: allow + glob: allow + grep: allow + task: deny +--- + +You are the `reader` agent. +Use the available read-only tools to inspect the requested repository files and collect the needed facts. +Return a short, direct summary of what you found. +Do not delegate work or assume any prior conversation history. 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..56ee526c --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs @@ -0,0 +1,66 @@ +//! Stateless Task delegation example using the models.dev catalog. +//! +//! Loads markdown agents from `examples/agents/`, 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 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}; + +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 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); + } + + let load_result = ModelsDevCatalog::load().await?; + println!( + "Loaded model catalog from models.dev (source: {:?})", + load_result.source + ); + + let mut catalog = AgentCatalog::new(); + AgentLoader::new().add_directory(&mut catalog, &examples_root)?; + + let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults::with_model(MODEL_ID)) + .build(); + + println!( + "Loading named agent `{AGENT_NAME}` from {}", + examples_root.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!( + "Use a single delegated read-only task to inspect {}. Then explain in three bullets how the task-enabled build flow works, why it lives in SerdesAI, and what v1 Task does not support.", + readme_path.display(), + ); + let response = agent.run(prompt.as_str(), ()).await?; + println!("{}", response.output()); + + Ok(()) +} From 92bfa0570735e298d4f1f06b6f00a37639499c95 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 10:12:34 +0000 Subject: [PATCH 06/18] Fixed: Mismatched XML closing tag in task.txt The closing tag for `` was missing the trailing "s", producing ``. Changes: - Corrected closing tag to `` --- src/llm-coding-tools-core/src/context/task.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt index 49d0960a..95098f8a 100644 --- a/src/llm-coding-tools-core/src/context/task.txt +++ b/src/llm-coding-tools-core/src/context/task.txt @@ -25,7 +25,7 @@ When using the Task tool, you must specify a subagent_type parameter to select w "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" From b8392f86a59eb6158cb70a1fb7c340de67d9f10e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 11:01:59 +0000 Subject: [PATCH 07/18] Added: Configurable max Task delegation depth to prevent unbounded recursion Task delegation now tracks a per-call depth (root starts at 0) and enforces a shared runtime-wide max depth, defaulting to 3 hops. Self- delegation and diamond-shaped call graphs remain allowed; only unbounded recursion is prevented. Changes: - Added TaskSettings (core) with const DEFAULT_MAX_DEPTH of 3 - Added task_settings field and .max_task_depth() / .task_settings() to AgentRuntimeBuilder - Task tool is omitted at build time when depth limit is reached - TaskHandle::execute rejects calls as defense-in-depth if Task tool is present at limit - Re-exported TaskSettings from agents and serdesai crates Benefits: - Prevents A -> A -> A -> ... without blocking legitimate self-delegation or A -> B -> A - Framework-agnostic setting in core; any integration can use TaskSettings - Both build-time and runtime guards ensure the limit is always enforced --- src/llm-coding-tools-agents/README.md | 1 + src/llm-coding-tools-agents/src/lib.rs | 2 +- .../src/runtime/builder.rs | 31 ++++++- .../src/runtime/mod.rs | 2 + .../src/runtime/state.rs | 16 +++- src/llm-coding-tools-core/README.md | 3 +- src/llm-coding-tools-core/src/lib.rs | 2 +- src/llm-coding-tools-core/src/tools/mod.rs | 2 +- src/llm-coding-tools-core/src/tools/task.rs | 71 +++++++++++++++ src/llm-coding-tools-serdesai/README.md | 1 + .../src/agent_runtime/build.rs | 18 ++++ .../src/agent_runtime/task.rs | 89 +++++++++++++++++-- src/llm-coding-tools-serdesai/src/lib.rs | 2 +- .../src/task/handle.rs | 89 ++++++++++++++++--- 14 files changed, 297 insertions(+), 32 deletions(-) diff --git a/src/llm-coding-tools-agents/README.md b/src/llm-coding-tools-agents/README.md index 91bfb8b3..4eb43781 100644 --- a/src/llm-coding-tools-agents/README.md +++ b/src/llm-coding-tools-agents/README.md @@ -64,6 +64,7 @@ loader.add_directory(&mut catalog, "/home/user/.opencode")?; let runtime = AgentRuntimeBuilder::new() .catalog(catalog) .defaults(AgentDefaults::with_model("openai/gpt-4o-mini")) + // .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 .build(); diff --git a/src/llm-coding-tools-agents/src/lib.rs b/src/llm-coding-tools-agents/src/lib.rs index ea58bba7..e027ebcc 100644 --- a/src/llm-coding-tools-agents/src/lib.rs +++ b/src/llm-coding-tools-agents/src/lib.rs @@ -14,7 +14,7 @@ pub use parser::AgentParseError; pub use runtime::{ callable_targets, default_tools, resolve_model_with_catalog, summarize_callable_targets, AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, - TaskTargetSummary, ToolCatalogEntry, ToolCatalogKind, + 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 95396f9e..86602ca4 100644 --- a/src/llm-coding-tools-agents/src/runtime/mod.rs +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -9,6 +9,7 @@ //! - [`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 @@ -45,6 +46,7 @@ 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}; diff --git a/src/llm-coding-tools-agents/src/runtime/state.rs b/src/llm-coding-tools-agents/src/runtime/state.rs index 487bc245..04f9b63f 100644 --- a/src/llm-coding-tools-agents/src/runtime/state.rs +++ b/src/llm-coding-tools-agents/src/runtime/state.rs @@ -1,12 +1,13 @@ -//! 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::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 +32,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 +46,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,6 +69,12 @@ 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] { 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/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/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 568c5ddd..dd9b252c 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -133,6 +133,7 @@ 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()); 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 a0883bf9..db54b5bb 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs @@ -145,6 +145,24 @@ where }) } +/// 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>, diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs index ca5c18e1..1f079ae2 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs @@ -4,9 +4,9 @@ //! - [`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_build}; +use super::build::{attach_standard_tools, prepare_task_build, AgentBuildError}; use llm_coding_tools_agents::AgentRuntime; -use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; +use llm_coding_tools_core::{models::ModelCatalog, CredentialLookup, CredentialResolver}; use serdes_ai::{Agent, AgentBuilder}; use std::sync::Arc; @@ -93,25 +93,27 @@ where model_catalog, credentials, }); - build_task_enabled_agent(context, name) + 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_build( + 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); + 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()) } @@ -124,13 +126,13 @@ mod tests { 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; + use llm_coding_tools_core::CredentialResolver; fn agent( name: &str, @@ -213,7 +215,7 @@ mod tests { credentials, }); - let agent = build_task_enabled_agent(context, "caller").expect("build should succeed"); + 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)); } @@ -247,7 +249,7 @@ mod tests { credentials, }); - let agent = build_task_enabled_agent(context, "caller").expect("build should succeed"); + 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)); @@ -277,4 +279,75 @@ mod tests { 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 5f276a75..cb5ee3b4 100644 --- a/src/llm-coding-tools-serdesai/src/lib.rs +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -54,7 +54,7 @@ pub use agent_runtime::{ 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/handle.rs b/src/llm-coding-tools-serdesai/src/task/handle.rs index 0cb12cdd..3ce3d3dc 100644 --- a/src/llm-coding-tools-serdesai/src/task/handle.rs +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -16,6 +16,7 @@ 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 @@ -25,6 +26,7 @@ where fn clone(&self) -> Self { Self { context: Arc::clone(&self.context), + current_depth: self.current_depth, } } } @@ -35,8 +37,11 @@ where { /// Creates a new handle over the shared task-enabled build context. #[inline] - pub(crate) fn new(context: Arc>) -> Self { - Self { context } + 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. @@ -55,6 +60,7 @@ where /// /// 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. @@ -74,15 +80,33 @@ where )); } - self.validate_target(caller_name, &input.subagent_type)?; let target_name = input.subagent_type.clone(); - let agent = build_task_enabled_agent::(self.context.clone(), target_name.as_str()) - .map_err(|err| { - ToolError::execution_failed(format!( - "failed to build delegated agent `{}`: {err}", - target_name - )) - })?; + 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)) })?; @@ -236,7 +260,7 @@ mod tests { )]) .build(); let context = build_test_context(runtime); - let handle = TaskHandle::new(context); + let handle = TaskHandle::new(context, 0); let input = TaskInput { description: "test".into(), @@ -269,7 +293,7 @@ mod tests { ]) .build(); let context = build_test_context(runtime); - let handle = TaskHandle::new(context); + let handle = TaskHandle::new(context, 0); let input = TaskInput { description: "test".into(), @@ -306,7 +330,7 @@ mod tests { ]) .build(); let context = build_test_context(runtime); - let handle = TaskHandle::new(context); + let handle = TaskHandle::new(context, 0); let input = TaskInput { description: "test".into(), @@ -339,7 +363,7 @@ mod tests { ]) .build(); let context = build_test_context(runtime); - let handle = TaskHandle::new(context); + let handle = TaskHandle::new(context, 0); let input = TaskInput { description: "test".into(), @@ -365,4 +389,41 @@ mod tests { _ => 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), + } + } } From 1af61ba06d7fdfe2ec999732483ee4042f3c3ad3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 13:29:35 +0000 Subject: [PATCH 08/18] Fixed: Formatter Issue --- src/llm-coding-tools-serdesai/src/agent_runtime/task.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs index 1f079ae2..8a7940e3 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs @@ -4,9 +4,9 @@ //! - [`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::{attach_standard_tools, prepare_task_build, AgentBuildError}; +use super::build::{AgentBuildError, attach_standard_tools, prepare_task_build}; use llm_coding_tools_agents::AgentRuntime; -use llm_coding_tools_core::{models::ModelCatalog, CredentialLookup, CredentialResolver}; +use llm_coding_tools_core::{CredentialLookup, CredentialResolver, models::ModelCatalog}; use serdes_ai::{Agent, AgentBuilder}; use std::sync::Arc; @@ -126,13 +126,13 @@ mod tests { 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; - use llm_coding_tools_core::CredentialResolver; fn agent( name: &str, From a7c0f96f40768ab54c292a2a90ed4b1e5a3554d6 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 20:38:56 +0000 Subject: [PATCH 09/18] Changed: Delegate sub-agent task calls as tool-call context instead of user prompts Sub-agents invoked by the task tool previously received the task prompt as a plain user message. Now they receive a synthetic tool-call / tool-return conversation pair, so the sub-agent sees the invocation as a tool result rather than a user prompt. Changes: - Inject synthetic ToolCallPart and ToolReturnPart via message_history - Use RunOptions::skip_user_prompt (serdesAI fork) to suppress the trailing empty user prompt that was previously unavoidable - Added serdesAI fork as submodule pointing to feat/optional-prompt branch - Added [patch.crates-io] entries to build against the local fork Benefits: - Sub-agent sees correct tool-call context instead of a plain user message - Avoids extra API request caused by the mandatory trailing user prompt - Respects provider concurrency limits by eliminating redundant round-trips - Resolves message ordering issues on providers with strict alternation --- .gitmodules | 3 ++ serdesAI | 1 + src/Cargo.lock | 22 ---------- src/Cargo.toml | 9 ++++ .../src/task/handle.rs | 41 +++++++++++++++++-- 5 files changed, 51 insertions(+), 25 deletions(-) create mode 100644 .gitmodules create mode 160000 serdesAI diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ff73e933 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "serdesAI"] + path = serdesAI + url = https://github.com/Sewer56/serdesAI.git diff --git a/serdesAI b/serdesAI new file mode 160000 index 00000000..a52e0bff --- /dev/null +++ b/serdesAI @@ -0,0 +1 @@ +Subproject commit a52e0bff9e1e06cc45a63ee47bdae775ca796252 diff --git a/src/Cargo.lock b/src/Cargo.lock index 0446d225..bb28c92f 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -3065,8 +3065,6 @@ dependencies = [ [[package]] name = "serdes-ai" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62dcf7d035a43aab94b8fed2925faa6f845d49de27066b2c9b07e339b3048a85" dependencies = [ "futures", "serdes-ai-agent", @@ -3085,8 +3083,6 @@ dependencies = [ [[package]] name = "serdes-ai-agent" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95fd65311bcd469934e9cf5b4d10b6296fd9bde944aa2e232b0fedd37cca4aee" dependencies = [ "anyhow", "async-trait", @@ -3108,8 +3104,6 @@ dependencies = [ [[package]] name = "serdes-ai-core" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c75900724c512454172492ffdd9ae24f8ccc5569e812c258a79d4151cd8934c" dependencies = [ "anyhow", "base64", @@ -3128,8 +3122,6 @@ dependencies = [ [[package]] name = "serdes-ai-macros" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bd2f1e7f4f1f9a0a9f8b31ea0bb24b13271dd46817c8b656821701d1e1d4a40" dependencies = [ "darling", "proc-macro2", @@ -3140,8 +3132,6 @@ dependencies = [ [[package]] name = "serdes-ai-models" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbca6da3265b8d1fce6255c4aee81b02ac9d2dba6e93829e09eaf1bc29d2886e" dependencies = [ "anyhow", "async-trait", @@ -3170,8 +3160,6 @@ dependencies = [ [[package]] name = "serdes-ai-output" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c73a180c99d702c59282057d6f993332c8150834017110051f56e272133c54f" dependencies = [ "anyhow", "async-trait", @@ -3191,8 +3179,6 @@ dependencies = [ [[package]] name = "serdes-ai-providers" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d857c9fc39b9c370eb7321fecb253c07a7892a3646c7455968a123da6df5a1d" dependencies = [ "async-trait", "base64", @@ -3213,8 +3199,6 @@ dependencies = [ [[package]] name = "serdes-ai-retries" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf2449d534d7ce2df7d743e61de516df945384aa50024965246ef5dfc638b93" dependencies = [ "anyhow", "async-trait", @@ -3229,8 +3213,6 @@ dependencies = [ [[package]] name = "serdes-ai-streaming" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159b5dfda85e1a886793e0962c6d40581044bb3ca008665b53f75ecb62eb3f74" dependencies = [ "async-trait", "bytes", @@ -3249,8 +3231,6 @@ dependencies = [ [[package]] name = "serdes-ai-tools" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae4c635d97827560acaa8d3af32a78fc50fece538d1e4638c889c7588f490777" dependencies = [ "anyhow", "async-trait", @@ -3269,8 +3249,6 @@ dependencies = [ [[package]] name = "serdes-ai-toolsets" version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e7ab76a1546ce6aa858c7a0fd438dd4235b3927fcf5a907bec26bacb6f2588" dependencies = [ "async-trait", "indexmap", diff --git a/src/Cargo.toml b/src/Cargo.toml index 7429dbb9..a2f9bc91 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -3,6 +3,15 @@ resolver = "2" members = ["llm-coding-tools-core", "llm-coding-tools-serdesai", "llm-coding-tools-agents", "llm-coding-tools-models-dev"] +[patch.crates-io] +serdes-ai = { path = "../serdesAI/serdes-ai" } +serdes-ai-agent = { path = "../serdesAI/serdes-ai-agent" } +serdes-ai-core = { path = "../serdesAI/serdes-ai-core" } +serdes-ai-models = { path = "../serdesAI/serdes-ai-models" } +serdes-ai-providers = { path = "../serdesAI/serdes-ai-providers" } +serdes-ai-streaming = { path = "../serdesAI/serdes-ai-streaming" } +serdes-ai-tools = { path = "../serdesAI/serdes-ai-tools" } + # Profile Build [profile.profile] inherits = "release" diff --git a/src/llm-coding-tools-serdesai/src/task/handle.rs b/src/llm-coding-tools-serdesai/src/task/handle.rs index 3ce3d3dc..a56e7f53 100644 --- a/src/llm-coding-tools-serdesai/src/task/handle.rs +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -11,6 +11,10 @@ use llm_coding_tools_core::{ CredentialLookup, CredentialResolver, TaskInput, TaskOutput, tool_names, }; use serdes_ai::tools::ToolError; +use serdes_ai::{ + ModelRequest, ModelRequestPart, ModelResponse, ModelResponsePart, RunOptions, ToolCallPart, + ToolReturnPart, +}; use std::sync::Arc; /// Shared Task executor used by the concrete SerdesAI tool. @@ -107,9 +111,40 @@ where target_name )) })?; - let response = agent.run(input.prompt.as_str(), ()).await.map_err(|err| { - ToolError::execution_failed(format!("delegated agent `{}` failed: {err}", target_name)) - })?; + let task_args = + serde_json::to_value(&input).expect("TaskInput serialization should never fail"); + + let tool_call_id = "task_call"; + + let mut assistant_response = ModelResponse::new(); + assistant_response.add_part(ModelResponsePart::ToolCall( + ToolCallPart::new(tool_names::TASK, task_args).with_tool_call_id(tool_call_id), + )); + + let mut assistant_req = ModelRequest::new(); + assistant_req.add_part(ModelRequestPart::ModelResponse(Box::new( + assistant_response, + ))); + + let mut return_req = ModelRequest::new(); + return_req.add_part(ModelRequestPart::ToolReturn( + ToolReturnPart::new(tool_names::TASK, input.prompt.as_str()) + .with_tool_call_id(tool_call_id), + )); + + let options = RunOptions::default() + .message_history(vec![assistant_req, return_req]) + .skip_user_prompt(true); + + let response = agent + .run_with_options("", (), options) + .await + .map_err(|err| { + ToolError::execution_failed(format!( + "delegated agent `{}` failed: {err}", + target_name + )) + })?; Ok(TaskOutput::new(response.into_output())) } From c345ffc21b744e7dadd15fce94b15d21d2c02ce7 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 20:52:59 +0000 Subject: [PATCH 10/18] Changed: Reorganize task-demo agents into dedicated subfolder Move orchestrator and reader agents from flat agents/ into agents/task-demo/, load them via add_file instead of add_directory, and document subfolder naming behaviour in file-reader.md. Changes: - Added HTML comment to file-reader.md documenting path-to-name mapping - Moved orchestrator.md and reader.md into agents/task-demo/ - Updated serdesai-task.rs to load from task-demo/ via add_file - Shortened agent prompt bodies to essentials Benefits: - Each example now owns its agent files independently - No namespace collisions between examples - Naming behaviour is self-documented in the example --- .../examples/agents/orchestrator.md | 15 --------------- .../examples/agents/reader.md | 15 --------------- .../examples/agents/task-demo/orchestrator.md | 11 +++++++++++ .../examples/agents/task-demo/reader.md | 11 +++++++++++ .../examples/serdesai-task.rs | 15 ++++++++++----- 5 files changed, 32 insertions(+), 35 deletions(-) delete mode 100644 src/llm-coding-tools-serdesai/examples/agents/orchestrator.md delete mode 100644 src/llm-coding-tools-serdesai/examples/agents/reader.md create mode 100644 src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md create mode 100644 src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md diff --git a/src/llm-coding-tools-serdesai/examples/agents/orchestrator.md b/src/llm-coding-tools-serdesai/examples/agents/orchestrator.md deleted file mode 100644 index 57f697f6..00000000 --- a/src/llm-coding-tools-serdesai/examples/agents/orchestrator.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: orchestrator -mode: primary -description: Delegates one stateless read-only job to the reader specialist. -permission: - task: - "*": deny - "reader": allow ---- - -You are the `orchestrator` agent. -Delegate exactly one focused file-inspection task to `reader` when the user needs repository facts. -Pass all required context in that single task call, then answer directly with a concise final summary. -Do not call `task` more than once. -Do not assume session state, continuation, or prior delegated context. diff --git a/src/llm-coding-tools-serdesai/examples/agents/reader.md b/src/llm-coding-tools-serdesai/examples/agents/reader.md deleted file mode 100644 index d76e6ea6..00000000 --- a/src/llm-coding-tools-serdesai/examples/agents/reader.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: reader -mode: subagent -description: Reads requested repository files and returns the important details. -permission: - read: allow - glob: allow - grep: allow - task: deny ---- - -You are the `reader` agent. -Use the available read-only tools to inspect the requested repository files and collect the needed facts. -Return a short, direct summary of what you found. -Do not delegate work or assume any prior conversation history. 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..4fbacf45 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md @@ -0,0 +1,11 @@ +--- +name: orchestrator +mode: primary +description: Delegates one stateless read-only job to the reader specialist. +permission: + task: + "*": deny + "reader": allow +--- + +Delegate reading the requested files to `reader`. Summarize and answer. No continuation state. 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..ccde00eb --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md @@ -0,0 +1,11 @@ +--- +mode: all +description: Reads repository files and summarizes the important details. +permission: + read: allow + glob: allow + grep: allow + task: deny +--- + +Read requested files and summarize. Do not delegate. diff --git a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs index 56ee526c..e17b127e 100644 --- a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs +++ b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs @@ -1,6 +1,6 @@ //! Stateless Task delegation example using the models.dev catalog. //! -//! Loads markdown agents from `examples/agents/`, builds the primary +//! 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`. //! @@ -20,7 +20,10 @@ const API_KEY_VALUE: &str = ""; // <-- Set your API key here #[tokio::main] async fn main() -> Result<(), Box> { - let examples_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"); + 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() { @@ -34,7 +37,9 @@ async fn main() -> Result<(), Box> { ); let mut catalog = AgentCatalog::new(); - AgentLoader::new().add_directory(&mut catalog, &examples_root)?; + 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) @@ -43,7 +48,7 @@ async fn main() -> Result<(), Box> { println!( "Loading named agent `{AGENT_NAME}` from {}", - examples_root.display() + agents_dir.display() ); let agent = runtime.build_with_task( AGENT_NAME, @@ -56,7 +61,7 @@ async fn main() -> Result<(), Box> { ); let prompt = format!( - "Use a single delegated read-only task to inspect {}. Then explain in three bullets how the task-enabled build flow works, why it lives in SerdesAI, and what v1 Task does not support.", + "Ask `reader` to give a short summary of {}.", readme_path.display(), ); let response = agent.run(prompt.as_str(), ()).await?; From 312df502aacbc2ead90c1b8e3cfd95ad82f64e6f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 23:13:32 +0000 Subject: [PATCH 11/18] Fixed: keep the Task tool available when delegation is allowed Stop hiding the `task` tool when an agent is allowed to hand work to a callable helper agent. Changes: - made runtime tool filtering keep `task` when at least one allowed target exists - cleaned up old permission filtering helpers and tightened the task demo - added tests and short docs for default tool and Task permission behavior Benefits: - prevents agents from losing delegation even when a helper agent is allowed - makes permission behavior easier to understand and maintain --- src/llm-coding-tools-agents/README.md | 3 + src/llm-coding-tools-agents/src/extensions.rs | 64 +------ .../src/runtime/mod.rs | 2 +- .../src/runtime/state.rs | 11 ++ .../src/runtime/task.rs | 179 ++++++++++++++---- src/llm-coding-tools-core/src/permissions.rs | 63 ------ src/llm-coding-tools-serdesai/README.md | 8 +- .../examples/agents/task-demo/orchestrator.md | 13 +- .../examples/agents/task-demo/reader.md | 16 +- .../examples/serdesai-task.rs | 15 +- .../src/agent_runtime/build.rs | 8 +- .../src/agent_runtime/task.rs | 80 +++++++- 12 files changed, 282 insertions(+), 180 deletions(-) diff --git a/src/llm-coding-tools-agents/README.md b/src/llm-coding-tools-agents/README.md index 4eb43781..69497474 100644 --- a/src/llm-coding-tools-agents/README.md +++ b/src/llm-coding-tools-agents/README.md @@ -80,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/runtime/mod.rs b/src/llm-coding-tools-agents/src/runtime/mod.rs index 86602ca4..5adfa41a 100644 --- a/src/llm-coding-tools-agents/src/runtime/mod.rs +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -17,8 +17,8 @@ //! - [`default_tools()`] - The standard tool set (read, write, edit, glob, grep, bash, webfetch, todo) //! //! Task delegation: -//! - [`callable_targets()`] - Returns the agents the active agent may delegate to //! - [`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: diff --git a/src/llm-coding-tools-agents/src/runtime/state.rs b/src/llm-coding-tools-agents/src/runtime/state.rs index 04f9b63f..d35c2b31 100644 --- a/src/llm-coding-tools-agents/src/runtime/state.rs +++ b/src/llm-coding-tools-agents/src/runtime/state.rs @@ -5,6 +5,7 @@ //! - [`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; @@ -80,4 +81,14 @@ impl AgentRuntime { 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 index 5a2cb21e..ac94d7bd 100644 --- a/src/llm-coding-tools-agents/src/runtime/task.rs +++ b/src/llm-coding-tools-agents/src/runtime/task.rs @@ -1,10 +1,12 @@ //! 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. -//! - [`summarize_callable_targets`] - Builds summary rows with stable names and descriptions. +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; @@ -18,29 +20,6 @@ pub struct TaskTargetSummary { pub description: Box, } -/// 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); - collect_callable_targets(&agents, caller) -} - /// For each agent `caller_name` can delegate to, returns its name and description. /// Results are in consistent alphabetical order. /// @@ -58,6 +37,7 @@ pub fn summarize_callable_targets( 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(), @@ -68,38 +48,107 @@ pub fn summarize_callable_targets( 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 collect_callable_targets<'a>( - agents: &[&'a AgentConfig], - caller: &AgentConfig, -) -> Vec<&'a AgentConfig> { +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 targets = Vec::with_capacity(agents.len()); + let mut task_is_callable = false; + + // Expose `task` only when at least one delegated target remains callable. for target in agents { - if !matches!(target.mode, AgentMode::All | AgentMode::Subagent) { - continue; + if target_is_callable(target, &task_rules, has_explicit_task_permission) { + task_is_callable = true; + break; } + } - if !has_explicit_task_permission - || task_rules.is_allowed(tool_names::TASK, target.name.as_ref()) - { - targets.push(*target); + 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); } } - targets + + allowed } #[cfg(test)] mod tests { use super::*; use crate::types::PermissionRule; - use crate::{AgentConfig, AgentMode}; + use crate::{AgentConfig, AgentMode, AgentRuntimeBuilder}; use ahash::AHashMap; use indexmap::IndexMap; use llm_coding_tools_core::permissions::PermissionAction; @@ -412,4 +461,60 @@ mod tests { 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-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-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md index dd9b252c..ebc9b4cc 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -146,7 +146,13 @@ let agent = runtime.build_with_task( # } ``` -Each Task call builds and runs the subagent once; `session_id` is rejected. Use [`build_agent_with_credentials_and_task`] for the lower-level helper. See [examples/serdesai-task.rs](examples/serdesai-task.rs). +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 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 index 4fbacf45..00ebe167 100644 --- a/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/orchestrator.md @@ -3,9 +3,20 @@ 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 --- -Delegate reading the requested files to `reader`. Summarize and answer. No continuation state. +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 index ccde00eb..7fa6248b 100644 --- a/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md +++ b/src/llm-coding-tools-serdesai/examples/agents/task-demo/reader.md @@ -1,11 +1,19 @@ --- -mode: all +name: reader +mode: subagent description: Reads repository files and summarizes the important details. permission: read: allow - glob: allow - grep: allow + write: deny + edit: deny + glob: deny + grep: deny + bash: deny + webfetch: deny + todoread: deny + todowrite: deny task: deny --- -Read requested files and summarize. Do not delegate. +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-task.rs b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs index e17b127e..88826377 100644 --- a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs +++ b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs @@ -11,6 +11,7 @@ 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 serdes_ai::{ModelRequestPart, UserContent}; use std::{path::PathBuf, sync::Arc}; const AGENT_NAME: &str = "orchestrator"; @@ -64,8 +65,20 @@ async fn main() -> Result<(), Box> { "Ask `reader` to give a short summary of {}.", readme_path.display(), ); - let response = agent.run(prompt.as_str(), ()).await?; + let response = agent.run(UserContent::text(prompt), ()).await?; println!("{}", response.output()); + println!( + "Root agent usage: {} model requests, {} tool calls", + response.usage.request_count, response.usage.tool_call_count + ); + + let tool_calls = response + .messages + .iter() + .flat_map(|request| request.parts.iter()) + .filter(|part| matches!(part, ModelRequestPart::ToolReturn(_))) + .count(); + println!("Task/tool returns observed in history: {tool_calls}"); Ok(()) } 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 db54b5bb..b4a64a9a 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/build.rs @@ -13,10 +13,9 @@ use crate::{ create_todo_tools, }; use llm_coding_tools_agents::{ - AgentRuntime, ModelResolutionError, RulesetExt, TaskTargetSummary, ToolCatalogEntry, - ToolCatalogKind, summarize_callable_targets, + 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; @@ -126,8 +125,7 @@ where .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 ruleset = Ruleset::from_permission_config(&agent.permission); - let tools = ruleset.filter_allowed_tools(runtime.tools()); + let tools = runtime.allowed_tools(name); let callable_target_summaries = summarize_callable_targets(runtime.catalog(), name); Ok(PreparedBuild { diff --git a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs index 8a7940e3..637f67aa 100644 --- a/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs +++ b/src/llm-coding-tools-serdesai/src/agent_runtime/task.rs @@ -161,6 +161,14 @@ mod tests { .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", @@ -200,11 +208,11 @@ mod tests { .catalog(AgentCatalog::from_entries([ agent( "caller", - AgentMode::All, + AgentMode::Primary, allow_tools(&[tool_names::READ]), "prompt", ), - agent("other", AgentMode::All, allow_tools(&[]), "prompt"), + agent("other", AgentMode::Primary, allow_tools(&[]), "prompt"), ])) .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) .build(); @@ -255,6 +263,70 @@ mod tests { 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()); @@ -264,11 +336,11 @@ mod tests { .catalog(AgentCatalog::from_entries([ agent( "caller", - AgentMode::All, + AgentMode::Primary, allow_tools(&[tool_names::READ]), "prompt", ), - agent("other", AgentMode::All, allow_tools(&[]), "prompt"), + agent("other", AgentMode::Primary, allow_tools(&[]), "prompt"), ])) .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini")) .build(); From 4ffff7070989b74aa169331fa076b18ba5a286d6 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 16 Mar 2026 23:42:47 +0000 Subject: [PATCH 12/18] Revert "Changed: Delegate sub-agent task calls as tool-call context instead of user prompts" This reverts commit a7c0f96f40768ab54c292a2a90ed4b1e5a3554d6. --- .gitmodules | 3 -- serdesAI | 1 - src/Cargo.lock | 22 ++++++++++ src/Cargo.toml | 9 ---- .../src/task/handle.rs | 41 ++----------------- 5 files changed, 25 insertions(+), 51 deletions(-) delete mode 100644 .gitmodules delete mode 160000 serdesAI diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index ff73e933..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "serdesAI"] - path = serdesAI - url = https://github.com/Sewer56/serdesAI.git diff --git a/serdesAI b/serdesAI deleted file mode 160000 index a52e0bff..00000000 --- a/serdesAI +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a52e0bff9e1e06cc45a63ee47bdae775ca796252 diff --git a/src/Cargo.lock b/src/Cargo.lock index bb28c92f..0446d225 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -3065,6 +3065,8 @@ dependencies = [ [[package]] name = "serdes-ai" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62dcf7d035a43aab94b8fed2925faa6f845d49de27066b2c9b07e339b3048a85" dependencies = [ "futures", "serdes-ai-agent", @@ -3083,6 +3085,8 @@ dependencies = [ [[package]] name = "serdes-ai-agent" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fd65311bcd469934e9cf5b4d10b6296fd9bde944aa2e232b0fedd37cca4aee" dependencies = [ "anyhow", "async-trait", @@ -3104,6 +3108,8 @@ dependencies = [ [[package]] name = "serdes-ai-core" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c75900724c512454172492ffdd9ae24f8ccc5569e812c258a79d4151cd8934c" dependencies = [ "anyhow", "base64", @@ -3122,6 +3128,8 @@ dependencies = [ [[package]] name = "serdes-ai-macros" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd2f1e7f4f1f9a0a9f8b31ea0bb24b13271dd46817c8b656821701d1e1d4a40" dependencies = [ "darling", "proc-macro2", @@ -3132,6 +3140,8 @@ dependencies = [ [[package]] name = "serdes-ai-models" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbca6da3265b8d1fce6255c4aee81b02ac9d2dba6e93829e09eaf1bc29d2886e" dependencies = [ "anyhow", "async-trait", @@ -3160,6 +3170,8 @@ dependencies = [ [[package]] name = "serdes-ai-output" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c73a180c99d702c59282057d6f993332c8150834017110051f56e272133c54f" dependencies = [ "anyhow", "async-trait", @@ -3179,6 +3191,8 @@ dependencies = [ [[package]] name = "serdes-ai-providers" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d857c9fc39b9c370eb7321fecb253c07a7892a3646c7455968a123da6df5a1d" dependencies = [ "async-trait", "base64", @@ -3199,6 +3213,8 @@ dependencies = [ [[package]] name = "serdes-ai-retries" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf2449d534d7ce2df7d743e61de516df945384aa50024965246ef5dfc638b93" dependencies = [ "anyhow", "async-trait", @@ -3213,6 +3229,8 @@ dependencies = [ [[package]] name = "serdes-ai-streaming" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159b5dfda85e1a886793e0962c6d40581044bb3ca008665b53f75ecb62eb3f74" dependencies = [ "async-trait", "bytes", @@ -3231,6 +3249,8 @@ dependencies = [ [[package]] name = "serdes-ai-tools" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae4c635d97827560acaa8d3af32a78fc50fece538d1e4638c889c7588f490777" dependencies = [ "anyhow", "async-trait", @@ -3249,6 +3269,8 @@ dependencies = [ [[package]] name = "serdes-ai-toolsets" version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e7ab76a1546ce6aa858c7a0fd438dd4235b3927fcf5a907bec26bacb6f2588" dependencies = [ "async-trait", "indexmap", diff --git a/src/Cargo.toml b/src/Cargo.toml index a2f9bc91..7429dbb9 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -3,15 +3,6 @@ resolver = "2" members = ["llm-coding-tools-core", "llm-coding-tools-serdesai", "llm-coding-tools-agents", "llm-coding-tools-models-dev"] -[patch.crates-io] -serdes-ai = { path = "../serdesAI/serdes-ai" } -serdes-ai-agent = { path = "../serdesAI/serdes-ai-agent" } -serdes-ai-core = { path = "../serdesAI/serdes-ai-core" } -serdes-ai-models = { path = "../serdesAI/serdes-ai-models" } -serdes-ai-providers = { path = "../serdesAI/serdes-ai-providers" } -serdes-ai-streaming = { path = "../serdesAI/serdes-ai-streaming" } -serdes-ai-tools = { path = "../serdesAI/serdes-ai-tools" } - # Profile Build [profile.profile] inherits = "release" diff --git a/src/llm-coding-tools-serdesai/src/task/handle.rs b/src/llm-coding-tools-serdesai/src/task/handle.rs index a56e7f53..3ce3d3dc 100644 --- a/src/llm-coding-tools-serdesai/src/task/handle.rs +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -11,10 +11,6 @@ use llm_coding_tools_core::{ CredentialLookup, CredentialResolver, TaskInput, TaskOutput, tool_names, }; use serdes_ai::tools::ToolError; -use serdes_ai::{ - ModelRequest, ModelRequestPart, ModelResponse, ModelResponsePart, RunOptions, ToolCallPart, - ToolReturnPart, -}; use std::sync::Arc; /// Shared Task executor used by the concrete SerdesAI tool. @@ -111,40 +107,9 @@ where target_name )) })?; - let task_args = - serde_json::to_value(&input).expect("TaskInput serialization should never fail"); - - let tool_call_id = "task_call"; - - let mut assistant_response = ModelResponse::new(); - assistant_response.add_part(ModelResponsePart::ToolCall( - ToolCallPart::new(tool_names::TASK, task_args).with_tool_call_id(tool_call_id), - )); - - let mut assistant_req = ModelRequest::new(); - assistant_req.add_part(ModelRequestPart::ModelResponse(Box::new( - assistant_response, - ))); - - let mut return_req = ModelRequest::new(); - return_req.add_part(ModelRequestPart::ToolReturn( - ToolReturnPart::new(tool_names::TASK, input.prompt.as_str()) - .with_tool_call_id(tool_call_id), - )); - - let options = RunOptions::default() - .message_history(vec![assistant_req, return_req]) - .skip_user_prompt(true); - - let response = agent - .run_with_options("", (), options) - .await - .map_err(|err| { - ToolError::execution_failed(format!( - "delegated agent `{}` failed: {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())) } From 7d7c0ccbe8109fbaa9a2f160ec0c5452b768d165 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 00:21:58 +0000 Subject: [PATCH 13/18] Changed: Stream task example output in real time with XML transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `agent.run()` with `agent.run_stream()` so thinking, assistant text, tool calls, and task inputs are printed as they arrive. Each message is tagged with a stable `` id matching the request step, making it easy to see which thinking belongs to which turn. Changes: - Switched from `run()` to `run_stream()` for live output - Added streaming XML printer with open/close tag tracking via `OpenStreamTag` - Accumulate tool call args across deltas, render on `ToolCallComplete` - Render `TaskInput` in typed form; remove post-hoc tool return replay - Reordered file: `main` first, helpers in call order - Added comments where intent isn't obvious from code alone Benefits: - Can observe agent reasoning and activity as it happens - Stable message ids clearly associate thinking with the right turn - Shorter output by removing the redundant tool-return history section --- .../examples/serdesai-task.rs | 216 ++++++++++++++++-- 1 file changed, 200 insertions(+), 16 deletions(-) diff --git a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs index 88826377..d30de4e8 100644 --- a/src/llm-coding-tools-serdesai/examples/serdesai-task.rs +++ b/src/llm-coding-tools-serdesai/examples/serdesai-task.rs @@ -7,12 +7,18 @@ //! 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; +use llm_coding_tools_core::{CredentialResolver, TaskInput}; use llm_coding_tools_models_dev::ModelsDevCatalog; use llm_coding_tools_serdesai::{AgentDefaults, AgentRuntimeTaskExt}; -use serdes_ai::{ModelRequestPart, UserContent}; -use std::{path::PathBuf, sync::Arc}; +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"; @@ -62,23 +68,201 @@ async fn main() -> Result<(), Box> { ); let prompt = format!( - "Ask `reader` to give a short summary of {}.", + "If the model supports visible reasoning output, think briefly before acting, then ask `reader` to give a short summary of {}.", readme_path.display(), ); - let response = agent.run(UserContent::text(prompt), ()).await?; - println!("{}", response.output()); + 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 usage: {} model requests, {} tool calls", - response.usage.request_count, response.usage.tool_call_count + "Root agent activity: {} model requests, {} tool calls", + request_count, tool_call_count ); - let tool_calls = response - .messages - .iter() - .flat_map(|request| request.parts.iter()) - .filter(|part| matches!(part, ModelRequestPart::ToolReturn(_))) - .count(); - println!("Task/tool returns observed in history: {tool_calls}"); - 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") + }) +} From b85c26c558b962d91925b6e449321b3971132ae0 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 11:02:49 +0000 Subject: [PATCH 14/18] Changed: Document llm-coding-tools-models-dev dependency in Task Tool README example --- src/llm-coding-tools-serdesai/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/llm-coding-tools-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md index ebc9b4cc..54c49d60 100644 --- a/src/llm-coding-tools-serdesai/README.md +++ b/src/llm-coding-tools-serdesai/README.md @@ -146,6 +146,8 @@ let agent = runtime.build_with_task( # } ``` +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` From 6d867373d95b99a47b8c527130b698be3bc13901 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 11:05:05 +0000 Subject: [PATCH 15/18] Changed: Add task to default tools listed in agents README The commented example omitted task from the default tool list despite it being included in DEFAULT_TOOLS. --- src/llm-coding-tools-agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-agents/README.md b/src/llm-coding-tools-agents/README.md index 69497474..0f0fdd18 100644 --- a/src/llm-coding-tools-agents/README.md +++ b/src/llm-coding-tools-agents/README.md @@ -65,7 +65,7 @@ let runtime = AgentRuntimeBuilder::new() .catalog(catalog) .defaults(AgentDefaults::with_model("openai/gpt-4o-mini")) // .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 + // .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 From 44904ebacedfabc0d908accfb0e616ff019627f9 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 11:06:36 +0000 Subject: [PATCH 16/18] Fixed: Add missing subagent_type to Task tool example The Task tool documentation stated subagent_type is required but the example invocation on the "When to Use" line omitted it. --- src/llm-coding-tools-core/src/context/task.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt index 95098f8a..8bfd2e80 100644 --- a/src/llm-coding-tools-core/src/context/task.txt +++ b/src/llm-coding-tools-core/src/context/task.txt @@ -3,7 +3,7 @@ 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(description="Check the file", prompt="/check-file path/to/file.py") +- 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 From 0e0ef25d8c50d4eeb31ebb517d23cda9afe54450 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 11:08:00 +0000 Subject: [PATCH 17/18] Fixed: Add missing task tool to runtime module doc comment The default_tools() bullet in the top-level doc comment listed only 8 tools but the catalog actually provides 10, including task. --- src/llm-coding-tools-agents/src/runtime/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-agents/src/runtime/mod.rs b/src/llm-coding-tools-agents/src/runtime/mod.rs index 5adfa41a..cbbe40cf 100644 --- a/src/llm-coding-tools-agents/src/runtime/mod.rs +++ b/src/llm-coding-tools-agents/src/runtime/mod.rs @@ -14,7 +14,7 @@ //! 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 From 7cace31ab95429ddf1eb1ab4857402c738b2ff0c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Tue, 17 Mar 2026 11:13:33 +0000 Subject: [PATCH 18/18] Changed: Document intentional implicit-allow semantics in validate_target The validate_target method in the Task handler skips Ruleset filtering when caller.permission lacks an explicit task key, allowing delegation to non-Primary targets by default. This comment records that design decision to prevent confusion with Ruleset's default-deny documentation. --- src/llm-coding-tools-serdesai/src/task/handle.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/llm-coding-tools-serdesai/src/task/handle.rs b/src/llm-coding-tools-serdesai/src/task/handle.rs index 3ce3d3dc..fa0517e7 100644 --- a/src/llm-coding-tools-serdesai/src/task/handle.rs +++ b/src/llm-coding-tools-serdesai/src/task/handle.rs @@ -138,6 +138,10 @@ where )); } + // `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)