Skip to main content

agent_sdk/
subagent.rs

1//! Subagent support for spawning child agents.
2//!
3//! This module provides the ability to spawn subagents from within an agent.
4//! Subagents are isolated agent instances that run to completion and return
5//! only their final response to the parent agent.
6//!
7//! # Overview
8//!
9//! Subagents are useful for:
10//! - Delegating complex subtasks to specialized agents
11//! - Running parallel investigations
12//! - Isolating context for specific operations
13//!
14//! # Example
15//!
16//! ```ignore
17//! use agent_sdk::subagent::{SubagentTool, SubagentConfig};
18//!
19//! let config = SubagentConfig::new("researcher")
20//!     .with_system_prompt("You are a research specialist...")
21//!     .with_max_turns(10);
22//!
23//! let tool = SubagentTool::new(config, provider, tools, || {
24//!     std::sync::Arc::new(agent_sdk::InMemoryEventStore::new())
25//! });
26//! registry.register(tool);
27//! ```
28//!
29//! # Behavior
30//!
31//! When a subagent runs:
32//! 1. A new isolated thread is created
33//! 2. The subagent runs until completion or max turns
34//! 3. Only the final text response is returned to the parent
35//! 4. The parent does not see the subagent's intermediate tool calls
36
37mod factory;
38
39pub use factory::SubagentFactory;
40
41use crate::events::AgentEvent;
42use crate::hooks::{AgentHooks, DefaultHooks};
43use crate::llm::LlmProvider;
44use crate::stores::{EventStore, InMemoryStore, MessageStore, StateStore};
45use crate::tools::{DynamicToolName, Tool, ToolContext, ToolRegistry};
46use crate::types::{AgentConfig, AgentInput, ThreadId, TokenUsage, ToolResult, ToolTier};
47use anyhow::{Context, Result, bail};
48use serde::{Deserialize, Serialize};
49use serde_json::{Value, json};
50use std::collections::HashMap;
51use std::sync::{Arc, Mutex, OnceLock, PoisonError};
52use std::time::{Duration, Instant};
53use tokio_util::sync::CancellationToken;
54
55/// Return the (display name, description) pair for a subagent of the given
56/// name, interning the leaked `&'static str`s so repeated construction with the
57/// same name reuses one allocation.
58///
59/// `SubagentFactory` builds a fresh `SubagentTool` per request, so leaking on
60/// every construction (the previous approach) grew memory without bound in
61/// long-running hosts. Interning bounds the leak to the number of distinct
62/// subagent names.
63fn cached_tool_strings(name: &str) -> (&'static str, &'static str) {
64    static CACHE: OnceLock<Mutex<HashMap<String, (&'static str, &'static str)>>> = OnceLock::new();
65    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
66    let mut map = cache.lock().unwrap_or_else(PoisonError::into_inner);
67    if let Some(entry) = map.get(name) {
68        return *entry;
69    }
70    let display: &'static str = Box::leak(format!("Subagent: {name}").into_boxed_str());
71    let description: &'static str = Box::leak(
72        format!(
73            "Spawn a subagent named '{name}' to handle a task. The subagent will work independently and return only its final response."
74        )
75        .into_boxed_str(),
76    );
77    *map.entry(name.to_string())
78        .or_insert((display, description))
79}
80
81/// Metadata key for tracking the current subagent nesting depth.
82///
83/// When a subagent spawns another subagent, the depth is incremented.
84/// Tools check this value against the configured maximum depth.
85pub const METADATA_SUBAGENT_DEPTH: &str = "subagent_depth";
86
87/// Metadata key for the maximum allowed subagent nesting depth.
88///
89/// Set by the host application (e.g. bip) to prevent unbounded recursion.
90pub const METADATA_MAX_SUBAGENT_DEPTH: &str = "max_subagent_depth";
91
92/// Configuration for a subagent.
93#[derive(Clone, Debug, Serialize, Deserialize)]
94pub struct SubagentConfig {
95    /// Name of the subagent (for identification).
96    pub name: String,
97    /// Human-friendly nickname assigned by the parent (e.g., "Zara").
98    pub nickname: Option<String>,
99    /// System prompt for the subagent.
100    pub system_prompt: String,
101    /// Maximum number of turns before stopping.
102    pub max_turns: Option<usize>,
103    /// Optional timeout in milliseconds.
104    pub timeout_ms: Option<u64>,
105    /// Optional model override for this subagent.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub model: Option<String>,
108}
109
110impl SubagentConfig {
111    /// Create a new subagent configuration.
112    #[must_use]
113    pub fn new(name: impl Into<String>) -> Self {
114        Self {
115            name: name.into(),
116            nickname: None,
117            system_prompt: String::new(),
118            max_turns: None,
119            timeout_ms: None,
120            model: None,
121        }
122    }
123
124    /// Set the system prompt.
125    #[must_use]
126    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
127        self.system_prompt = prompt.into();
128        self
129    }
130
131    /// Set the maximum number of turns.
132    #[must_use]
133    pub const fn with_max_turns(mut self, max: usize) -> Self {
134        self.max_turns = Some(max);
135        self
136    }
137
138    /// Set the timeout in milliseconds.
139    #[must_use]
140    pub const fn with_timeout_ms(mut self, timeout: u64) -> Self {
141        self.timeout_ms = Some(timeout);
142        self
143    }
144
145    /// Set the model override for this subagent.
146    #[must_use]
147    pub fn with_model(mut self, model: impl Into<String>) -> Self {
148        self.model = Some(model.into());
149        self
150    }
151
152    /// Set a human-friendly nickname for this subagent.
153    #[must_use]
154    pub fn with_nickname(mut self, nickname: impl Into<String>) -> Self {
155        self.nickname = Some(nickname.into());
156        self
157    }
158}
159
160/// Log entry for a single tool call within a subagent.
161#[derive(Clone, Debug, Serialize, Deserialize)]
162pub struct ToolCallLog {
163    /// Tool name.
164    pub name: String,
165    /// Tool display name.
166    pub display_name: String,
167    /// Brief context/args (e.g., file path, command).
168    pub context: String,
169    /// Brief result summary.
170    pub result: String,
171    /// Whether the tool call succeeded.
172    pub success: bool,
173    /// Duration in milliseconds.
174    pub duration_ms: Option<u64>,
175}
176
177/// Result from a subagent execution.
178#[derive(Clone, Debug, Serialize, Deserialize)]
179pub struct SubagentResult {
180    /// Name of the subagent.
181    pub name: String,
182    /// The final text response (only visible part to parent).
183    pub final_response: String,
184    /// Total number of turns taken.
185    pub total_turns: usize,
186    /// Number of tool calls made by the subagent.
187    pub tool_count: u32,
188    /// Log of tool calls made by the subagent.
189    pub tool_logs: Vec<ToolCallLog>,
190    /// Token usage statistics.
191    pub usage: TokenUsage,
192    /// Whether the subagent completed successfully.
193    pub success: bool,
194    /// Duration in milliseconds.
195    pub duration_ms: u64,
196    /// Detailed error information when `success` is false.
197    ///
198    /// Contains the raw error message from the agent event, which may include
199    /// stack trace information or structured error context.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub error_details: Option<String>,
202    /// Retained for serialization compatibility with older clients.
203    ///
204    /// The previous implementation inferred this from the "last pending tool"
205    /// when any generic error occurred, which was incorrect for LLM or
206    /// transport failures. The field is currently never populated until the
207    /// SDK has deterministic error provenance.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub failed_tool: Option<String>,
210}
211
212/// Tool for spawning subagents.
213///
214/// This tool allows an agent to spawn a child agent that runs independently
215/// and returns only its final response.
216///
217/// # Example
218///
219/// ```ignore
220/// use agent_sdk::subagent::{SubagentTool, SubagentConfig};
221///
222/// let config = SubagentConfig::new("analyzer")
223///     .with_system_prompt("You analyze code...");
224///
225/// let tool = SubagentTool::new(config, provider.clone(), tools.clone(), || {
226///     std::sync::Arc::new(agent_sdk::InMemoryEventStore::new())
227/// });
228/// ```
229pub struct SubagentTool<P, H = DefaultHooks, M = InMemoryStore, S = InMemoryStore>
230where
231    P: LlmProvider,
232    H: AgentHooks,
233    M: MessageStore,
234    S: StateStore,
235{
236    config: SubagentConfig,
237    provider: Arc<P>,
238    tools: Arc<ToolRegistry<()>>,
239    hooks: Arc<H>,
240    message_store_factory: Arc<dyn Fn() -> M + Send + Sync>,
241    state_store_factory: Arc<dyn Fn() -> S + Send + Sync>,
242    event_store_factory: Arc<dyn Fn() -> Arc<dyn EventStore> + Send + Sync>,
243    /// Cached display name to avoid `Box::leak` on every call.
244    cached_display_name: &'static str,
245    /// Cached description to avoid `Box::leak` on every call.
246    cached_description: &'static str,
247}
248
249impl<P> SubagentTool<P, DefaultHooks, InMemoryStore, InMemoryStore>
250where
251    P: LlmProvider + 'static,
252{
253    /// Create a new subagent tool with default hooks and in-memory message/state stores.
254    #[must_use]
255    pub fn new<EF>(
256        config: SubagentConfig,
257        provider: Arc<P>,
258        tools: Arc<ToolRegistry<()>>,
259        event_store_factory: EF,
260    ) -> Self
261    where
262        EF: Fn() -> Arc<dyn EventStore> + Send + Sync + 'static,
263    {
264        // Intern the leaked strings so repeated construction with the same
265        // name reuses one allocation (factories build a tool per request).
266        let (cached_display_name, cached_description) = cached_tool_strings(&config.name);
267        Self {
268            config,
269            provider,
270            tools,
271            hooks: Arc::new(DefaultHooks),
272            message_store_factory: Arc::new(InMemoryStore::new),
273            state_store_factory: Arc::new(InMemoryStore::new),
274            event_store_factory: Arc::new(event_store_factory),
275            cached_display_name,
276            cached_description,
277        }
278    }
279}
280
281impl<P, H, M, S> SubagentTool<P, H, M, S>
282where
283    P: LlmProvider + Clone + 'static,
284    H: AgentHooks + Clone + 'static,
285    M: MessageStore + 'static,
286    S: StateStore + 'static,
287{
288    /// Create with custom hooks.
289    #[must_use]
290    pub fn with_hooks<H2: AgentHooks + Clone + 'static>(
291        self,
292        hooks: Arc<H2>,
293    ) -> SubagentTool<P, H2, M, S> {
294        SubagentTool {
295            config: self.config,
296            provider: self.provider,
297            tools: self.tools,
298            hooks,
299            message_store_factory: self.message_store_factory,
300            state_store_factory: self.state_store_factory,
301            event_store_factory: self.event_store_factory,
302            cached_display_name: self.cached_display_name,
303            cached_description: self.cached_description,
304        }
305    }
306
307    /// Create with custom store factories.
308    #[must_use]
309    pub fn with_stores<M2, S2, MF, SF>(
310        self,
311        message_factory: MF,
312        state_factory: SF,
313    ) -> SubagentTool<P, H, M2, S2>
314    where
315        M2: MessageStore + 'static,
316        S2: StateStore + 'static,
317        MF: Fn() -> M2 + Send + Sync + 'static,
318        SF: Fn() -> S2 + Send + Sync + 'static,
319    {
320        SubagentTool {
321            config: self.config,
322            provider: self.provider,
323            tools: self.tools,
324            hooks: self.hooks,
325            message_store_factory: Arc::new(message_factory),
326            state_store_factory: Arc::new(state_factory),
327            event_store_factory: self.event_store_factory,
328            cached_display_name: self.cached_display_name,
329            cached_description: self.cached_description,
330        }
331    }
332
333    /// Get the subagent configuration.
334    #[must_use]
335    pub const fn config(&self) -> &SubagentConfig {
336        &self.config
337    }
338
339    /// Run the subagent with a task.
340    ///
341    /// The `parent_cancel` token links the subagent's lifecycle to its parent.
342    /// Cancelling the parent token will also cancel the subagent.
343    async fn run_subagent<Ctx>(
344        &self,
345        task: &str,
346        subagent_id: String,
347        parent_ctx: &ToolContext<Ctx>,
348        parent_cancel: CancellationToken,
349    ) -> Result<SubagentResult>
350    where
351        Ctx: Send + Sync + 'static,
352    {
353        use crate::agent_loop::AgentLoop;
354
355        let start = Instant::now();
356        // Each subagent run gets its own thread id, so a shared event store
357        // only returns this subagent's events when queried with `thread_id`.
358        let thread_id = ThreadId::new();
359
360        // Create stores for this subagent run
361        let message_store = (self.message_store_factory)();
362        let state_store = (self.state_store_factory)();
363        let event_store = (self.event_store_factory)();
364
365        // Create agent config with a default max_turns to prevent unbounded execution
366        let agent_config = AgentConfig {
367            max_turns: Some(self.config.max_turns.unwrap_or(100)),
368            system_prompt: self.config.system_prompt.clone(),
369            ..Default::default()
370        };
371
372        // Build the subagent
373        let agent = AgentLoop::new(
374            (*self.provider).clone(),
375            (*self.tools).clone(),
376            (*self.hooks).clone(),
377            message_store,
378            state_store,
379            Arc::clone(&event_store),
380            agent_config,
381        );
382
383        // Derive the child context from the parent so the subagent-depth guard
384        // and the concurrency semaphore actually propagate into nested runs.
385        let tool_ctx = build_subagent_child_context(parent_ctx);
386
387        // Run with a child cancellation token so parent cancellation propagates.
388        // Use `run_abortable` so we can abort the spawned task on timeout
389        // instead of leaving a detached task that continues making API calls.
390        let cancel_token = parent_cancel.child_token();
391        let timeout_cancel = cancel_token.clone();
392        let (state_rx, task_handle) = agent.run_abortable(
393            thread_id.clone(),
394            AgentInput::Text(task.to_string()),
395            tool_ctx,
396            cancel_token,
397        );
398
399        let wait_result = wait_for_subagent_state(self.config.timeout_ms, start, state_rx).await;
400        let mut state = SubagentExecutionState::new();
401        let replay_events = apply_subagent_wait_outcome(
402            classify_subagent_wait_result(wait_result.as_ref()),
403            &self.config,
404            &timeout_cancel,
405            &task_handle,
406            &mut state,
407        );
408
409        if replay_events {
410            replay_subagent_events(
411                &event_store,
412                &thread_id,
413                parent_ctx,
414                &self.config,
415                &subagent_id,
416                &mut state,
417            )
418            .await?;
419        }
420
421        let result = state.into_result(self.config.name.clone(), start);
422        emit_subagent_observability(self, &result);
423        Ok(result)
424    }
425}
426
427/// Derive the tool context for a subagent run from its parent.
428///
429/// Copies the parent metadata (so host-set policy such as
430/// [`METADATA_MAX_SUBAGENT_DEPTH`] carries through), increments
431/// [`METADATA_SUBAGENT_DEPTH`] by one, and propagates the shared subagent
432/// concurrency semaphore. Without this the depth guard is dead code and nested
433/// spawning is unbounded.
434fn build_subagent_child_context<Ctx>(parent_ctx: &ToolContext<Ctx>) -> ToolContext<()> {
435    let parent_depth = parent_ctx
436        .metadata
437        .get(METADATA_SUBAGENT_DEPTH)
438        .and_then(Value::as_u64)
439        .unwrap_or(0);
440
441    let mut child = ToolContext::new(());
442    child.metadata.clone_from(&parent_ctx.metadata);
443    child
444        .metadata
445        .insert(METADATA_SUBAGENT_DEPTH.to_string(), json!(parent_depth + 1));
446
447    if let Some(semaphore) = parent_ctx.subagent_semaphore() {
448        child = child.with_subagent_semaphore(semaphore);
449    }
450    child
451}
452
453type SubagentWaitResult = Result<
454    Result<crate::types::AgentRunState, tokio::sync::oneshot::error::RecvError>,
455    tokio::time::error::Elapsed,
456>;
457
458struct SubagentExecutionState {
459    final_response: String,
460    /// Message id whose text currently populates `final_response`. Used to keep
461    /// only the **last** assistant message's text (the documented contract),
462    /// rather than concatenating every interim turn's commentary.
463    final_response_message_id: Option<String>,
464    total_turns: usize,
465    tool_count: u32,
466    tool_logs: Vec<ToolCallLog>,
467    pending_tools: HashMap<String, (String, String)>,
468    total_usage: TokenUsage,
469    success: bool,
470    error_details: Option<String>,
471    failed_tool: Option<String>,
472}
473
474impl SubagentExecutionState {
475    fn new() -> Self {
476        Self {
477            final_response: String::new(),
478            final_response_message_id: None,
479            total_turns: 0,
480            tool_count: 0,
481            tool_logs: Vec::new(),
482            pending_tools: HashMap::new(),
483            total_usage: TokenUsage::default(),
484            success: true,
485            error_details: None,
486            failed_tool: None,
487        }
488    }
489
490    /// Record a failure: set the parent-visible response, the detailed error,
491    /// and clear the success flag.
492    fn fail(&mut self, response: impl Into<String>, details: String) {
493        self.final_response = response.into();
494        self.error_details = Some(details);
495        self.success = false;
496    }
497
498    fn into_result(self, name: String, start: Instant) -> SubagentResult {
499        SubagentResult {
500            name,
501            final_response: self.final_response,
502            total_turns: self.total_turns,
503            tool_count: self.tool_count,
504            tool_logs: self.tool_logs,
505            usage: self.total_usage,
506            success: self.success,
507            duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
508            error_details: self.error_details,
509            failed_tool: self.failed_tool,
510        }
511    }
512}
513
514fn subagent_total_tokens(total_usage: &TokenUsage) -> u64 {
515    u64::from(total_usage.input_tokens) + u64::from(total_usage.output_tokens)
516}
517
518struct SubagentProgressUpdate<'a> {
519    subagent_id: &'a str,
520    total_turns: usize,
521    total_usage: &'a TokenUsage,
522    tool_name: String,
523    tool_context: String,
524    completed: bool,
525    success: bool,
526    tool_count: u32,
527}
528
529enum SubagentWaitOutcome {
530    ReplayEvents,
531    TimedOut,
532    Disconnected,
533    Cancelled,
534    AwaitingConfirmation,
535    Error(crate::types::AgentError),
536}
537
538async fn wait_for_subagent_state(
539    timeout_ms: Option<u64>,
540    start: Instant,
541    state_rx: tokio::sync::oneshot::Receiver<crate::types::AgentRunState>,
542) -> Option<SubagentWaitResult> {
543    let timeout_duration = timeout_ms.map(Duration::from_millis);
544    if timeout_duration.is_some_and(|timeout| timeout.saturating_sub(start.elapsed()).is_zero()) {
545        return None;
546    }
547    if let Some(timeout) = timeout_duration {
548        let remaining = timeout.saturating_sub(start.elapsed());
549        Some(tokio::time::timeout(remaining, state_rx).await)
550    } else {
551        Some(Ok(state_rx.await))
552    }
553}
554
555fn classify_subagent_wait_result(wait_result: Option<&SubagentWaitResult>) -> SubagentWaitOutcome {
556    match wait_result {
557        Some(Ok(Ok(
558            crate::types::AgentRunState::Done { .. } | crate::types::AgentRunState::Refusal { .. },
559        ))) => SubagentWaitOutcome::ReplayEvents,
560        Some(Ok(Ok(crate::types::AgentRunState::Cancelled { .. }))) => {
561            SubagentWaitOutcome::Cancelled
562        }
563        Some(Ok(Ok(crate::types::AgentRunState::AwaitingConfirmation { .. }))) => {
564            SubagentWaitOutcome::AwaitingConfirmation
565        }
566        Some(Ok(Ok(crate::types::AgentRunState::Error(error)))) => {
567            SubagentWaitOutcome::Error(error.clone())
568        }
569        // `AgentRunState` is `#[non_exhaustive]`; an unrecognized future run
570        // state is surfaced as an error so the subagent failure is not
571        // silently treated as a successful replay.
572        Some(Ok(Ok(_))) => SubagentWaitOutcome::Error(crate::types::AgentError::new(
573            "subagent returned an unrecognized run state".to_string(),
574            false,
575        )),
576        Some(Ok(Err(_))) => SubagentWaitOutcome::Disconnected,
577        None | Some(Err(_)) => SubagentWaitOutcome::TimedOut,
578    }
579}
580
581fn apply_subagent_wait_outcome(
582    outcome: SubagentWaitOutcome,
583    config: &SubagentConfig,
584    timeout_cancel: &CancellationToken,
585    task_handle: &tokio::task::JoinHandle<()>,
586    state: &mut SubagentExecutionState,
587) -> bool {
588    // Compute the (parent-visible response, detailed error) for the failure
589    // arms; a successful replay returns early and leaves the finished task be.
590    let (response, details) = match outcome {
591        SubagentWaitOutcome::ReplayEvents => return true,
592        SubagentWaitOutcome::TimedOut => (
593            "Subagent timed out".to_string(),
594            format!(
595                "Subagent '{}' timed out after {}ms",
596                config.name,
597                config.timeout_ms.unwrap_or(0)
598            ),
599        ),
600        SubagentWaitOutcome::Disconnected => (
601            "Subagent ended unexpectedly".to_string(),
602            format!(
603                "Subagent '{}' ended before returning a final state",
604                config.name
605            ),
606        ),
607        SubagentWaitOutcome::Cancelled => (
608            "Subagent cancelled".to_string(),
609            format!("Subagent '{}' was cancelled", config.name),
610        ),
611        SubagentWaitOutcome::AwaitingConfirmation => (
612            "Subagent requires confirmation".to_string(),
613            format!(
614                "Subagent '{}' requested confirmation, which is not supported in nested runs",
615                config.name
616            ),
617        ),
618        SubagentWaitOutcome::Error(error) => (error.message.clone(), error.message),
619    };
620
621    // Every failure path reclaims the spawned run before reporting.
622    timeout_cancel.cancel();
623    task_handle.abort();
624    state.fail(response, details);
625    false
626}
627
628// The ENG-9422 `..` pattern (fmt insists on three lines) tipped this one
629// line over the limit; same call as the provider impls' allows.
630#[allow(clippy::too_many_lines)]
631async fn replay_subagent_events<Ctx: Send + Sync + 'static>(
632    event_store: &Arc<dyn EventStore>,
633    thread_id: &ThreadId,
634    parent_ctx: &ToolContext<Ctx>,
635    config: &SubagentConfig,
636    subagent_id: &str,
637    state: &mut SubagentExecutionState,
638) -> Result<()> {
639    for envelope in event_store.get_events(thread_id).await? {
640        match envelope.event {
641            AgentEvent::Text {
642                message_id, text, ..
643            } => {
644                // Keep only the last assistant message's text. A new message id
645                // means a new assistant message, so reset the accumulator;
646                // multiple text blocks within one message still concatenate.
647                // This honors the documented contract that the parent receives
648                // "only the final text response", not interim commentary.
649                if state.final_response_message_id.as_deref() != Some(message_id.as_str()) {
650                    state.final_response.clear();
651                    state.final_response_message_id = Some(message_id);
652                }
653                state.final_response.push_str(&text);
654            }
655            AgentEvent::ToolCallStart {
656                id, name, input, ..
657            } => {
658                state.tool_count += 1;
659                let context = extract_tool_context(&name, &input);
660                state
661                    .pending_tools
662                    .insert(id, (name.clone(), context.clone()));
663
664                emit_subagent_progress_if_possible(
665                    parent_ctx,
666                    config,
667                    SubagentProgressUpdate {
668                        subagent_id,
669                        total_turns: state.total_turns,
670                        total_usage: &state.total_usage,
671                        tool_name: name,
672                        tool_context: context,
673                        completed: false,
674                        success: false,
675                        tool_count: state.tool_count,
676                    },
677                )
678                .await;
679            }
680            AgentEvent::ToolCallEnd {
681                id,
682                name,
683                display_name,
684                result,
685            } => {
686                let context = state
687                    .pending_tools
688                    .remove(&id)
689                    .map(|(_, ctx)| ctx)
690                    .unwrap_or_default();
691                let tool_success = result.success;
692                state.tool_logs.push(ToolCallLog {
693                    name: name.clone(),
694                    display_name: display_name.clone(),
695                    context: context.clone(),
696                    result: summarize_tool_result(&name, &result),
697                    success: tool_success,
698                    duration_ms: result.duration_ms,
699                });
700
701                emit_subagent_progress_if_possible(
702                    parent_ctx,
703                    config,
704                    SubagentProgressUpdate {
705                        subagent_id,
706                        total_turns: state.total_turns,
707                        total_usage: &state.total_usage,
708                        tool_name: name,
709                        tool_context: context,
710                        completed: true,
711                        success: tool_success,
712                        tool_count: state.tool_count,
713                    },
714                )
715                .await;
716            }
717            AgentEvent::TurnComplete { turn, usage, .. } => {
718                state.total_turns = turn;
719                state.total_usage.add(&usage);
720            }
721            AgentEvent::Done {
722                total_turns: turns, ..
723            } => {
724                state.total_turns = turns;
725                break;
726            }
727            AgentEvent::Refusal { text, .. } => {
728                let refusal_message =
729                    text.unwrap_or_else(|| "Subagent refused the request".to_string());
730                state.error_details = Some(refusal_message.clone());
731                state.final_response = refusal_message;
732                state.success = false;
733                break;
734            }
735            AgentEvent::Error { message, .. } => {
736                state.error_details = Some(message.clone());
737                state.final_response = message;
738                state.success = false;
739                break;
740            }
741            _ => {}
742        }
743    }
744    Ok(())
745}
746
747async fn emit_subagent_progress_if_possible<Ctx: Send + Sync + 'static>(
748    parent_ctx: &ToolContext<Ctx>,
749    config: &SubagentConfig,
750    update: SubagentProgressUpdate<'_>,
751) {
752    if let Err(error) = emit_subagent_progress(parent_ctx, config, update).await {
753        log::warn!("Failed to emit subagent progress event: {error}");
754    }
755}
756
757async fn emit_subagent_progress<Ctx: Send + Sync + 'static>(
758    parent_ctx: &ToolContext<Ctx>,
759    config: &SubagentConfig,
760    SubagentProgressUpdate {
761        subagent_id,
762        total_turns,
763        total_usage,
764        tool_name,
765        tool_context,
766        completed,
767        success,
768        tool_count,
769    }: SubagentProgressUpdate<'_>,
770) -> Result<()> {
771    let max_turns = config.max_turns.map(usize_to_u32_saturating);
772    let current_turn = Some(usize_to_u32_saturating(total_turns));
773
774    parent_ctx
775        .emit_event(AgentEvent::SubagentProgress {
776            subagent_id: subagent_id.to_string(),
777            subagent_name: config.name.clone(),
778            nickname: config.nickname.clone(),
779            child_thread_id: None,
780            child_root_task_id: None,
781            subagent_task_id: None,
782            max_turns,
783            current_turn,
784            model: config.model.clone(),
785            tool_name,
786            tool_context,
787            completed,
788            success,
789            tool_count,
790            total_tokens: subagent_total_tokens(total_usage),
791            input_tokens: u64::from(total_usage.input_tokens),
792            output_tokens: u64::from(total_usage.output_tokens),
793            cache_read_input_tokens: u64::from(total_usage.cached_input_tokens),
794            cache_creation_input_tokens: u64::from(total_usage.cache_creation_input_tokens),
795        })
796        .await
797}
798
799fn usize_to_u32_saturating(value: usize) -> u32 {
800    u32::try_from(value).unwrap_or(u32::MAX)
801}
802
803#[cfg(feature = "otel")]
804fn emit_subagent_observability<P, H, M, S>(tool: &SubagentTool<P, H, M, S>, result: &SubagentResult)
805where
806    P: LlmProvider + Clone + 'static,
807    H: AgentHooks + Clone + 'static,
808    M: MessageStore + 'static,
809    S: StateStore + 'static,
810{
811    use crate::observability::{attrs, baggage, langfuse, metrics, provider_name, spans};
812    use opentelemetry::Context;
813    use opentelemetry::KeyValue;
814    use opentelemetry::trace::{Span, TraceContextExt};
815
816    // Capture the parent turn's SpanContext **before** starting the
817    // synthetic subagent span so the link points at the caller, not at
818    // ourselves.  When the agent runs without an active parent span
819    // (e.g. running a subagent from a top-level test) the captured
820    // context is invalid and `link_to_parent_turn` becomes a no-op.
821    let parent_ctx = Context::current();
822    let parent_span_context = parent_ctx.span().span_context().clone();
823
824    let normalized_provider_name = provider_name::normalize(tool.provider.provider());
825    let request_model = tool.provider.model().to_string();
826    let agent_name = tool.config.name.clone();
827
828    let mut span = spans::start_internal_span(
829        "invoke_agent",
830        vec![
831            KeyValue::new(attrs::GEN_AI_OPERATION_NAME, "invoke_agent"),
832            KeyValue::new(attrs::GEN_AI_AGENT_NAME, agent_name.clone()),
833            KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, normalized_provider_name),
834            KeyValue::new(attrs::GEN_AI_REQUEST_MODEL, request_model.clone()),
835            KeyValue::new(attrs::SDK_RUN_MODE, "loop"),
836        ],
837    );
838    baggage::copy_baggage_to_active_span(&mut span);
839    langfuse::tag_observation(&mut span, langfuse::ObservationType::Agent);
840    if parent_span_context.is_valid() {
841        spans::link_to_parent_turn(
842            &mut span,
843            &parent_span_context.trace_id().to_string(),
844            &parent_span_context.span_id().to_string(),
845        );
846    }
847    let outcome = if result.success { "done" } else { "error" };
848    span.set_attribute(KeyValue::new(attrs::SDK_OUTCOME, outcome));
849    span.set_attribute(attrs::kv_i64(
850        attrs::SDK_TOTAL_TURNS,
851        i64::try_from(result.total_turns).unwrap_or(0),
852    ));
853    span.set_attribute(attrs::kv_i64(
854        attrs::GEN_AI_USAGE_INPUT_TOKENS,
855        i64::from(result.usage.input_tokens),
856    ));
857    span.set_attribute(attrs::kv_i64(
858        attrs::GEN_AI_USAGE_OUTPUT_TOKENS,
859        i64::from(result.usage.output_tokens),
860    ));
861    if outcome == "error" {
862        spans::set_span_error(&mut span, "agent_error", "subagent invocation failed");
863    }
864    span.end();
865
866    // ── Metrics ─────────────────────────────────────────────────────
867    //
868    // Subagent invocations get a dedicated counter so dashboards can
869    // tell parent-turn LLM activity apart from delegated subagent
870    // work. We also fold the subagent's token usage into the shared
871    // `gen_ai.client.token.usage` histogram so global token-spend
872    // accounting stays accurate; the `gen_ai.operation.name` label
873    // discriminates `invoke_agent` from regular `chat` calls.
874    let metrics_handle = metrics::Metrics::global();
875    metrics_handle.subagent_invocations.add(
876        1,
877        &[
878            KeyValue::new(attrs::GEN_AI_AGENT_NAME, agent_name),
879            KeyValue::new(attrs::SDK_OUTCOME, outcome),
880        ],
881    );
882    record_subagent_token_usage(
883        &metrics_handle,
884        result,
885        normalized_provider_name,
886        &request_model,
887    );
888}
889
890#[cfg(feature = "otel")]
891fn record_subagent_token_usage(
892    metrics: &crate::observability::metrics::Metrics,
893    result: &SubagentResult,
894    provider_name: &'static str,
895    request_model: &str,
896) {
897    use crate::observability::attrs;
898    use opentelemetry::KeyValue;
899
900    let entries: [(u32, &'static str); 2] = [
901        (result.usage.input_tokens, "input"),
902        (result.usage.output_tokens, "output"),
903    ];
904
905    for (count, token_type) in entries {
906        if count == 0 {
907            continue;
908        }
909        metrics.token_usage.record(
910            u64::from(count),
911            &[
912                KeyValue::new(attrs::GEN_AI_OPERATION_NAME, "invoke_agent"),
913                KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, provider_name),
914                KeyValue::new("gen_ai.token.type", token_type),
915                KeyValue::new(attrs::GEN_AI_REQUEST_MODEL, request_model.to_string()),
916            ],
917        );
918    }
919}
920
921#[cfg(not(feature = "otel"))]
922const fn emit_subagent_observability<P, H, M, S>(
923    _tool: &SubagentTool<P, H, M, S>,
924    _result: &SubagentResult,
925) where
926    P: LlmProvider + Clone + 'static,
927    H: AgentHooks + Clone + 'static,
928    M: MessageStore + 'static,
929    S: StateStore + 'static,
930{
931}
932
933/// Extracts context information from tool input for display.
934fn extract_tool_context(name: &str, input: &Value) -> String {
935    match name {
936        "read" => input
937            .get("file_path")
938            .or_else(|| input.get("path"))
939            .and_then(Value::as_str)
940            .unwrap_or("")
941            .to_string(),
942        "write" | "edit" => input
943            .get("file_path")
944            .or_else(|| input.get("path"))
945            .and_then(Value::as_str)
946            .unwrap_or("")
947            .to_string(),
948        "bash" => {
949            let cmd = input.get("command").and_then(Value::as_str).unwrap_or("");
950            // Truncate long commands (UTF-8 safe)
951            if cmd.len() > 60 {
952                format!("{}...", crate::primitive_tools::truncate_str(cmd, 57))
953            } else {
954                cmd.to_string()
955            }
956        }
957        "glob" | "grep" => input
958            .get("pattern")
959            .and_then(Value::as_str)
960            .unwrap_or("")
961            .to_string(),
962        "web_search" => input
963            .get("query")
964            .and_then(Value::as_str)
965            .unwrap_or("")
966            .to_string(),
967        _ => String::new(),
968    }
969}
970
971/// Summarizes tool result for logging.
972fn summarize_tool_result(name: &str, result: &ToolResult) -> String {
973    if !result.success {
974        let first_line = result.output.lines().next().unwrap_or("Error");
975        return if first_line.len() > 50 {
976            format!(
977                "{}...",
978                crate::primitive_tools::truncate_str(first_line, 47)
979            )
980        } else {
981            first_line.to_string()
982        };
983    }
984
985    match name {
986        "read" => {
987            let line_count = result.output.lines().count();
988            format!("{line_count} lines")
989        }
990        "write" => "wrote file".to_string(),
991        "edit" => "edited".to_string(),
992        "bash" => {
993            let lines: Vec<&str> = result.output.lines().collect();
994            if lines.is_empty() {
995                "done".to_string()
996            } else if lines.len() == 1 {
997                let line = lines[0];
998                if line.len() > 50 {
999                    format!("{}...", crate::primitive_tools::truncate_str(line, 47))
1000                } else {
1001                    line.to_string()
1002                }
1003            } else {
1004                format!("{} lines", lines.len())
1005            }
1006        }
1007        "glob" => {
1008            let count = result.output.lines().count();
1009            format!("{count} files")
1010        }
1011        "grep" => {
1012            let count = result.output.lines().count();
1013            format!("{count} matches")
1014        }
1015        _ => {
1016            let line_count = result.output.lines().count();
1017            if line_count == 0 {
1018                "done".to_string()
1019            } else {
1020                format!("{line_count} lines")
1021            }
1022        }
1023    }
1024}
1025
1026impl<P, H, M, S, Ctx> Tool<Ctx> for SubagentTool<P, H, M, S>
1027where
1028    P: LlmProvider + Clone + 'static,
1029    H: AgentHooks + Clone + 'static,
1030    M: MessageStore + 'static,
1031    S: StateStore + 'static,
1032    Ctx: Send + Sync + 'static,
1033{
1034    type Name = DynamicToolName;
1035
1036    fn name(&self) -> DynamicToolName {
1037        DynamicToolName::new(format!("subagent_{}", self.config.name))
1038    }
1039
1040    fn display_name(&self) -> &'static str {
1041        self.cached_display_name
1042    }
1043
1044    fn description(&self) -> &'static str {
1045        self.cached_description
1046    }
1047
1048    fn input_schema(&self) -> Value {
1049        json!({
1050            "type": "object",
1051            "properties": {
1052                "task": {
1053                    "type": "string",
1054                    "description": "The task or question for the subagent to handle"
1055                }
1056            },
1057            "required": ["task"]
1058        })
1059    }
1060
1061    fn tier(&self) -> ToolTier {
1062        // Subagent spawning requires confirmation
1063        ToolTier::Confirm
1064    }
1065
1066    async fn execute(&self, ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
1067        let task = input
1068            .get("task")
1069            .and_then(Value::as_str)
1070            .context("Missing 'task' parameter")?;
1071
1072        // ── Depth limit enforcement ───────────────────────────────────
1073        let current_depth = ctx
1074            .metadata
1075            .get(METADATA_SUBAGENT_DEPTH)
1076            .and_then(Value::as_u64)
1077            .unwrap_or(0);
1078        let max_depth = ctx
1079            .metadata
1080            .get(METADATA_MAX_SUBAGENT_DEPTH)
1081            .and_then(Value::as_u64)
1082            .unwrap_or(3); // default: 3 levels deep
1083
1084        if current_depth >= max_depth {
1085            bail!(
1086                "Subagent depth limit exceeded ({current_depth}/{max_depth}). \
1087                 Cannot spawn nested subagent '{}' — maximum nesting depth reached.",
1088                self.config.name
1089            );
1090        }
1091
1092        // ── Thread limit enforcement (semaphore) ──────────────────────
1093        let _permit = if let Some(ref sem) = ctx.subagent_semaphore() {
1094            match sem.clone().try_acquire_owned() {
1095                Ok(permit) => Some(permit),
1096                Err(_) => {
1097                    return Ok(ToolResult {
1098                        success: false,
1099                        output: format!(
1100                            "Cannot spawn subagent '{}': maximum concurrent subagent limit reached. \
1101                         Try again when another subagent completes.",
1102                            self.config.name
1103                        ),
1104                        artifact: None,
1105                        data: None,
1106                        documents: Vec::new(),
1107                        duration_ms: Some(0),
1108                    });
1109                }
1110            }
1111        } else {
1112            None
1113        };
1114
1115        // Generate a unique ID for this subagent execution
1116        let subagent_id = format!(
1117            "{}_{:x}",
1118            self.config.name,
1119            std::time::SystemTime::now()
1120                .duration_since(std::time::UNIX_EPOCH)
1121                .unwrap_or_default()
1122                .as_nanos()
1123        );
1124
1125        // Use the context's cancellation token if available, otherwise create a standalone one.
1126        // This ensures that when a parent agent is cancelled, subagents are also cancelled.
1127        let cancel_token = ctx.cancel_token().unwrap_or_default();
1128
1129        let result = self
1130            .run_subagent(task, subagent_id, ctx, cancel_token)
1131            .await?;
1132
1133        Ok(ToolResult {
1134            success: result.success,
1135            output: result.final_response.clone(),
1136            artifact: None,
1137            data: Some(serde_json::to_value(&result).unwrap_or_default()),
1138            documents: Vec::new(),
1139            duration_ms: Some(result.duration_ms),
1140        })
1141    }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use crate::authority::{EventAuthority, LocalEventAuthority};
1148    use crate::events::{AgentEvent, AgentEventEnvelope};
1149    use crate::llm::{ChatOutcome, ChatRequest, ChatResponse, ContentBlock, StopReason, Usage};
1150    use crate::stores::{EventStore, InMemoryEventStore, StoredTurnEvents};
1151    use anyhow::{Context, Result, bail};
1152    use async_trait::async_trait;
1153    use tokio::sync::Mutex;
1154
1155    #[derive(Clone)]
1156    struct TestProvider {
1157        responses: Arc<Mutex<Vec<ChatOutcome>>>,
1158        delay: Option<Duration>,
1159    }
1160
1161    impl TestProvider {
1162        fn new(responses: Vec<ChatOutcome>) -> Self {
1163            Self {
1164                responses: Arc::new(Mutex::new(responses)),
1165                delay: None,
1166            }
1167        }
1168
1169        fn with_delay(mut self, delay: Duration) -> Self {
1170            self.delay = Some(delay);
1171            self
1172        }
1173
1174        fn text_response(text: &str) -> ChatOutcome {
1175            ChatOutcome::Success(ChatResponse {
1176                id: "resp_text".to_string(),
1177                content: vec![ContentBlock::Text {
1178                    text: text.to_string(),
1179                }],
1180                model: "test-model".to_string(),
1181                stop_reason: Some(StopReason::EndTurn),
1182                usage: Usage {
1183                    served_speed: None,
1184                    input_tokens: 10,
1185                    output_tokens: 20,
1186                    cached_input_tokens: 0,
1187                    cache_creation_input_tokens: 0,
1188                },
1189            })
1190        }
1191
1192        fn tool_use_response(tool_id: &str, tool_name: &str, input: Value) -> ChatOutcome {
1193            ChatOutcome::Success(ChatResponse {
1194                id: "resp_tool".to_string(),
1195                content: vec![ContentBlock::ToolUse {
1196                    id: tool_id.to_string(),
1197                    name: tool_name.to_string(),
1198                    input,
1199                    thought_signature: None,
1200                }],
1201                model: "test-model".to_string(),
1202                stop_reason: Some(StopReason::ToolUse),
1203                usage: Usage {
1204                    served_speed: None,
1205                    input_tokens: 15,
1206                    output_tokens: 25,
1207                    cached_input_tokens: 0,
1208                    cache_creation_input_tokens: 0,
1209                },
1210            })
1211        }
1212
1213        fn refusal_response(text: Option<&str>) -> ChatOutcome {
1214            let content = text.map_or_else(Vec::new, |text| {
1215                vec![ContentBlock::Text {
1216                    text: text.to_string(),
1217                }]
1218            });
1219            ChatOutcome::Success(ChatResponse {
1220                id: "resp_refusal".to_string(),
1221                content,
1222                model: "test-model".to_string(),
1223                stop_reason: Some(StopReason::Refusal),
1224                usage: Usage {
1225                    served_speed: None,
1226                    input_tokens: 12,
1227                    output_tokens: 0,
1228                    cached_input_tokens: 0,
1229                    cache_creation_input_tokens: 0,
1230                },
1231            })
1232        }
1233    }
1234
1235    #[async_trait]
1236    impl LlmProvider for TestProvider {
1237        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
1238            if let Some(delay) = self.delay {
1239                tokio::time::sleep(delay).await;
1240            }
1241
1242            let mut responses = self.responses.lock().await;
1243            if responses.is_empty() {
1244                Ok(Self::text_response("default"))
1245            } else {
1246                Ok(responses.remove(0))
1247            }
1248        }
1249
1250        fn model(&self) -> &'static str {
1251            "test-model"
1252        }
1253
1254        fn provider(&self) -> &'static str {
1255            "mock"
1256        }
1257    }
1258
1259    struct TestEchoTool;
1260
1261    impl Tool<()> for TestEchoTool {
1262        type Name = DynamicToolName;
1263
1264        fn name(&self) -> DynamicToolName {
1265            DynamicToolName::new("echo")
1266        }
1267
1268        fn display_name(&self) -> &'static str {
1269            "Echo"
1270        }
1271
1272        fn description(&self) -> &'static str {
1273            "Echo the input"
1274        }
1275
1276        fn input_schema(&self) -> Value {
1277            json!({
1278                "type": "object",
1279                "properties": {
1280                    "message": { "type": "string" }
1281                },
1282                "required": ["message"]
1283            })
1284        }
1285
1286        fn tier(&self) -> ToolTier {
1287            ToolTier::Observe
1288        }
1289
1290        async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
1291            let message = input
1292                .get("message")
1293                .and_then(Value::as_str)
1294                .context("missing echo message")?;
1295            Ok(ToolResult::success(format!("Echo: {message}")))
1296        }
1297    }
1298
1299    #[derive(Clone, Default)]
1300    struct RecordingEventStore {
1301        inner: Arc<InMemoryEventStore>,
1302        appended: Arc<Mutex<Vec<(ThreadId, usize, AgentEventEnvelope)>>>,
1303    }
1304
1305    impl RecordingEventStore {
1306        async fn appended_events(&self) -> Vec<(ThreadId, usize, AgentEventEnvelope)> {
1307            self.appended.lock().await.clone()
1308        }
1309    }
1310
1311    #[async_trait]
1312    impl EventStore for RecordingEventStore {
1313        async fn append(
1314            &self,
1315            thread_id: &ThreadId,
1316            turn: usize,
1317            envelope: AgentEventEnvelope,
1318        ) -> Result<()> {
1319            self.appended
1320                .lock()
1321                .await
1322                .push((thread_id.clone(), turn, envelope.clone()));
1323            self.inner.append(thread_id, turn, envelope).await
1324        }
1325
1326        async fn finish_turn(&self, thread_id: &ThreadId, turn: usize) -> Result<()> {
1327            self.inner.finish_turn(thread_id, turn).await
1328        }
1329
1330        async fn get_turn(
1331            &self,
1332            thread_id: &ThreadId,
1333            turn: usize,
1334        ) -> Result<Option<StoredTurnEvents>> {
1335            self.inner.get_turn(thread_id, turn).await
1336        }
1337
1338        async fn get_turns(&self, thread_id: &ThreadId) -> Result<Vec<StoredTurnEvents>> {
1339            self.inner.get_turns(thread_id).await
1340        }
1341
1342        async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
1343            self.inner.clear(thread_id).await
1344        }
1345    }
1346
1347    #[derive(Clone, Default)]
1348    struct AlwaysFailAppendEventStore;
1349
1350    #[async_trait]
1351    impl EventStore for AlwaysFailAppendEventStore {
1352        async fn append(
1353            &self,
1354            _thread_id: &ThreadId,
1355            _turn: usize,
1356            _envelope: AgentEventEnvelope,
1357        ) -> Result<()> {
1358            bail!("append failed")
1359        }
1360
1361        async fn finish_turn(&self, _thread_id: &ThreadId, _turn: usize) -> Result<()> {
1362            Ok(())
1363        }
1364
1365        async fn get_turn(
1366            &self,
1367            _thread_id: &ThreadId,
1368            _turn: usize,
1369        ) -> Result<Option<StoredTurnEvents>> {
1370            Ok(None)
1371        }
1372
1373        async fn get_turns(&self, _thread_id: &ThreadId) -> Result<Vec<StoredTurnEvents>> {
1374            Ok(Vec::new())
1375        }
1376
1377        async fn clear(&self, _thread_id: &ThreadId) -> Result<()> {
1378            Ok(())
1379        }
1380    }
1381
1382    #[derive(Clone, Default)]
1383    struct NoReadAfterFailureEventStore {
1384        inner: Arc<InMemoryEventStore>,
1385    }
1386
1387    #[async_trait]
1388    impl EventStore for NoReadAfterFailureEventStore {
1389        async fn append(
1390            &self,
1391            thread_id: &ThreadId,
1392            turn: usize,
1393            envelope: AgentEventEnvelope,
1394        ) -> Result<()> {
1395            self.inner.append(thread_id, turn, envelope).await
1396        }
1397
1398        async fn finish_turn(&self, thread_id: &ThreadId, turn: usize) -> Result<()> {
1399            self.inner.finish_turn(thread_id, turn).await
1400        }
1401
1402        async fn get_turn(
1403            &self,
1404            thread_id: &ThreadId,
1405            turn: usize,
1406        ) -> Result<Option<StoredTurnEvents>> {
1407            self.inner.get_turn(thread_id, turn).await
1408        }
1409
1410        async fn get_turns(&self, _thread_id: &ThreadId) -> Result<Vec<StoredTurnEvents>> {
1411            bail!("get_events should not be called after subagent failure")
1412        }
1413
1414        async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
1415            self.inner.clear(thread_id).await
1416        }
1417    }
1418
1419    #[derive(Clone, Default)]
1420    struct PanicProvider;
1421
1422    #[async_trait]
1423    impl LlmProvider for PanicProvider {
1424        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
1425            // Panic isolation catches this at the run-loop
1426            // boundary and turns it into a structured `Error`, so the
1427            // parent classifies it as an agent error rather than a
1428            // dropped channel. The literal text is asserted on by
1429            // `test_run_subagent_panic_classified_as_error_not_disconnected`.
1430            panic!("panic provider should disconnect subagent");
1431        }
1432
1433        fn model(&self) -> &'static str {
1434            "panic-model"
1435        }
1436
1437        fn provider(&self) -> &'static str {
1438            "panic"
1439        }
1440    }
1441
1442    #[test]
1443    fn test_subagent_config_builder() {
1444        let config = SubagentConfig::new("test")
1445            .with_system_prompt("Test prompt")
1446            .with_max_turns(5)
1447            .with_timeout_ms(30000);
1448
1449        assert_eq!(config.name, "test");
1450        assert_eq!(config.system_prompt, "Test prompt");
1451        assert_eq!(config.max_turns, Some(5));
1452        assert_eq!(config.timeout_ms, Some(30000));
1453    }
1454
1455    #[test]
1456    fn test_subagent_config_defaults() {
1457        let config = SubagentConfig::new("default");
1458
1459        assert_eq!(config.name, "default");
1460        assert!(config.system_prompt.is_empty());
1461        assert_eq!(config.max_turns, None);
1462        assert_eq!(config.timeout_ms, None);
1463    }
1464
1465    #[test]
1466    fn test_subagent_result_serialization() -> Result<()> {
1467        let result = SubagentResult {
1468            name: "test".to_string(),
1469            final_response: "Done".to_string(),
1470            total_turns: 3,
1471            tool_count: 5,
1472            tool_logs: vec![
1473                ToolCallLog {
1474                    name: "read".to_string(),
1475                    display_name: "Read file".to_string(),
1476                    context: "/tmp/test.rs".to_string(),
1477                    result: "50 lines".to_string(),
1478                    success: true,
1479                    duration_ms: Some(10),
1480                },
1481                ToolCallLog {
1482                    name: "grep".to_string(),
1483                    display_name: "Grep TODO".to_string(),
1484                    context: "TODO".to_string(),
1485                    result: "3 matches".to_string(),
1486                    success: true,
1487                    duration_ms: Some(5),
1488                },
1489            ],
1490            usage: TokenUsage::default(),
1491            success: true,
1492            duration_ms: 1000,
1493            error_details: None,
1494            failed_tool: None,
1495        };
1496
1497        let json = serde_json::to_string(&result).context("failed to serialize subagent result")?;
1498        assert!(json.contains("test"));
1499        assert!(json.contains("Done"));
1500        assert!(json.contains("tool_count"));
1501        assert!(json.contains("tool_logs"));
1502        assert!(json.contains("/tmp/test.rs"));
1503
1504        Ok(())
1505    }
1506
1507    #[test]
1508    fn test_subagent_result_field_extraction() -> Result<()> {
1509        let result = SubagentResult {
1510            name: "explore".to_string(),
1511            final_response: "Found 3 config files".to_string(),
1512            total_turns: 2,
1513            tool_count: 5,
1514            tool_logs: vec![ToolCallLog {
1515                name: "glob".to_string(),
1516                display_name: "Glob config files".to_string(),
1517                context: "**/*.toml".to_string(),
1518                result: "3 files".to_string(),
1519                success: true,
1520                duration_ms: Some(15),
1521            }],
1522            usage: TokenUsage {
1523                input_tokens: 1500,
1524                output_tokens: 500,
1525                ..Default::default()
1526            },
1527            success: true,
1528            duration_ms: 2500,
1529            error_details: None,
1530            failed_tool: None,
1531        };
1532
1533        let value =
1534            serde_json::to_value(&result).context("failed to convert subagent result to json")?;
1535
1536        let tool_count = value.get("tool_count").and_then(Value::as_u64);
1537        assert_eq!(tool_count, Some(5));
1538
1539        let usage = value.get("usage").context("missing usage field")?;
1540        let input_tokens = usage.get("input_tokens").and_then(Value::as_u64);
1541        let output_tokens = usage.get("output_tokens").and_then(Value::as_u64);
1542        assert_eq!(input_tokens, Some(1500));
1543        assert_eq!(output_tokens, Some(500));
1544
1545        let logs = value
1546            .get("tool_logs")
1547            .and_then(Value::as_array)
1548            .context("missing tool_logs array")?;
1549        assert_eq!(logs.len(), 1);
1550
1551        let first_log = &logs[0];
1552        assert_eq!(first_log.get("name").and_then(Value::as_str), Some("glob"));
1553        assert_eq!(
1554            first_log.get("context").and_then(Value::as_str),
1555            Some("**/*.toml")
1556        );
1557        assert_eq!(
1558            first_log.get("result").and_then(Value::as_str),
1559            Some("3 files")
1560        );
1561        assert_eq!(
1562            first_log.get("success").and_then(Value::as_bool),
1563            Some(true)
1564        );
1565
1566        Ok(())
1567    }
1568
1569    #[tokio::test]
1570    async fn test_run_subagent_uses_isolated_child_thread() -> Result<()> {
1571        let event_store = Arc::new(RecordingEventStore::default());
1572        let provider = Arc::new(TestProvider::new(vec![
1573            TestProvider::tool_use_response("tool_1", "echo", json!({ "message": "child" })),
1574            TestProvider::text_response("Subagent complete"),
1575        ]));
1576        let mut tools = ToolRegistry::new();
1577        tools.register(TestEchoTool);
1578
1579        let tool = SubagentTool::new(SubagentConfig::new("worker"), provider, Arc::new(tools), {
1580            let store = Arc::clone(&event_store);
1581            move || -> Arc<dyn EventStore> { store.clone() }
1582        });
1583        let parent_thread = ThreadId::new();
1584        let parent_ctx = ToolContext::new(()).with_event_store(
1585            event_store.clone(),
1586            parent_thread.clone(),
1587            1,
1588            Arc::new(LocalEventAuthority::new()),
1589        );
1590
1591        let result = tool
1592            .run_subagent(
1593                "Inspect the repo",
1594                "subagent_1".to_string(),
1595                &parent_ctx,
1596                CancellationToken::new(),
1597            )
1598            .await?;
1599
1600        assert!(result.success);
1601        assert_eq!(result.tool_count, 1);
1602        assert_eq!(result.tool_logs.len(), 1);
1603
1604        let parent_turn = event_store
1605            .get_turn(&parent_thread, 1)
1606            .await?
1607            .context("missing parent turn")?;
1608        assert!(!parent_turn.events.is_empty());
1609        assert!(
1610            parent_turn
1611                .events
1612                .iter()
1613                .all(|envelope| { matches!(envelope.event, AgentEvent::SubagentProgress { .. }) })
1614        );
1615
1616        let appended = event_store.appended_events().await;
1617        let child_thread = appended
1618            .iter()
1619            .map(|(thread_id, _, _)| thread_id.clone())
1620            .find(|thread_id| thread_id != &parent_thread)
1621            .context("missing child thread events")?;
1622        let child_turn = event_store
1623            .get_turn(&child_thread, 1)
1624            .await?
1625            .context("missing child turn")?;
1626        let child_events = event_store.get_events(&child_thread).await?;
1627
1628        assert!(
1629            child_turn
1630                .events
1631                .iter()
1632                .any(|envelope| { matches!(envelope.event, AgentEvent::ToolCallStart { .. }) })
1633        );
1634        assert!(
1635            child_events
1636                .iter()
1637                .any(|envelope| { matches!(envelope.event, AgentEvent::Done { .. }) })
1638        );
1639
1640        Ok(())
1641    }
1642
1643    #[tokio::test]
1644    async fn test_run_subagent_timeout_marks_result_as_failed() -> Result<()> {
1645        let event_store = Arc::new(NoReadAfterFailureEventStore::default());
1646        let provider = Arc::new(
1647            TestProvider::new(vec![TestProvider::text_response("Too late")])
1648                .with_delay(Duration::from_millis(50)),
1649        );
1650        let tool = SubagentTool::new(
1651            SubagentConfig::new("worker").with_timeout_ms(10),
1652            provider,
1653            Arc::new(ToolRegistry::<()>::new()),
1654            {
1655                let store = Arc::clone(&event_store);
1656                move || -> Arc<dyn EventStore> { store.clone() }
1657            },
1658        );
1659
1660        let result = tool
1661            .run_subagent(
1662                "Take too long",
1663                "subagent_timeout".to_string(),
1664                &ToolContext::new(()),
1665                CancellationToken::new(),
1666            )
1667            .await?;
1668
1669        assert!(!result.success);
1670        assert_eq!(result.final_response, "Subagent timed out");
1671        assert!(
1672            result
1673                .error_details
1674                .context("missing timeout details")?
1675                .contains("timed out")
1676        );
1677
1678        Ok(())
1679    }
1680
1681    #[tokio::test]
1682    async fn test_run_subagent_progress_failures_do_not_abort_successful_runs() -> Result<()> {
1683        let provider = Arc::new(TestProvider::new(vec![
1684            TestProvider::tool_use_response("tool_1", "echo", json!({ "message": "child" })),
1685            TestProvider::text_response("Subagent complete"),
1686        ]));
1687        let mut tools = ToolRegistry::new();
1688        tools.register(TestEchoTool);
1689
1690        let tool = SubagentTool::new(SubagentConfig::new("worker"), provider, Arc::new(tools), {
1691            move || -> Arc<dyn EventStore> { Arc::new(InMemoryEventStore::new()) }
1692        });
1693        let parent_ctx = ToolContext::new(()).with_event_store(
1694            Arc::new(AlwaysFailAppendEventStore),
1695            ThreadId::new(),
1696            1,
1697            Arc::new(LocalEventAuthority::new()),
1698        );
1699
1700        let result = tool
1701            .run_subagent(
1702                "Inspect the repo",
1703                "subagent_progress".to_string(),
1704                &parent_ctx,
1705                CancellationToken::new(),
1706            )
1707            .await?;
1708
1709        assert!(result.success);
1710        assert_eq!(result.final_response, "Subagent complete");
1711        assert_eq!(result.tool_count, 1);
1712
1713        Ok(())
1714    }
1715
1716    #[tokio::test]
1717    async fn test_run_subagent_panic_classified_as_error_not_disconnected() -> Result<()> {
1718        // A subagent whose LLM provider panics must surface as a
1719        // structured `Error` — NOT the `Disconnected` ("ended
1720        // unexpectedly") path. Before panic isolation the
1721        // panic unwound the spawned run task, dropped `state_tx`, and
1722        // the parent saw a `RecvError` it misclassified as
1723        // `Disconnected`. The run-loop catch_unwind boundary now turns
1724        // the panic into `AgentRunState::Error`, which the subagent
1725        // classifies correctly.
1726        let tool = SubagentTool::new(
1727            SubagentConfig::new("worker"),
1728            Arc::new(PanicProvider),
1729            Arc::new(ToolRegistry::<()>::new()),
1730            move || -> Arc<dyn EventStore> { Arc::new(InMemoryEventStore::new()) },
1731        );
1732
1733        let result = tool
1734            .run_subagent(
1735                "Crash",
1736                "subagent_panic".to_string(),
1737                &ToolContext::new(()),
1738                CancellationToken::new(),
1739            )
1740            .await?;
1741
1742        assert!(!result.success);
1743        // The disconnect path must NOT be taken: a caught panic is a
1744        // structured agent error, not an opaque dropped channel.
1745        assert_ne!(result.final_response, "Subagent ended unexpectedly");
1746        let details = result
1747            .error_details
1748            .context("panicking subagent must carry structured error details")?;
1749        assert!(
1750            !details.contains("ended before returning a final state"),
1751            "panic must not be classified as Disconnected; got {details:?}",
1752        );
1753        assert!(
1754            details.contains("panicked"),
1755            "structured error should reflect the panic; got {details:?}",
1756        );
1757        assert!(
1758            details.contains("panic provider should disconnect subagent"),
1759            "structured error should carry the original panic message; got {details:?}",
1760        );
1761
1762        Ok(())
1763    }
1764
1765    #[tokio::test]
1766    async fn test_run_subagent_refusal_marks_result_as_failed() -> Result<()> {
1767        let tool = SubagentTool::new(
1768            SubagentConfig::new("worker"),
1769            Arc::new(TestProvider::new(vec![TestProvider::refusal_response(
1770                Some("Refused for policy reasons"),
1771            )])),
1772            Arc::new(ToolRegistry::<()>::new()),
1773            || Arc::new(InMemoryEventStore::new()),
1774        );
1775
1776        let result = tool
1777            .run_subagent(
1778                "Refuse",
1779                "subagent_refusal".to_string(),
1780                &ToolContext::new(()),
1781                CancellationToken::new(),
1782            )
1783            .await?;
1784
1785        assert!(!result.success);
1786        assert_eq!(result.final_response, "Refused for policy reasons");
1787        assert_eq!(
1788            result.error_details.as_deref(),
1789            Some("Refused for policy reasons")
1790        );
1791
1792        Ok(())
1793    }
1794
1795    #[tokio::test]
1796    async fn test_run_subagent_cancelled_marks_result_as_failed() -> Result<()> {
1797        let tool = SubagentTool::new(
1798            SubagentConfig::new("worker"),
1799            Arc::new(
1800                TestProvider::new(vec![TestProvider::text_response("Too late")])
1801                    .with_delay(Duration::from_millis(50)),
1802            ),
1803            Arc::new(ToolRegistry::<()>::new()),
1804            || Arc::new(InMemoryEventStore::new()),
1805        );
1806        let cancel_token = CancellationToken::new();
1807        cancel_token.cancel();
1808
1809        let result = tool
1810            .run_subagent(
1811                "Cancel",
1812                "subagent_cancelled".to_string(),
1813                &ToolContext::new(()),
1814                cancel_token,
1815            )
1816            .await?;
1817
1818        assert!(!result.success);
1819        assert_eq!(result.final_response, "Subagent cancelled");
1820        assert!(
1821            result
1822                .error_details
1823                .context("missing cancellation details")?
1824                .contains("cancelled")
1825        );
1826
1827        Ok(())
1828    }
1829
1830    #[tokio::test]
1831    async fn test_run_subagent_llm_error_does_not_infer_failed_tool() -> Result<()> {
1832        let provider = Arc::new(TestProvider::new(vec![
1833            ChatOutcome::ServerError("llm transport failed".to_string()),
1834            ChatOutcome::ServerError("llm transport failed".to_string()),
1835            ChatOutcome::ServerError("llm transport failed".to_string()),
1836            ChatOutcome::ServerError("llm transport failed".to_string()),
1837            ChatOutcome::ServerError("llm transport failed".to_string()),
1838            ChatOutcome::ServerError("llm transport failed".to_string()),
1839        ]));
1840        let mut tools = ToolRegistry::new();
1841        tools.register(TestEchoTool);
1842
1843        let tool = SubagentTool::new(
1844            SubagentConfig::new("worker"),
1845            provider,
1846            Arc::new(tools),
1847            || Arc::new(InMemoryEventStore::new()),
1848        );
1849
1850        let result = tool
1851            .run_subagent(
1852                "Trigger an llm failure",
1853                "subagent_llm_error".to_string(),
1854                &ToolContext::new(()),
1855                CancellationToken::new(),
1856            )
1857            .await?;
1858
1859        assert!(!result.success);
1860        assert!(result.failed_tool.is_none());
1861        assert!(
1862            result
1863                .error_details
1864                .as_deref()
1865                .unwrap_or_default()
1866                .contains("Server error")
1867        );
1868
1869        Ok(())
1870    }
1871
1872    #[tokio::test]
1873    async fn test_replay_subagent_events_stops_after_error() -> Result<()> {
1874        let event_store: Arc<dyn EventStore> = Arc::new(InMemoryEventStore::new());
1875        let thread_id = ThreadId::new();
1876        let authority = LocalEventAuthority::new();
1877        event_store
1878            .append(
1879                &thread_id,
1880                1,
1881                authority.wrap(AgentEvent::error("subagent boom", false)),
1882            )
1883            .await?;
1884        event_store
1885            .append(
1886                &thread_id,
1887                1,
1888                authority.wrap(AgentEvent::Text {
1889                    message_id: "msg_after_error".to_string(),
1890                    text: "should not be appended".to_string(),
1891                    emitter_task_id: None,
1892                }),
1893            )
1894            .await?;
1895
1896        let mut state = SubagentExecutionState::new();
1897        replay_subagent_events(
1898            &event_store,
1899            &thread_id,
1900            &ToolContext::new(()),
1901            &SubagentConfig::new("worker"),
1902            "subagent_error",
1903            &mut state,
1904        )
1905        .await?;
1906
1907        assert!(!state.success);
1908        assert_eq!(state.final_response, "subagent boom");
1909        assert_eq!(state.error_details.as_deref(), Some("subagent boom"));
1910
1911        Ok(())
1912    }
1913
1914    #[tokio::test]
1915    async fn test_replay_keeps_only_final_message_text() -> Result<()> {
1916        let event_store: Arc<dyn EventStore> = Arc::new(InMemoryEventStore::new());
1917        let thread_id = ThreadId::new();
1918        let authority = LocalEventAuthority::new();
1919
1920        // Interim commentary in an early assistant message.
1921        event_store
1922            .append(
1923                &thread_id,
1924                1,
1925                authority.wrap(AgentEvent::Text {
1926                    message_id: "m1".to_string(),
1927                    text: "Let me check the repo...".to_string(),
1928                    emitter_task_id: None,
1929                }),
1930            )
1931            .await?;
1932        // The final answer in a later message, split across two text blocks.
1933        event_store
1934            .append(
1935                &thread_id,
1936                2,
1937                authority.wrap(AgentEvent::Text {
1938                    message_id: "m2".to_string(),
1939                    text: "Final answer ".to_string(),
1940                    emitter_task_id: None,
1941                }),
1942            )
1943            .await?;
1944        event_store
1945            .append(
1946                &thread_id,
1947                2,
1948                authority.wrap(AgentEvent::Text {
1949                    message_id: "m2".to_string(),
1950                    text: "part two".to_string(),
1951                    emitter_task_id: None,
1952                }),
1953            )
1954            .await?;
1955        event_store
1956            .append(
1957                &thread_id,
1958                2,
1959                authority.wrap(AgentEvent::done(
1960                    thread_id.clone(),
1961                    2,
1962                    TokenUsage::default(),
1963                    Duration::from_millis(1),
1964                )),
1965            )
1966            .await?;
1967
1968        let mut state = SubagentExecutionState::new();
1969        replay_subagent_events(
1970            &event_store,
1971            &thread_id,
1972            &ToolContext::new(()),
1973            &SubagentConfig::new("worker"),
1974            "subagent_final",
1975            &mut state,
1976        )
1977        .await?;
1978
1979        // Interim commentary must not be glued onto the final answer; only the
1980        // last assistant message's text is returned.
1981        assert_eq!(state.final_response, "Final answer part two");
1982
1983        Ok(())
1984    }
1985
1986    #[test]
1987    fn build_subagent_child_context_increments_depth_and_propagates_semaphore() {
1988        let semaphore = Arc::new(tokio::sync::Semaphore::new(2));
1989        let parent = ToolContext::new(())
1990            .with_metadata(METADATA_SUBAGENT_DEPTH, json!(2))
1991            .with_metadata(METADATA_MAX_SUBAGENT_DEPTH, json!(5))
1992            .with_subagent_semaphore(Arc::clone(&semaphore));
1993
1994        let child = build_subagent_child_context(&parent);
1995
1996        assert_eq!(
1997            child
1998                .metadata
1999                .get(METADATA_SUBAGENT_DEPTH)
2000                .and_then(Value::as_u64),
2001            Some(3)
2002        );
2003        // Host-set max-depth carries through unchanged.
2004        assert_eq!(
2005            child
2006                .metadata
2007                .get(METADATA_MAX_SUBAGENT_DEPTH)
2008                .and_then(Value::as_u64),
2009            Some(5)
2010        );
2011        // The shared concurrency semaphore propagates into the child run.
2012        assert!(child.subagent_semaphore().is_some());
2013    }
2014
2015    #[test]
2016    fn cached_tool_strings_are_interned_per_name() {
2017        let a = cached_tool_strings("worker");
2018        let b = cached_tool_strings("worker");
2019        // The same name reuses a single leaked allocation rather than leaking
2020        // afresh on every construction.
2021        assert!(std::ptr::eq(a.0, b.0));
2022        assert!(std::ptr::eq(a.1, b.1));
2023        assert_eq!(a.0, "Subagent: worker");
2024
2025        let c = cached_tool_strings("a-different-name");
2026        assert!(!std::ptr::eq(a.0, c.0));
2027    }
2028
2029    #[tokio::test]
2030    async fn test_nested_subagent_depth_limit_propagates() -> Result<()> {
2031        use crate::hooks::AllowAllHooks;
2032
2033        // The inner subagent must never run: the depth guard fires first.
2034        let inner = SubagentTool::new(
2035            SubagentConfig::new("inner"),
2036            Arc::new(TestProvider::new(vec![TestProvider::text_response(
2037                "inner ran (should not happen)",
2038            )])),
2039            Arc::new(ToolRegistry::<()>::new()),
2040            || -> Arc<dyn EventStore> { Arc::new(InMemoryEventStore::new()) },
2041        );
2042        let mut middle_tools = ToolRegistry::new();
2043        middle_tools.register(inner);
2044
2045        // The outer subagent tries to spawn the nested subagent, then finishes.
2046        // `AllowAllHooks` auto-approves the Confirm-tier nested spawn so it
2047        // actually reaches the depth guard.
2048        let outer = SubagentTool::new(
2049            SubagentConfig::new("outer"),
2050            Arc::new(TestProvider::new(vec![
2051                TestProvider::tool_use_response(
2052                    "call_inner",
2053                    "subagent_inner",
2054                    json!({ "task": "go deeper" }),
2055                ),
2056                TestProvider::text_response("outer done"),
2057            ])),
2058            Arc::new(middle_tools),
2059            || -> Arc<dyn EventStore> { Arc::new(InMemoryEventStore::new()) },
2060        )
2061        .with_hooks(Arc::new(AllowAllHooks));
2062
2063        // Cap nesting at depth 1: the outer runs at depth 1, so its nested
2064        // spawn (which would be depth 1 >= max 1) must be rejected.
2065        let parent_ctx = ToolContext::new(()).with_metadata(METADATA_MAX_SUBAGENT_DEPTH, json!(1));
2066
2067        let result = outer
2068            .execute(&parent_ctx, json!({ "task": "start" }))
2069            .await?;
2070
2071        assert!(result.success, "outer subagent should still complete");
2072        assert_eq!(result.output, "outer done");
2073
2074        let subresult: SubagentResult =
2075            serde_json::from_value(result.data.context("missing subagent result data")?)
2076                .context("subagent result should deserialize")?;
2077        let nested = subresult
2078            .tool_logs
2079            .iter()
2080            .find(|log| log.name == "subagent_inner")
2081            .context("missing nested subagent tool log")?;
2082        assert!(!nested.success, "nested spawn must be rejected");
2083        assert!(
2084            nested.result.contains("depth limit"),
2085            "nested failure should be the depth-limit error; got: {}",
2086            nested.result
2087        );
2088
2089        Ok(())
2090    }
2091}