tinyagents/harness/subagent/mod.rs
1//! First-class sub-agents with recursion-depth tracking.
2//!
3//! This is the harness's flagship recursion surface: it lets one agent run
4//! another agent as a child of itself, so a language model orchestrating tools
5//! is, transparently, a language model orchestrating *other models*. It is the
6//! concrete "agents calling agents" mechanism behind the crate's
7//! recursive-language-model framing — the in-harness analogue of the graph-side
8//! [`crate::graph::subgraph`] recursion.
9//!
10//! This module provides the agent-calling-agent compositional primitive:
11//!
12//! - [`SubAgent`] wraps an [`AgentHarness`] and runs it as a *child run* one
13//! level deeper in the recursion tree than its caller.
14//! - [`SubAgentTool`] adapts a [`SubAgent`] into a [`Tool`] so a parent agent
15//! can invoke another agent exactly like any other tool.
16//! - [`SubAgentSession`] keeps a single [`SubAgent`] alive across multiple
17//! turns, *reusing* the same harness while accumulating the conversation
18//! transcript — the post-completion, human-in-the-loop reuse primitive.
19//!
20//! # Reuse vs. steering
21//!
22//! There are two ways an orchestrator keeps a sub-agent "in play" across human
23//! input:
24//!
25//! - **Reuse** ([`SubAgentSession`]): the child run *completes*, the
26//! orchestrator obtains human input, then calls the **same** sub-agent again
27//! carrying the prior transcript. Nothing is killed or restarted.
28//! - **Steering** ([`crate::harness::steering`]): an orchestrator/human injects
29//! commands into a **still-running** agent at safe checkpoints.
30//!
31//! `SubAgentSession` implements the first. The flow is:
32//!
33//! 1. `session.send(state, ctx, vec![Message::user("…")])` — runs the sub-agent
34//! over the retained transcript and folds its reply back in.
35//! 2. Inspect the returned [`AgentRun`]; obtain human input out-of-band.
36//! 3. `session.send(state, ctx, vec![Message::user(human_reply)])` — the same
37//! sub-agent answers with the full prior context still in the transcript.
38//!
39//! Every send after the first emits [`AgentEvent::SubAgentReused`] so the reuse
40//! is visible alongside the per-send
41//! [`SubAgentStarted`][AgentEvent::SubAgentStarted]/[`SubAgentCompleted`][AgentEvent::SubAgentCompleted]
42//! bracket.
43//!
44//! # Depth tracking
45//!
46//! Every run carries a `depth` in its [`RunConfig`] (top-level runs are depth
47//! `0`). When a sub-agent is invoked at `parent_depth`, its child run is created
48//! at `parent_depth + 1`. The depth cap is
49//! [`RunLimits::max_depth`][crate::harness::limits::RunLimits::max_depth]
50//! (default [`RunLimits::DEFAULT_MAX_DEPTH`][crate::harness::limits::RunLimits::DEFAULT_MAX_DEPTH],
51//! i.e. `8`), read from the child harness's [`RunPolicy`][crate::harness::runtime::RunPolicy].
52//! If the child depth would exceed the cap, the invocation fails fast with
53//! [`TinyAgentsError::SubAgentDepth`] *before* any model call — a deterministic,
54//! cheap guard against unbounded recursion.
55//!
56//! # Observability
57//!
58//! Each invocation emits [`AgentEvent::SubAgentStarted`] and
59//! [`AgentEvent::SubAgentCompleted`] (carrying the sub-agent name and child
60//! depth). When invoked with a shared [`EventSink`] — via
61//! [`SubAgent::invoke_with_events`] or [`SubAgent::invoke_in_parent`] — the child
62//! run's own events also flow onto the parent sink, so a parent observer sees
63//! the full nested run tree.
64//!
65//! # Layout
66//!
67//! - [`types`] holds the public type definitions.
68//! - This file holds the impls (constructors, the invoke methods, and the
69//! [`Tool`] adapter).
70//! - `test.rs` holds focused tests.
71
72mod types;
73
74pub use types::*;
75
76use std::sync::Arc;
77
78use async_trait::async_trait;
79use serde_json::{Value, json};
80
81use crate::error::{Result, TinyAgentsError};
82use crate::harness::context::{RunConfig, RunContext};
83use crate::harness::events::{AgentEvent, EventSink};
84use crate::harness::ids::{ThreadId, next_seq};
85use crate::harness::message::Message;
86use crate::harness::middleware::AgentRun;
87use crate::harness::runtime::AgentHarness;
88use crate::harness::tool::{Tool, ToolCall, ToolExecutionContext, ToolResult, ToolSchema};
89
90impl<State: Send + Sync, Ctx: Send + Sync> SubAgent<State, Ctx> {
91 /// Creates a sub-agent wrapping `harness` with a stable `name` and
92 /// `description`.
93 pub fn new(
94 name: impl Into<String>,
95 description: impl Into<String>,
96 harness: Arc<AgentHarness<State, Ctx>>,
97 ) -> Self {
98 Self {
99 harness,
100 name: name.into(),
101 description: description.into(),
102 system_prompt: None,
103 }
104 }
105
106 /// Sets a fixed system prompt prepended to every child run as a leading
107 /// system message. Returns `self` for chaining.
108 pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
109 self.system_prompt = Some(prompt.into());
110 self
111 }
112
113 /// Returns the sub-agent's name.
114 pub fn name(&self) -> &str {
115 &self.name
116 }
117
118 /// Returns the sub-agent's description.
119 pub fn description(&self) -> &str {
120 &self.description
121 }
122
123 /// Returns the wrapped harness.
124 pub fn harness(&self) -> &Arc<AgentHarness<State, Ctx>> {
125 &self.harness
126 }
127
128 /// Builds the child run's seed messages from the optional system prompt and
129 /// the caller `input`.
130 fn seed_messages(&self, input: String) -> Vec<Message> {
131 let mut messages = Vec::with_capacity(2);
132 if let Some(prompt) = &self.system_prompt {
133 messages.push(Message::system(prompt.clone()));
134 }
135 messages.push(Message::user(input));
136 messages
137 }
138
139 /// Builds the child [`RunConfig`] for an invocation at `parent_depth`,
140 /// enforcing the depth cap and deriving an isolated child thread from a
141 /// parent thread when one is available.
142 ///
143 /// Returns [`TinyAgentsError::SubAgentDepth`] when the child depth
144 /// (`parent_depth + 1`) would exceed the harness policy's `max_depth`.
145 fn child_config(
146 &self,
147 parent_depth: usize,
148 thread_id: Option<&ThreadId>,
149 max_turn_output_tokens: Option<u32>,
150 ) -> Result<RunConfig> {
151 let max_depth = self.harness.policy().limits.max_depth;
152 let child_depth = RunConfig::checked_child_depth(parent_depth, max_depth)?;
153 // Suffix a process-unique sequence so each invocation gets its own run
154 // id: a bare `{name}-d{depth}` was reused across invocations, which
155 // interleaved journals and status stores keyed by run id. The prefix
156 // stays stable and readable for log grepping.
157 let child_run_id = format!("{}-d{child_depth}-{}", self.name, next_seq());
158 let mut config = RunConfig::new(child_run_id.clone())
159 .with_depth(child_depth)
160 .with_max_depth(max_depth);
161 config.thread_id = thread_id.map(|parent| child_thread_id(parent, &child_run_id));
162 config.max_turn_output_tokens = max_turn_output_tokens;
163 Ok(config)
164 }
165
166 /// Runs the sub-agent as a child run at `parent_depth`, returning the
167 /// child's [`AgentRun`].
168 ///
169 /// The child run is created at `parent_depth + 1`. `ctx_data` seeds the
170 /// child [`RunContext`]. Sub-agent lifecycle events are emitted on the
171 /// child's own (fresh) event sink; use [`Self::invoke_with_events`] to fan
172 /// them out to a shared parent sink.
173 ///
174 /// # Errors
175 ///
176 /// Returns [`TinyAgentsError::SubAgentDepth`] if the child depth would
177 /// exceed the configured `max_depth`, or any error surfaced by the child
178 /// agent loop.
179 pub async fn invoke(
180 &self,
181 state: &State,
182 ctx_data: Ctx,
183 parent_depth: usize,
184 input: impl Into<String>,
185 ) -> Result<AgentRun> {
186 let config = self.child_config(parent_depth, None, None)?;
187 let ctx = RunContext::new(config, ctx_data);
188 self.run_child(state, ctx, input.into(), false).await
189 }
190
191 /// Like [`Self::invoke`] but routes the child run's events (and the
192 /// sub-agent lifecycle events) onto the shared `events` sink so a parent
193 /// observer sees the nested run.
194 pub async fn invoke_with_events(
195 &self,
196 state: &State,
197 ctx_data: Ctx,
198 parent_depth: usize,
199 input: impl Into<String>,
200 events: &EventSink,
201 ) -> Result<AgentRun> {
202 let config = self.child_config(parent_depth, None, None)?;
203 let ctx = RunContext::new(config, ctx_data).with_events(events.clone());
204 self.run_child(state, ctx, input.into(), false).await
205 }
206
207 /// Runs the sub-agent as a child of the live `parent` context.
208 ///
209 /// This is the fully context-threaded entry point: the child depth is
210 /// derived from `parent.depth()` and the child inherits the parent's event
211 /// sink so all nested events share one stream. `ctx_data` seeds the child
212 /// context's user data.
213 ///
214 /// # Errors
215 ///
216 /// Identical to [`Self::invoke`].
217 pub async fn invoke_in_parent(
218 &self,
219 state: &State,
220 ctx_data: Ctx,
221 parent: &RunContext<Ctx>,
222 input: impl Into<String>,
223 ) -> Result<AgentRun> {
224 let config = self.child_config(
225 parent.depth(),
226 parent.thread_id(),
227 parent.config.max_turn_output_tokens,
228 )?;
229 let ctx = RunContext::new(config, ctx_data).with_events(parent.events.clone());
230 self.run_child(state, ctx, input.into(), parent.streaming)
231 .await
232 }
233
234 /// Shared driver: emits the sub-agent lifecycle events around the child
235 /// agent loop.
236 ///
237 /// When `streaming` is `true` the child runs through the streaming loop
238 /// path, so its per-token model/reasoning deltas are emitted onto the
239 /// shared [`EventSink`] and reach the parent's stream (stamped with the
240 /// child's own `run_id` and `depth`). When `false` the child runs the
241 /// unary path, leaving the parent's event stream unchanged.
242 async fn run_child(
243 &self,
244 state: &State,
245 ctx: RunContext<Ctx>,
246 input: String,
247 streaming: bool,
248 ) -> Result<AgentRun> {
249 let depth = ctx.depth();
250 let messages = self.seed_messages(input);
251 // Clone the sink (it shares listeners and the offset counter with the
252 // context) so the completion event can be emitted after `ctx` is moved
253 // into the child agent loop.
254 let events = ctx.events.clone();
255
256 events.emit(AgentEvent::SubAgentStarted {
257 name: self.name.clone(),
258 depth,
259 });
260
261 let run = if streaming {
262 self.harness
263 .invoke_streaming_in_context(state, ctx, messages)
264 .await?
265 } else {
266 self.harness.invoke_in_context(state, ctx, messages).await?
267 };
268
269 events.emit(AgentEvent::SubAgentCompleted {
270 name: self.name.clone(),
271 depth,
272 });
273
274 Ok(run)
275 }
276}
277
278/// Derives an isolated child thread id from the parent thread and the child's
279/// run id. The run id already carries a process-unique sequence, so the thread
280/// id inherits its uniqueness.
281fn child_thread_id(parent: &ThreadId, child_run_id: &str) -> ThreadId {
282 ThreadId::new(format!("{}-subagent-{child_run_id}", parent.as_str()))
283}
284
285impl<State: Send + Sync, Ctx: Send + Sync> SubAgentSession<State, Ctx> {
286 /// Creates a session that reuses `subagent` across turns.
287 ///
288 /// The child runs at depth `1` by default (caller `parent_depth = 0`); use
289 /// [`Self::with_parent_depth`] to express deeper nesting.
290 pub fn new(subagent: Arc<SubAgent<State, Ctx>>) -> Self {
291 Self {
292 subagent,
293 transcript: Vec::new(),
294 turn: 0,
295 parent_depth: 0,
296 events: EventSink::new(),
297 seeded: false,
298 }
299 }
300
301 /// Creates a session from an owned [`SubAgent`], wrapping it in an `Arc`.
302 pub fn from_subagent(subagent: SubAgent<State, Ctx>) -> Self {
303 Self::new(Arc::new(subagent))
304 }
305
306 /// Routes the reuse lifecycle and the child run's own events onto `events`
307 /// so an external observer (or testkit recorder) sees every send. Returns
308 /// `self` for chaining.
309 pub fn with_events(mut self, events: EventSink) -> Self {
310 self.events = events;
311 self
312 }
313
314 /// Sets the caller depth the child runs at; the child run is created at
315 /// `parent_depth + 1`. Returns `self` for chaining.
316 pub fn with_parent_depth(mut self, parent_depth: usize) -> Self {
317 self.parent_depth = parent_depth;
318 self
319 }
320
321 /// Returns the reused sub-agent. The same `Arc` is shared across every
322 /// send, so this is how callers confirm the harness was never rebuilt.
323 pub fn subagent(&self) -> &Arc<SubAgent<State, Ctx>> {
324 &self.subagent
325 }
326
327 /// Returns the accumulated conversation transcript carried across sends.
328 pub fn transcript(&self) -> &[Message] {
329 &self.transcript
330 }
331
332 /// Returns the number of completed sends (turns).
333 pub fn turns(&self) -> usize {
334 self.turn
335 }
336
337 /// Clears the retained transcript and turn counter, so the next [`Self::send`]
338 /// starts a fresh conversation (re-seeding the fixed system prompt). The
339 /// underlying [`SubAgent`]/harness is left untouched and still reused.
340 pub fn reset(&mut self) {
341 self.transcript.clear();
342 self.turn = 0;
343 self.seeded = false;
344 }
345
346 /// Builds the child [`RunConfig`] for the current turn, enforcing the depth
347 /// cap exactly as [`SubAgent::invoke`] does.
348 fn child_config(&self) -> Result<RunConfig> {
349 let max_depth = self.subagent.harness.policy().limits.max_depth;
350 let child_depth = RunConfig::checked_child_depth(self.parent_depth, max_depth)?;
351 // As in `SubAgent::child_config`, suffix a process-unique sequence so
352 // two sessions reusing the same sub-agent never share run ids for the
353 // same turn number.
354 Ok(RunConfig::new(format!(
355 "{}-t{}-d{child_depth}-{}",
356 self.subagent.name,
357 self.turn,
358 next_seq()
359 ))
360 .with_depth(child_depth)
361 .with_max_depth(max_depth))
362 }
363
364 /// Runs the reused sub-agent for one turn over the FULL accumulated
365 /// transcript, then folds the produced assistant/tool messages back into
366 /// the transcript so the next send continues with full context.
367 ///
368 /// `input` (typically a single [`Message::user`] carrying human input) is
369 /// appended to the retained transcript before the run. On the first send
370 /// the sub-agent's fixed [`SubAgent::with_system_prompt`] is prepended once.
371 /// The same underlying harness is reused on every call — nothing is
372 /// reconstructed.
373 ///
374 /// # Errors
375 ///
376 /// Returns [`TinyAgentsError::SubAgentDepth`] if the child depth would
377 /// exceed the configured `max_depth`, or any error surfaced by the child
378 /// agent loop.
379 pub async fn send(
380 &mut self,
381 state: &State,
382 ctx_data: Ctx,
383 input: Vec<Message>,
384 ) -> Result<AgentRun> {
385 // Seed the fixed system prompt once, on the first send.
386 if !self.seeded {
387 if let Some(prompt) = &self.subagent.system_prompt {
388 self.transcript.push(Message::system(prompt.clone()));
389 }
390 self.seeded = true;
391 }
392
393 // Append the new (e.g. human/user) input to the retained transcript.
394 self.transcript.extend(input);
395
396 let config = self.child_config()?;
397 let depth = config.depth;
398 let ctx = RunContext::new(config, ctx_data).with_events(self.events.clone());
399
400 // Clone the sink so we can emit the completion event after `ctx` is
401 // moved into the child agent loop.
402 let events = self.events.clone();
403 events.emit(AgentEvent::SubAgentStarted {
404 name: self.subagent.name.clone(),
405 depth,
406 });
407 if self.turn > 0 {
408 events.emit(AgentEvent::SubAgentReused {
409 name: self.subagent.name.clone(),
410 turn: self.turn,
411 });
412 }
413
414 // REUSE the same underlying harness/SubAgent (no reconstruction),
415 // running it over the full accumulated transcript.
416 let run = self
417 .subagent
418 .harness
419 .invoke_in_context(state, ctx, self.transcript.clone())
420 .await?;
421
422 events.emit(AgentEvent::SubAgentCompleted {
423 name: self.subagent.name.clone(),
424 depth,
425 });
426
427 // Carry the produced assistant/tool messages forward. `run.messages` is
428 // the working transcript the loop ended with (everything we passed plus
429 // the new assistant/tool messages), so the next send continues with the
430 // full context.
431 self.transcript = run.messages.clone();
432 self.turn += 1;
433
434 Ok(run)
435 }
436}
437
438impl<State: Send + Sync, Ctx: Send + Sync> SubAgentTool<State, Ctx> {
439 /// Default JSON Schema for a sub-agent tool: an object with one required
440 /// string field named [`SUBAGENT_INPUT_FIELD`].
441 fn default_parameters() -> Value {
442 json!({
443 "type": "object",
444 "properties": {
445 SUBAGENT_INPUT_FIELD: {
446 "type": "string",
447 "description": "The task or question to delegate to the sub-agent."
448 }
449 },
450 "required": [SUBAGENT_INPUT_FIELD]
451 })
452 }
453
454 /// Wraps `subagent` as a tool invoked at `parent_depth = 0` (child runs at
455 /// depth `1`). The tool name defaults to the sub-agent name.
456 pub fn new(subagent: Arc<SubAgent<State, Ctx>>) -> Self {
457 let tool_name = subagent.name().to_owned();
458 Self {
459 subagent,
460 tool_name,
461 parent_depth: 0,
462 parameters: Self::default_parameters(),
463 }
464 }
465
466 /// Overrides the model-visible tool name.
467 pub fn with_tool_name(mut self, name: impl Into<String>) -> Self {
468 self.tool_name = name.into();
469 self
470 }
471
472 /// Sets the caller depth this tool invokes the child at; the child runs at
473 /// `parent_depth + 1`. Use this to express deeper nesting through the tool
474 /// path (where the live parent depth is not available).
475 pub fn with_parent_depth(mut self, parent_depth: usize) -> Self {
476 self.parent_depth = parent_depth;
477 self
478 }
479
480 /// Overrides the model-visible JSON Schema for the tool arguments.
481 pub fn with_parameters(mut self, parameters: Value) -> Self {
482 self.parameters = parameters;
483 self
484 }
485
486 /// Extracts the child input string from model-supplied `arguments`.
487 ///
488 /// Accepts either an object carrying a string [`SUBAGENT_INPUT_FIELD`] field
489 /// or a bare JSON string; anything else yields the empty string.
490 fn extract_input(arguments: &Value) -> String {
491 match arguments {
492 Value::String(s) => s.clone(),
493 Value::Object(map) => map
494 .get(SUBAGENT_INPUT_FIELD)
495 .and_then(Value::as_str)
496 .unwrap_or_default()
497 .to_owned(),
498 _ => String::new(),
499 }
500 }
501
502 fn limit_result_for_parent(
503 call_id: String,
504 tool_name: &str,
505 error: &TinyAgentsError,
506 ) -> Option<ToolResult> {
507 let limit_kind = match error {
508 TinyAgentsError::LimitExceeded(_) => "configured run limit",
509 TinyAgentsError::Timeout(_) => "wall-clock deadline",
510 TinyAgentsError::SubAgentDepth(_) => "recursion depth limit",
511 _ => return None,
512 };
513
514 Some(ToolResult::error(
515 call_id,
516 tool_name,
517 format!(
518 "Sub-agent `{tool_name}` stopped before completing because it hit its {limit_kind}: {error}. The parent orchestrator should treat this as a delegated-agent limit signal, not a completed answer."
519 ),
520 ))
521 }
522}
523
524#[async_trait]
525impl<State, Ctx> Tool<State> for SubAgentTool<State, Ctx>
526where
527 State: Send + Sync,
528 Ctx: Send + Sync + Default,
529{
530 fn name(&self) -> &str {
531 &self.tool_name
532 }
533
534 fn description(&self) -> &str {
535 self.subagent.description()
536 }
537
538 fn schema(&self) -> ToolSchema {
539 ToolSchema::new(
540 self.tool_name.clone(),
541 self.subagent.description().to_owned(),
542 self.parameters.clone(),
543 )
544 }
545
546 async fn call(&self, state: &State, call: ToolCall) -> Result<ToolResult> {
547 let input = Self::extract_input(&call.arguments);
548 let call_id = call.id;
549 let run = match self
550 .subagent
551 .invoke(state, Ctx::default(), self.parent_depth, input)
552 .await
553 {
554 Ok(run) => run,
555 Err(error) => {
556 if let Some(result) =
557 Self::limit_result_for_parent(call_id, &self.tool_name, &error)
558 {
559 return Ok(result);
560 }
561 return Err(error);
562 }
563 };
564 let text = run.text().unwrap_or_default();
565 Ok(ToolResult::text(call_id, &self.tool_name, text))
566 }
567
568 async fn call_with_context(
569 &self,
570 state: &State,
571 call: ToolCall,
572 context: ToolExecutionContext,
573 ) -> Result<ToolResult> {
574 let input = Self::extract_input(&call.arguments);
575 let call_id = call.id;
576 // Route a depth-limit rejection through the same limit-to-tool-error
577 // conversion as a failure from `run_child` below, rather than letting
578 // it propagate raw and abort the whole parent run: a sub-agent that
579 // is simply too deep is a delegated-agent limit signal the parent
580 // orchestrator should see as a tool result, not a fatal error.
581 let config = match self.subagent.child_config(
582 context.depth,
583 context.thread_id.as_ref(),
584 context.max_turn_output_tokens,
585 ) {
586 Ok(config) => config,
587 Err(error) => {
588 if let Some(result) =
589 Self::limit_result_for_parent(call_id, &self.tool_name, &error)
590 {
591 return Ok(result);
592 }
593 return Err(error);
594 }
595 };
596 let ctx = RunContext::new(config, Ctx::default()).with_events(context.events);
597 // Match the parent's drive mode: when the parent run streams, the child
598 // streams too, so its deltas flow onto the shared sink and reach the
599 // parent's `invoke_stream` consumer with the child's own lineage.
600 let run = match self
601 .subagent
602 .run_child(state, ctx, input, context.streaming)
603 .await
604 {
605 Ok(run) => run,
606 Err(error) => {
607 if let Some(result) =
608 Self::limit_result_for_parent(call_id, &self.tool_name, &error)
609 {
610 return Ok(result);
611 }
612 return Err(error);
613 }
614 };
615 let text = run.text().unwrap_or_default();
616 Ok(ToolResult::text(call_id, &self.tool_name, text))
617 }
618}
619
620#[cfg(test)]
621mod test;