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}