1mod 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
55fn 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
81pub const METADATA_SUBAGENT_DEPTH: &str = "subagent_depth";
86
87pub const METADATA_MAX_SUBAGENT_DEPTH: &str = "max_subagent_depth";
91
92#[derive(Clone, Debug, Serialize, Deserialize)]
94pub struct SubagentConfig {
95 pub name: String,
97 pub nickname: Option<String>,
99 pub system_prompt: String,
101 pub max_turns: Option<usize>,
103 pub timeout_ms: Option<u64>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub model: Option<String>,
108}
109
110impl SubagentConfig {
111 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
162pub struct ToolCallLog {
163 pub name: String,
165 pub display_name: String,
167 pub context: String,
169 pub result: String,
171 pub success: bool,
173 pub duration_ms: Option<u64>,
175}
176
177#[derive(Clone, Debug, Serialize, Deserialize)]
179pub struct SubagentResult {
180 pub name: String,
182 pub final_response: String,
184 pub total_turns: usize,
186 pub tool_count: u32,
188 pub tool_logs: Vec<ToolCallLog>,
190 pub usage: TokenUsage,
192 pub success: bool,
194 pub duration_ms: u64,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub error_details: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub failed_tool: Option<String>,
210}
211
212pub 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: &'static str,
245 cached_description: &'static str,
247}
248
249impl<P> SubagentTool<P, DefaultHooks, InMemoryStore, InMemoryStore>
250where
251 P: LlmProvider + 'static,
252{
253 #[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 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 #[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 #[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 #[must_use]
335 pub const fn config(&self) -> &SubagentConfig {
336 &self.config
337 }
338
339 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 let thread_id = ThreadId::new();
359
360 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 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 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 let tool_ctx = build_subagent_child_context(parent_ctx);
386
387 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
427fn 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 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 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 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 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 timeout_cancel.cancel();
623 task_handle.abort();
624 state.fail(response, details);
625 false
626}
627
628#[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 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 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 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
933fn 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 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
971fn 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 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 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); 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 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 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 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!("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 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 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 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 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 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 assert_eq!(
2005 child
2006 .metadata
2007 .get(METADATA_MAX_SUBAGENT_DEPTH)
2008 .and_then(Value::as_u64),
2009 Some(5)
2010 );
2011 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 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 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 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 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}