Skip to main content

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;