Skip to main content

yoagent/
sub_agent.rs

1//! Sub-agent tool — delegates tasks to a child agent loop.
2//!
3//! The `SubAgentTool` implements `AgentTool` and internally runs `agent_loop()`
4//! with its own system prompt, tools, and provider. The parent LLM invokes it
5//! like any other tool, passing a natural-language `task` string.
6//!
7//! # Design
8//!
9//! - **Context isolation**: each invocation starts a fresh conversation
10//! - **Nesting supported**: sub-agents can contain other SubAgentTools for recursive delegation (use `with_max_turns()` to bound depth)
11//! - **Cancellation propagation**: the parent's cancel token is forwarded
12//! - **Event forwarding**: sub-agent events stream to the parent via `on_update`
13//! - **Spend reporting**: the sub-agent's [`SessionStats`] — its own usage and,
14//!   recursively, its own sub-agents' — reach the parent loop, which keeps them
15//!   in a separate [`SessionStats::sub_agents`] bucket. Failed runs included.
16//!
17//! # Example
18//!
19//! ```rust,no_run
20//! use yoagent::sub_agent::SubAgentTool;
21//! use yoagent::provider::ModelConfig;
22//!
23//! // Provider selected from the config's protocol; key from ANTHROPIC_API_KEY.
24//! let researcher = SubAgentTool::from_config(
25//!     "researcher",
26//!     ModelConfig::anthropic("claude-sonnet-5", "Sonnet 5"),
27//! )
28//! .with_description("Searches codebases and documents")
29//! .with_system_prompt("You are a research assistant.");
30//! ```
31
32use crate::agent_loop::{agent_loop_with_stats, AgentLoopConfig};
33use crate::context::ExecutionLimits;
34use crate::provider::model::ModelConfig;
35use crate::provider::StreamProvider;
36use crate::shared_state::SharedState;
37use crate::tool_source::{ArcTool, ToolSource};
38use crate::tools::shared_state_tool::SharedStateTool;
39use crate::types::*;
40use std::sync::Arc;
41use tokio::sync::mpsc;
42
43/// Default max turns for sub-agents (prevents runaway execution).
44const DEFAULT_MAX_TURNS: usize = 10;
45
46/// The limits a delegation runs under unless [`SubAgentTool::with_execution_limits`]
47/// says otherwise: 10 turns, 1M tokens, 5 minutes, loop detection on.
48fn default_execution_limits() -> ExecutionLimits {
49    ExecutionLimits::default()
50        .with_max_turns(DEFAULT_MAX_TURNS)
51        .with_max_total_tokens(1_000_000)
52        .with_max_duration(std::time::Duration::from_secs(300))
53}
54
55/// A tool that delegates work to a child agent loop.
56///
57/// When the parent LLM calls this tool, it spawns a fresh `agent_loop()` with
58/// its own system prompt, tools, and provider. The sub-agent runs to completion
59/// and its final text output is returned as the tool result.
60pub struct SubAgentTool {
61    tool_name: String,
62    tool_description: String,
63    system_prompt: String,
64    skills_prompt: String,
65    model: String,
66    api_key: String,
67    provider: Arc<dyn StreamProvider>,
68    tools: Vec<Arc<dyn AgentTool>>,
69    tool_sources: Vec<Arc<dyn ToolSource>>,
70    thinking_level: ThinkingLevel,
71    max_tokens: Option<u32>,
72    temperature: Option<f32>,
73    cache_config: CacheConfig,
74    tool_execution: ToolExecutionStrategy,
75    retry_config: crate::retry::RetryConfig,
76    execution_limits: ExecutionLimits,
77    shared_state: Option<SharedState>,
78    context_config: Option<crate::context::ContextConfig>,
79    turn_delay: Option<std::time::Duration>,
80    model_config: Option<ModelConfig>,
81    tool_middleware: Vec<Arc<dyn ToolMiddleware>>,
82    extensions: Vec<Arc<dyn crate::Extension>>,
83    tree_extensions: Vec<Arc<dyn crate::Extension>>,
84    max_stop_continues: usize,
85    turn_hooks: Vec<Arc<dyn TurnHook>>,
86    input_filters: Vec<Arc<dyn InputFilter>>,
87    #[cfg(feature = "decision")]
88    skills: crate::skills::SkillSet,
89    #[cfg(feature = "decision")]
90    decision: Option<crate::decision::Advisory>,
91    #[cfg(feature = "decision")]
92    tool_gate: Option<crate::decision::ToolGate>,
93}
94
95impl SubAgentTool {
96    /// Create a new sub-agent tool with a name and provider.
97    #[doc(hidden)]
98    #[deprecated(
99        since = "0.10.0",
100        note = "use SubAgentTool::from_config(name, config) — provider + env key \
101                resolved automatically — or SubAgentTool::from_provider(name, provider, config) \
102                for a custom provider; will be removed in 1.0"
103    )]
104    pub fn new(name: impl Into<String>, provider: Arc<dyn StreamProvider>) -> Self {
105        Self::build(name, provider)
106    }
107
108    /// Internal constructor shared by `new` and the `from_*` builders (not
109    /// deprecated, so the builders don't trip the deprecation lint).
110    fn build(name: impl Into<String>, provider: Arc<dyn StreamProvider>) -> Self {
111        let name = name.into();
112        Self {
113            tool_description: format!("Delegate a task to the '{}' sub-agent", name),
114            tool_name: name,
115            system_prompt: String::new(),
116            skills_prompt: String::new(),
117            model: String::new(),
118            api_key: String::new(),
119            provider,
120            tools: Vec::new(),
121            tool_sources: Vec::new(),
122            thinking_level: ThinkingLevel::Off,
123            max_tokens: None,
124            temperature: None,
125            cache_config: CacheConfig::default(),
126            tool_execution: ToolExecutionStrategy::default(),
127            retry_config: crate::retry::RetryConfig::default(),
128            execution_limits: default_execution_limits(),
129            shared_state: None,
130            context_config: None,
131            turn_delay: None,
132            model_config: None,
133            tool_middleware: Vec::new(),
134            extensions: Vec::new(),
135            tree_extensions: Vec::new(),
136            max_stop_continues: crate::extension::DEFAULT_MAX_STOP_CONTINUES,
137            turn_hooks: Vec::new(),
138            input_filters: Vec::new(),
139            #[cfg(feature = "decision")]
140            skills: crate::skills::SkillSet::empty(),
141            #[cfg(feature = "decision")]
142            decision: None,
143            #[cfg(feature = "decision")]
144            tool_gate: None,
145        }
146    }
147
148    /// Create a sub-agent from a name and [`ModelConfig`], selecting the
149    /// built-in provider for the config's protocol.
150    ///
151    /// Mirrors [`Agent::from_config`](crate::Agent::from_config): the model
152    /// id, provider, and pricing come from one config, and the API key is
153    /// resolved from the provider-conventional env var unless set explicitly
154    /// with [`with_api_key`](Self::with_api_key).
155    ///
156    /// # Panics
157    ///
158    /// Never panics — the default registry covers every [`ApiProtocol`]
159    /// variant. Use [`from_config_with`](Self::from_config_with) with a custom
160    /// registry when a protocol may be unregistered and you want a `Result`.
161    ///
162    /// [`ApiProtocol`]: crate::provider::ApiProtocol
163    pub fn from_config(name: impl Into<String>, config: ModelConfig) -> Self {
164        Self::from_config_with(&crate::provider::ProviderRegistry::default(), name, config)
165            .expect("default registry covers all built-in protocols")
166    }
167
168    /// Like [`from_config`](Self::from_config) but resolves the provider from a
169    /// caller-supplied registry, returning an error if the config's protocol
170    /// isn't registered. Mirrors
171    /// [`Agent::from_config_with`](crate::Agent::from_config_with).
172    pub fn from_config_with(
173        registry: &crate::provider::ProviderRegistry,
174        name: impl Into<String>,
175        config: ModelConfig,
176    ) -> Result<Self, crate::AgentBuildError> {
177        let provider = registry
178            .resolve(&config.api)
179            .ok_or(crate::AgentBuildError::NoProviderForProtocol(config.api))?;
180        Ok(Self::build(name, provider).configured_for(config))
181    }
182
183    /// Create a sub-agent from a name, explicit provider, and [`ModelConfig`].
184    ///
185    /// The escape hatch for custom providers and test doubles (pair with
186    /// [`ModelConfig::mock`](crate::provider::ModelConfig::mock)). Mirrors
187    /// [`Agent::from_provider`](crate::Agent::from_provider).
188    pub fn from_provider(
189        name: impl Into<String>,
190        provider: Arc<dyn StreamProvider>,
191        config: ModelConfig,
192    ) -> Self {
193        Self::build(name, provider).configured_for(config)
194    }
195
196    /// Set the model id and stash the config on a freshly-constructed
197    /// sub-agent (provider already wired).
198    fn configured_for(mut self, config: ModelConfig) -> Self {
199        self.model = config.id.clone();
200        self.model_config = Some(config);
201        self
202    }
203
204    /// Re-price the sub-agent's model config against the process-wide price
205    /// table now — [`ModelConfig::reprice`] on the stored config. Builder
206    /// style: call it before handing the tool to an agent, because a
207    /// registered tool is shared and immutable. Constructors resolve prices
208    /// when they run, so either install prices
209    /// ([`prices::enable_bundled`](crate::provider::prices::enable_bundled),
210    /// `prices::enable_live`,
211    /// [`global::install_override`](crate::provider::prices::global::install_override),
212    /// [`global::install_fetched`](crate::provider::prices::global::install_fetched))
213    /// before building the config, or call this afterwards. Same rules as
214    /// [`ModelConfig::reprice`].
215    pub fn reprice(mut self) -> Self {
216        if let Some(config) = self.model_config.take() {
217            self.model_config = Some(config.reprice());
218        }
219        self
220    }
221
222    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
223        self.tool_description = desc.into();
224        self
225    }
226
227    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
228        self.system_prompt = prompt.into();
229        self
230    }
231
232    /// Attach a skill set so the sub-agent sees the skills index.
233    ///
234    /// Mirrors [`Agent::with_skills`](crate::agent::Agent::with_skills): the skills
235    /// index is formatted as XML per the [AgentSkills standard](https://agentskills.io)
236    /// and appended to the sub-agent's system prompt at dispatch time. The sub-agent
237    /// can then read individual SKILL.md files via the `read_file` tool when it
238    /// decides a skill is relevant (make sure the sub-agent has such a tool).
239    pub fn with_skills(mut self, skills: crate::skills::SkillSet) -> Self {
240        self.skills_prompt = skills.format_for_prompt();
241        #[cfg(feature = "decision")]
242        {
243            self.skills = skills;
244        }
245        self
246    }
247
248    #[doc(hidden)]
249    #[deprecated(
250        since = "0.10.0",
251        note = "the model id now comes from the ModelConfig passed to \
252                SubAgentTool::from_config / from_provider; will be removed in 1.0"
253    )]
254    pub fn with_model(mut self, model: impl Into<String>) -> Self {
255        self.model = model.into();
256        self
257    }
258
259    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
260        self.api_key = key.into();
261        self
262    }
263
264    pub fn with_tools(mut self, tools: Vec<Arc<dyn AgentTool>>) -> Self {
265        self.tools = tools;
266        self
267    }
268
269    /// Add a [`ToolSource`], consulted once per delegation (each delegation
270    /// is one run of the sub-agent). Mirrors
271    /// [`Agent::with_tool_source`](crate::Agent::with_tool_source): the
272    /// sub-agent's own tools win a name collision, then earlier sources.
273    pub fn with_tool_source(mut self, source: impl ToolSource + 'static) -> Self {
274        self.tool_sources.push(Arc::new(source));
275        self
276    }
277
278    /// Add a tool middleware for the sub-agent's own tool calls. Mirrors
279    /// [`Agent::with_tool_middleware`](crate::Agent::with_tool_middleware).
280    pub fn with_tool_middleware(mut self, middleware: impl ToolMiddleware + 'static) -> Self {
281        self.tool_middleware.push(Arc::new(middleware));
282        self
283    }
284
285    /// Add an [`Extension`](crate::Extension) for the sub-agent's runs (see
286    /// [`crate::extension`]). Extensions run in installation order, after the
287    /// tree extensions and after the older hooks (middleware, filters).
288    pub fn with_extension(mut self, extension: impl crate::Extension + 'static) -> Self {
289        self.extensions.push(Arc::new(extension));
290        self
291    }
292
293    /// Add an [`Extension`](crate::Extension) for the sub-agent's runs **and
294    /// every run they delegate to**, at any depth: host policy (permissions,
295    /// deny rules, redaction, audit, a budget across the tree). A child runs
296    /// it ahead of its own extensions and cannot remove it. Its tools are not
297    /// offered to child runs.
298    pub fn with_tree_extension(mut self, extension: impl crate::Extension + 'static) -> Self {
299        self.tree_extensions.push(Arc::new(extension));
300        self
301    }
302
303    /// How many times per run an extension's `on_stop` may continue it
304    /// (default [`DEFAULT_MAX_STOP_CONTINUES`](crate::extension::DEFAULT_MAX_STOP_CONTINUES)).
305    pub fn with_max_stop_continues(mut self, max: usize) -> Self {
306        self.max_stop_continues = max;
307        self
308    }
309
310    /// Add a [`TurnHook`] for the sub-agent's own LLM requests. Mirrors
311    /// [`Agent::with_turn_hook`](crate::Agent::with_turn_hook): awaited
312    /// before every request, it may append one note to that request's
313    /// latest user turn. Hooks run in installation order, before the
314    /// decision advisory's.
315    pub fn with_turn_hook(mut self, hook: impl TurnHook + 'static) -> Self {
316        self.turn_hooks.push(Arc::new(hook));
317        self
318    }
319
320    /// Add an async input filter for the task the parent model hands this
321    /// sub-agent. Mirrors
322    /// [`Agent::with_async_input_filter`](crate::Agent::with_async_input_filter).
323    /// When a filter rejects the task, the sub-agent does not run and the
324    /// tool call fails with the reason (the parent's model sees it); a
325    /// `Warn` is appended to the task.
326    pub fn with_async_input_filter(mut self, filter: impl AsyncInputFilter + 'static) -> Self {
327        self.input_filters.push(Arc::new(AsyncFilter::new(filter)));
328        self
329    }
330
331    /// Screen the task the parent model hands this sub-agent with a
332    /// decision model. Mirrors
333    /// [`Agent::with_input_guard`](crate::Agent::with_input_guard) — fails
334    /// closed — except that the "input" is the task text the parent model
335    /// wrote, not the human's words. A rejected task fails the tool call
336    /// with the guard's reason. Panics when the guard has no checks.
337    #[cfg(feature = "decision")]
338    #[cfg_attr(docsrs, doc(cfg(feature = "decision")))]
339    pub fn with_input_guard(self, guard: crate::decision::InputGuard) -> Self {
340        guard.assert_has_checks();
341        self.with_extension(guard)
342    }
343
344    /// Attach a decision model for the sub-agent's own turns: advisory skill
345    /// and tool hints only. Mirrors
346    /// [`Agent::with_decision_model`](crate::Agent::with_decision_model).
347    #[cfg(feature = "decision")]
348    #[cfg_attr(docsrs, doc(cfg(feature = "decision")))]
349    pub fn with_decision_model(self, model: crate::decision::DecisionModel) -> Self {
350        self.with_decision_advisory(crate::decision::Advisory::new(model))
351    }
352
353    /// Mirrors [`Agent::with_decision_advisory`](crate::Agent::with_decision_advisory).
354    #[cfg(feature = "decision")]
355    #[cfg_attr(docsrs, doc(cfg(feature = "decision")))]
356    pub fn with_decision_advisory(mut self, advisory: crate::decision::Advisory) -> Self {
357        self.decision = Some(advisory);
358        self
359    }
360
361    /// Gate the sub-agent's own tool calls. Mirrors
362    /// [`Agent::with_tool_gate`](crate::Agent::with_tool_gate), fail-closed
363    /// included. A parent's gate does not cover these calls; and here the
364    /// gate's `user_request` is the task text the parent model wrote.
365    #[cfg(feature = "decision")]
366    #[cfg_attr(docsrs, doc(cfg(feature = "decision")))]
367    pub fn with_tool_gate(mut self, gate: crate::decision::ToolGate) -> Self {
368        self.tool_gate = Some(gate);
369        self
370    }
371
372    pub fn with_thinking(mut self, level: ThinkingLevel) -> Self {
373        self.thinking_level = level;
374        self
375    }
376
377    pub fn with_max_tokens(mut self, max: u32) -> Self {
378        self.max_tokens = Some(max);
379        self
380    }
381
382    /// Set the sampling temperature for the sub-agent. Note: the newest
383    /// reasoning models (e.g. Claude Fable 5 / Opus 4.7+) reject sampling
384    /// parameters — leave unset for those.
385    pub fn with_temperature(mut self, temperature: f32) -> Self {
386        self.temperature = Some(temperature);
387        self
388    }
389
390    pub fn with_cache_config(mut self, config: CacheConfig) -> Self {
391        self.cache_config = config;
392        self
393    }
394
395    pub fn with_tool_execution(mut self, strategy: ToolExecutionStrategy) -> Self {
396        self.tool_execution = strategy;
397        self
398    }
399
400    pub fn with_retry_config(mut self, config: crate::retry::RetryConfig) -> Self {
401        self.retry_config = config;
402        self
403    }
404
405    /// Give the sub-agent its own context management.
406    ///
407    /// Sub-agents ran without one, so `truncate_tool_output_on_append` never
408    /// fired for them and a sub-agent's oversized tool output entered its
409    /// context whole — and, once #133 wired a stash sink here, that sink was
410    /// unreachable because the loop gates truncation and stashing together on
411    /// this being `Some`.
412    ///
413    /// This enables the **whole** context pipeline for the child loop, not just
414    /// truncation: compaction summarizes and drops old turns once the sub-agent
415    /// exceeds `max_context_tokens`. `max_turns` remains the guard on a
416    /// sub-agent's *length*; this adds guards on any single tool result and on
417    /// total history.
418    ///
419    /// The gate is this being `Some` **and** `truncate_tool_output_on_append`,
420    /// which defaults to true — passing a config with it false still yields no
421    /// truncation and no stash. Note also that the budget is line-based, so a
422    /// single-line result (minified JSON, base64) passes through whole.
423    ///
424    /// Not inherited by nested sub-agents; each needs its own call.
425    pub fn with_context_config(mut self, config: crate::context::ContextConfig) -> Self {
426        self.context_config = Some(config);
427        self
428    }
429
430    /// Cap on turns per delegation (default 10). Changes only the turn
431    /// count; see [`with_execution_limits`](Self::with_execution_limits) for
432    /// the rest.
433    pub fn with_max_turns(mut self, max: usize) -> Self {
434        self.execution_limits = self.execution_limits.with_max_turns(max);
435        self
436    }
437
438    /// The limits each delegation runs under: turns, tokens, wall-clock time
439    /// and loop detection, all per delegation. The default is 10 turns, 1M
440    /// tokens and 5 minutes. The token count includes every turn's whole
441    /// prompt, so a delegation working over a large context reaches 1M in a
442    /// few turns (see
443    /// [`ExecutionLimits::max_total_tokens`](crate::context::ExecutionLimits::max_total_tokens));
444    /// raise it here for long-running sub-agents. Replaces every limit,
445    /// including one set with [`with_max_turns`](Self::with_max_turns)
446    /// earlier.
447    pub fn with_execution_limits(mut self, limits: ExecutionLimits) -> Self {
448        self.execution_limits = limits;
449        self
450    }
451
452    /// Attach a shared key-value store. Sub-agents get a `shared_state` tool
453    /// to read/write variables. The parent can also read/write programmatically
454    /// via the `SharedState` handle.
455    pub fn with_shared_state(mut self, state: SharedState) -> Self {
456        self.shared_state = Some(state);
457        self
458    }
459
460    /// Attach a shared store restricted to this sub-agent's own namespace.
461    ///
462    /// Equivalent to [`with_shared_state(state.scoped(name))`](Self::with_shared_state),
463    /// spelled out because the isolating variant is easy to forget. The
464    /// sub-agent cannot read, overwrite, or even enumerate keys outside its
465    /// scope, while the parent's unscoped handle still sees everything it
466    /// writes.
467    ///
468    /// Use when sub-agents should not see each other's data; keep
469    /// `with_shared_state` when sharing is the point.
470    pub fn with_scoped_shared_state(mut self, state: SharedState, scope: impl AsRef<str>) -> Self {
471        self.shared_state = Some(state.scoped(scope));
472        self
473    }
474
475    /// Add an inter-turn delay to throttle API requests.
476    /// Useful when using OAuth tokens or providers with low rate limits.
477    /// The delay is applied before each turn except the first.
478    pub fn with_turn_delay(mut self, delay: std::time::Duration) -> Self {
479        self.turn_delay = Some(delay);
480        self
481    }
482
483    /// Set the model configuration for multi-provider support.
484    /// Required for non-Anthropic providers (OpenAI-compat, Google, etc.)
485    /// to specify base URL, compat flags, and other provider-specific settings.
486    #[doc(hidden)]
487    #[deprecated(
488        since = "0.10.0",
489        note = "pass the ModelConfig to SubAgentTool::from_config(name, config) or \
490                from_provider(name, provider, config) instead; will be removed in 1.0"
491    )]
492    pub fn with_model_config(mut self, config: ModelConfig) -> Self {
493        self.model_config = Some(config);
494        self
495    }
496}
497
498#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
499#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
500impl AgentTool for SubAgentTool {
501    fn name(&self) -> &str {
502        &self.tool_name
503    }
504
505    fn label(&self) -> &str {
506        &self.tool_name
507    }
508
509    fn description(&self) -> &str {
510        &self.tool_description
511    }
512
513    fn parameters_schema(&self) -> serde_json::Value {
514        serde_json::json!({
515            "type": "object",
516            "properties": {
517                "task": {
518                    "type": "string",
519                    "description": "The task to delegate to this sub-agent"
520                }
521            },
522            "required": ["task"]
523        })
524    }
525
526    async fn execute(
527        &self,
528        params: serde_json::Value,
529        ctx: ToolContext,
530    ) -> Result<ToolResult, ToolError> {
531        let cancel = ctx.cancel.clone();
532        let on_update = ctx.on_update.clone();
533        let on_progress = ctx.on_progress.clone();
534        // Extract the task parameter
535        let task = params
536            .get("task")
537            .and_then(|v| v.as_str())
538            .ok_or_else(|| ToolError::InvalidArgs("Missing required 'task' parameter".into()))?
539            .to_string();
540
541        // Build tool list from Arc wrappers
542        let mut tools: Vec<Box<dyn AgentTool>> = self
543            .tools
544            .iter()
545            .map(|t| Box::new(ArcTool(Arc::clone(t))) as Box<dyn AgentTool>)
546            .collect();
547
548        // Append the skills index (if any) so the sub-agent can discover skills.
549        let mut system_prompt = self.system_prompt.clone();
550        if !self.skills_prompt.is_empty() {
551            if system_prompt.is_empty() {
552                system_prompt = self.skills_prompt.clone();
553            } else {
554                system_prompt = format!("{}\n\n{}", system_prompt, self.skills_prompt);
555            }
556        }
557
558        // Inject SharedStateTool when shared state is configured
559        if let Some(ref state) = self.shared_state {
560            tools.push(Box::new(SharedStateTool::new(state.clone())));
561            let summary = state.prompt_summary().await;
562            system_prompt.push_str(&format!(
563                "\n\n## Shared State\nYou have access to a shared variable store via the `shared_state` tool.\nAvailable: {}",
564                summary
565            ));
566        }
567
568        // Tools resolved for this run come after the static ones (which win
569        // a name collision).
570        if !self.tool_sources.is_empty() {
571            // A hung source must not outlive the parent's abort.
572            let sourced = tokio::select! {
573                sourced = crate::tool_source::collect(&self.tool_sources) => sourced,
574                _ = cancel.cancelled() => return Err(ToolError::Cancelled),
575            };
576            crate::tool_source::merge(&mut tools, sourced);
577        }
578
579        // Fresh context for the sub-agent
580        let mut context = AgentContext {
581            system_prompt,
582            messages: Vec::new(),
583            tools,
584        };
585
586        #[cfg(feature = "decision")]
587        let decision_extensions = crate::decision::wire(
588            self.decision.as_ref(),
589            self.tool_gate.as_ref(),
590            &self.skills,
591        );
592        let (provider, tool_middleware) = (
593            with_turn_hooks(&self.provider, self.turn_hooks.clone()),
594            self.tool_middleware.clone(),
595        );
596
597        // Config with Arc'd provider
598        let mut config = AgentLoopConfig::new(provider, self.model.clone());
599        config.api_key = if self.api_key.is_empty() {
600            crate::provider::resolve_api_key_or_warn(self.model_config.as_ref())
601        } else {
602            self.api_key.clone()
603        };
604        config.thinking_level = self.thinking_level;
605        config.max_tokens = self.max_tokens;
606        config.temperature = self.temperature;
607        config.model_config = self.model_config.clone();
608        config.context_config = self.context_config.clone();
609        config.execution_limits = Some(self.execution_limits.clone());
610        config.cache_config = self.cache_config.clone();
611        config.tool_output_sink = self.shared_state.clone();
612        config.tool_execution = self.tool_execution.clone();
613        config.retry_config = self.retry_config.clone();
614        config.input_filters = self.input_filters.clone();
615        config.tool_middleware = tool_middleware;
616        config.turn_delay = self.turn_delay;
617        config.extensions = self.extensions.clone();
618        #[cfg(feature = "decision")]
619        config.extensions.extend(decision_extensions);
620        config.tree_extensions = self.tree_extensions.clone();
621        config.max_stop_continues = self.max_stop_continues;
622        // The caller's tree extensions (host policy) apply here too, ahead of
623        // this sub-agent's own, at this delegation's depth and label.
624        config.delegated_from(&ctx);
625
626        // Channel for sub-agent events
627        let (tx, rx) = mpsc::unbounded_channel();
628        let mut unforwarded = Some(rx);
629        // Why an input filter rejected the task, if one did.
630        let rejected: Arc<std::sync::Mutex<Option<String>>> = Arc::default();
631
632        // Forward sub-agent events to parent via on_update and on_progress callbacks
633        let forward_handle = if on_update.is_some() || on_progress.is_some() {
634            let tool_name = self.tool_name.clone();
635            let rejected = rejected.clone();
636            let mut rx = unforwarded.take().expect("receiver not taken yet");
637            Some(crate::rt::spawn(async move {
638                while let Some(event) = rx.recv().await {
639                    if let AgentEvent::InputRejected { reason } = &event {
640                        *rejected.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason.clone());
641                    }
642                    // Forward progress messages via on_progress
643                    if let AgentEvent::ProgressMessage { text, .. } = &event {
644                        if let Some(ref cb) = on_progress {
645                            cb(text.clone());
646                        }
647                    }
648
649                    // Convert interesting events to ToolResult updates for the parent
650                    if let Some(ref on_update) = on_update {
651                        let update_text = match &event {
652                            AgentEvent::MessageUpdate {
653                                delta: StreamDelta::Text { delta },
654                                ..
655                            } => Some(delta.clone()),
656                            AgentEvent::ToolExecutionStart { tool_name, .. } => {
657                                Some(format!("[sub-agent calling tool: {}]", tool_name))
658                            }
659                            // The failed attempt's text was forwarded above;
660                            // mark where the retry's text starts.
661                            AgentEvent::ProviderRetry { error, .. } => {
662                                Some(format!("[sub-agent retrying after: {}]", error))
663                            }
664                            _ => None,
665                        };
666
667                        if let Some(text) = update_text {
668                            on_update(ToolResult {
669                                content: vec![Content::Text { text }],
670                                details: serde_json::json!({ "sub_agent": tool_name }),
671                            });
672                        }
673                    }
674                }
675            }))
676        } else {
677            None
678        };
679
680        // Run the sub-agent loop
681        let prompt = AgentMessage::Llm(Message::user(task));
682        let run_cancel = cancel.clone();
683        let (new_messages, run_stats) =
684            agent_loop_with_stats(vec![prompt], &mut context, &config, tx, cancel).await;
685
686        // Wait for event forwarding to complete. A forwarder that failed may
687        // have missed a rejection: the delegation then fails rather than
688        // passing for an empty success.
689        let mut forward_failed = None;
690        if let Some(handle) = forward_handle {
691            if let Err(e) = handle.await {
692                tracing::error!(tool = %self.tool_name, "sub-agent event forwarder failed: {e}");
693                forward_failed = Some(e.to_string());
694            }
695        } else if let Some(mut rx) = unforwarded {
696            while let Ok(event) = rx.try_recv() {
697                if let AgentEvent::InputRejected { reason } = event {
698                    *rejected.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason);
699                }
700            }
701        }
702
703        // Report before deciding success or failure: a failed delegation
704        // still spent tokens, and `Err(ToolError)` has nowhere to carry them.
705        // `run_stats` covers this run's own turns and, recursively, whatever
706        // its own sub-agents reported to it.
707        ctx.report_delegated_run(run_stats.clone());
708        if let Some(e) = forward_failed {
709            return Err(ToolError::Failed(format!(
710                "sub-agent '{}' could not be followed: its event forwarder failed ({e})",
711                self.tool_name
712            )));
713        }
714
715        // An input filter rejected the task: nothing ran.
716        let rejection = rejected.lock().unwrap_or_else(|e| e.into_inner()).take();
717        if let Some(reason) = rejection {
718            return Err(ToolError::Failed(format!(
719                "Sub-agent '{}' rejected its task: {}",
720                self.tool_name, reason
721            )));
722        }
723
724        // Cancelled before the model answered at all: not an empty success.
725        let answered = new_messages
726            .iter()
727            .any(|m| matches!(m, AgentMessage::Llm(Message::Assistant { .. })));
728        if run_cancel.is_cancelled() && !answered {
729            return Err(ToolError::Cancelled);
730        }
731
732        // Check if the last message was an error
733        if let Some(error_msg) = extract_error(&new_messages) {
734            return Err(ToolError::Failed(format!(
735                "Sub-agent '{}' failed: {}",
736                self.tool_name, error_msg
737            )));
738        }
739
740        // Extract final assistant text from the returned messages
741        let result_text = extract_final_text(&new_messages);
742
743        // Include full sub-agent conversation in details for debugging
744        let mut details = serde_json::json!({
745            "sub_agent": self.tool_name,
746            "turns": new_messages.len(),
747        });
748        // Read with `SessionStats::from_sub_agent_result`.
749        details[SUB_AGENT_STATS_KEY] = serde_json::to_value(&run_stats).unwrap_or_default();
750
751        Ok(ToolResult {
752            content: vec![Content::Text { text: result_text }],
753            details,
754        })
755    }
756}
757
758/// `provider` behind the turn hooks, or as is when there are none.
759fn with_turn_hooks(
760    provider: &Arc<dyn StreamProvider>,
761    hooks: Vec<Arc<dyn TurnHook>>,
762) -> Arc<dyn StreamProvider> {
763    if hooks.is_empty() {
764        provider.clone()
765    } else {
766        Arc::new(crate::provider::TurnHookProvider::new(
767            provider.clone(),
768            hooks,
769        ))
770    }
771}
772
773/// Check if the last assistant message was an error, return the error message.
774fn extract_error(messages: &[AgentMessage]) -> Option<String> {
775    // A loop abort ends with a marker user message while the last *assistant*
776    // message still carries `ToolUse`, so the scan below returned `None` and
777    // the delegation reported as a clean success with "(sub-agent produced no
778    // text output)". A sub-agent that burned its entire budget looping looked
779    // like one that simply had nothing to say.
780    //
781    // Only loop aborts and cancels. Hitting `max_turns` is a bound, not a failure — the
782    // work was cut short but what it produced is real, so it is returned, with
783    // the stop notice appended by `extract_final_text` so the parent's model
784    // knows the answer is partial.
785    if let Some(AgentMessage::Llm(Message::User { content, .. })) = messages.last() {
786        if let Some(Content::Text { text }) = content.first() {
787            // A cancel between provider calls did not finish either.
788            if text.starts_with(crate::agent_loop::LOOP_ABORT_PREFIX)
789                || text == crate::agent_loop::CANCELLED_MARKER
790            {
791                return Some(text.clone());
792            }
793        }
794    }
795
796    for msg in messages.iter().rev() {
797        if let AgentMessage::Llm(Message::Assistant {
798            stop_reason,
799            error_message,
800            ..
801        }) = msg
802        {
803            // A cancelled run ends as `Aborted`. Its message is empty, so
804            // falling through would hand back an earlier turn's text as if
805            // the delegation had finished.
806            if *stop_reason == StopReason::Error || *stop_reason == StopReason::Aborted {
807                return Some(error_message.clone().unwrap_or_else(|| {
808                    if *stop_reason == StopReason::Aborted {
809                        "Cancelled".into()
810                    } else {
811                        "Unknown error".into()
812                    }
813                }));
814            }
815        }
816    }
817    None
818}
819
820/// Extract the final assistant text from agent messages.
821/// Collects text from the last assistant message, or returns a fallback.
822/// The loop's own stop marker, if the run ended on one.
823fn stopped_notice(messages: &[AgentMessage]) -> Option<String> {
824    if let Some(AgentMessage::Llm(Message::User { content, .. })) = messages.last() {
825        if let Some(Content::Text { text }) = content.first() {
826            if text.starts_with(crate::agent_loop::AGENT_STOPPED_PREFIX) {
827                return Some(text.clone());
828            }
829        }
830    }
831    None
832}
833
834fn extract_final_text(messages: &[AgentMessage]) -> String {
835    let mut out = None;
836    for msg in messages.iter().rev() {
837        if let AgentMessage::Llm(Message::Assistant { content, .. }) = msg {
838            let texts: Vec<&str> = content
839                .iter()
840                .filter_map(|c| match c {
841                    Content::Text { text } if !text.is_empty() => Some(text.as_str()),
842                    _ => None,
843                })
844                .collect();
845            if !texts.is_empty() {
846                out = Some(texts.join("\n"));
847                break;
848            }
849        }
850    }
851
852    // Tell the parent the answer is partial, whether or not there was text to
853    // return. A turn-limited run whose last assistant message held only tool
854    // calls has no text at all, and reporting that as "produced no text output"
855    // reads as an empty success rather than a run cut short.
856    match (out, stopped_notice(messages)) {
857        (Some(text), Some(stop)) => format!("{text}\n\n{stop}"),
858        (Some(text), None) => text,
859        (None, Some(stop)) => stop,
860        (None, None) => "(sub-agent produced no text output)".to_string(),
861    }
862}