Skip to main content

mecha_core/
subagent.rs

1//! Subagents.
2//!
3//! A subagent is an [`Agent`] wrapped in a [`Tool`]. That is the whole design:
4//! the parent loop never learns that delegation exists, it just calls a tool
5//! that happens to take a while and return prose.
6//!
7//! What makes them worth having is **capability restriction**. The child gets a
8//! rebuilt tool registry — an allowlist, not an inheritance — so you can hand
9//! it exactly one dangerous capability and nothing to pair it with. A child
10//! that can fetch web pages but cannot send anything is unable to exfiltrate no
11//! matter what the page tells it.
12//!
13//! ## What subagents do not do
14//!
15//! They do not launder untrusted content into trusted content. If a child reads
16//! a web page and hands its parent a summary, that summary is still derived
17//! from attacker-influenced text and can still carry instructions. So by default
18//! a child whose tools can reach untrusted sources produces **untrusted
19//! output**, and the parent's trifecta interlock still applies.
20//!
21//! What you actually gain is threefold: the raw content never enters the
22//! parent's context, the child cannot send, and the two halves of the trifecta
23//! can be kept in separate agents entirely.
24
25use crate::agent::{Agent, Conversation, RunContext};
26use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
27use anyhow::Result;
28use async_trait::async_trait;
29use serde::{Deserialize, Serialize};
30use serde_json::{json, Value};
31use std::sync::Arc;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(default, deny_unknown_fields)]
35pub struct SubagentProfile {
36    /// Tool name the parent sees. Keep it a verb the model will reach for.
37    pub name: String,
38    /// Shown to the parent model. This is what decides whether delegation
39    /// happens at all, so say when to use it, not just what it is.
40    pub description: String,
41    /// Allowlist of tools the child may use. Empty means no tools, which is
42    /// occasionally what you want — a pure summarizer.
43    pub tools: Vec<String>,
44    pub system_prompt: Option<String>,
45    pub max_turns: u32,
46    /// Run this child on a different model. A narrow task with two tools does
47    /// not need the model the parent is using, and a small fast one keeps
48    /// delegation cheap enough to be worth doing.
49    pub model: Option<String>,
50    /// Run this child against a different provider entry — a second
51    /// llama-server on another port, or a hosted model for one hard step.
52    pub provider: Option<String>,
53    /// Force the child's answer to be treated as trustworthy even though its
54    /// tools can reach untrusted sources.
55    ///
56    /// Off by default, and turning it on is a real risk decision: it lets
57    /// attacker-influenced text through to the parent with the trifecta
58    /// interlock disarmed. Reasonable when the child returns something
59    /// structurally harmless — a number, a yes/no — and not otherwise.
60    pub trusted_output: bool,
61}
62
63impl Default for SubagentProfile {
64    fn default() -> Self {
65        SubagentProfile {
66            name: "subagent".into(),
67            description: "Delegate a self-contained task.".into(),
68            tools: Vec::new(),
69            system_prompt: None,
70            max_turns: 12,
71            model: None,
72            provider: None,
73            trusted_output: false,
74        }
75    }
76}
77
78/// A configured subagent, exposed to the parent as one tool.
79pub struct Subagent {
80    profile: SubagentProfile,
81    agent: Arc<Agent>,
82    /// Derived from the child's tools at construction, so the parent's taint
83    /// tracking stays correct without anyone having to remember to declare it.
84    capabilities: Capabilities,
85}
86
87impl Subagent {
88    pub fn new(profile: SubagentProfile, agent: Arc<Agent>) -> Self {
89        // A child's answer is only as trustworthy as the least trustworthy
90        // thing it can read. Private data is *not* propagated: the point of a
91        // subagent is that what it saw stays with it, and only its answer —
92        // which the parent is about to read anyway — comes back.
93        let child_reads_untrusted = agent
94            .registry()
95            .iter()
96            .any(|t| t.capabilities().untrusted_input);
97
98        let capabilities = Capabilities {
99            untrusted_input: child_reads_untrusted && !profile.trusted_output,
100            ..Capabilities::default()
101        };
102
103        Subagent {
104            profile,
105            agent,
106            capabilities,
107        }
108    }
109
110    /// The tools this child was actually given, for `mecha tools` and for
111    /// checking that a profile's allowlist matched anything at all.
112    pub fn tool_names(&self) -> Vec<&str> {
113        self.agent.registry().iter().map(|t| t.name()).collect()
114    }
115}
116
117#[async_trait]
118impl Tool for Subagent {
119    fn name(&self) -> &str {
120        &self.profile.name
121    }
122
123    fn description(&self) -> &str {
124        &self.profile.description
125    }
126
127    fn input_schema(&self) -> Value {
128        json!({
129            "type": "object",
130            "properties": {
131                "task": {
132                    "type": "string",
133                    "description": "The complete task, written for someone with no \
134                                    memory of this conversation. State the goal, any \
135                                    context they need, and what to return."
136                }
137            },
138            "required": ["task"]
139        })
140    }
141
142    fn read_only(&self) -> bool {
143        // The child enforces its own permissions over its own tools; gating the
144        // spawn itself would ask the user to approve twice.
145        true
146    }
147
148    fn capabilities(&self) -> Capabilities {
149        self.capabilities
150    }
151
152    async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
153        let Some(task) = input.get("task").and_then(Value::as_str) else {
154            return Ok(ToolOutput::err("missing required string argument `task`"));
155        };
156
157        // A fresh conversation every time. The child inherits no history, which
158        // is the context-isolation half of why subagents are useful — and no
159        // taint either, because it has not read any of what the parent read.
160        // What comes back is marked untrusted on its own merits, below.
161        let mut convo = Conversation::user(task);
162
163        // The child works in the *caller's* workspace, not the one that existed
164        // when it was built — otherwise a parent running against a per-run
165        // sandbox delegates to a child still pointed at the original directory,
166        // which is both wrong and a hole in the jail. Permissions stay the
167        // child's own: the allowlist is the point of a subagent.
168        let cx = RunContext {
169            tools: Arc::new(ctx.clone()),
170            approver: Arc::clone(&self.agent.context().approver),
171            // The child's own `max_turns` comes from its profile, via its
172            // config — a parent's remaining budget is not the child's business.
173            budget: Default::default(),
174            // Cancelling the parent cancels the child with it: the child is
175            // one of the parent's tool calls, and a Ctrl-C that left a
176            // subagent running would be a lie. From the *caller's* context —
177            // the agent's own default has no token, which is exactly how this
178            // used to wait out the whole child run.
179            cancel: ctx.cancel.clone(),
180            // The child has its own transcript, and its own config decides
181            // when to summarise it.
182            compact_at_tokens: None,
183            // A subagent inherits the caller's phase: delegating from a
184            // planning run must not be the way to get a write executed. Also
185            // from the caller's context, for the same reason as `cancel` —
186            // the agent's own default is always `Execute`.
187            phase: ctx.phase,
188            // The child agent's own hooks — the front-end that installs hooks
189            // on the parent must install them on each child too (setup does),
190            // or delegating becomes the way around a pre_tool policy.
191            hooks: Arc::clone(&self.agent.context().hooks),
192            // Steering is addressed to the parent. The child was given a
193            // self-contained task and has no conversation to redirect.
194            queued_input: None,
195            // Same rule as hooks: setup installs the parent's outbox route on
196            // each child, or delegating becomes the way to send unstaged.
197            outbox: self.agent.context().outbox.clone(),
198        };
199
200        // If somebody is watching the parent run, forward the child's events
201        // wrapped in `Nested`, so a delegation stops being a tool call that
202        // goes dark for minutes. A grandchild's events arrive here already
203        // wrapped once and get wrapped again — depth for free.
204        let (child_events, forwarder) = match &ctx.events {
205            Some(parent) => {
206                let parent = parent.clone();
207                let name = self.profile.name.clone();
208                // The dispatch stamped the parent's tool_use id for this very
209                // call; carrying it on every wrapped event is what lets a
210                // renderer keep two parallel delegations apart.
211                let call_id = ctx.call_id.clone();
212                let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
213                let task = tokio::spawn(async move {
214                    while let Some(event) = rx.recv().await {
215                        let _ = parent.send(crate::agent::AgentEvent::Nested {
216                            tool: name.clone(),
217                            id: call_id.clone(),
218                            event: Box::new(event),
219                        });
220                    }
221                });
222                (Some(tx), Some(task))
223            }
224            None => (None, None),
225        };
226
227        let result = self.agent.run_in(&cx, &mut convo, child_events).await;
228
229        // Drain the forwarder before building the result, on both paths.
230        // `run_in` dropped its sender on return, so this terminates — and it
231        // is what guarantees every `Nested` event lands *between* the parent's
232        // `ToolCall` and `ToolResult` rather than racing past the latter.
233        if let Some(task) = forwarder {
234            let _ = task.await;
235        }
236
237        let outcome = match result {
238            Ok(o) => o,
239            Err(e) => {
240                return Ok(ToolOutput::err(format!(
241                    "subagent `{}` failed: {e:#}",
242                    self.profile.name
243                )))
244            }
245        };
246
247        let mut content = outcome.text;
248        if content.trim().is_empty() {
249            content = format!(
250                "The `{}` subagent finished without producing an answer after {} turns.",
251                self.profile.name, outcome.turns
252            );
253        }
254        if outcome.exhausted {
255            content
256                .push_str("\n\n[note: the subagent ran out of turns, so this may be incomplete]");
257        }
258        if outcome.blocked_sends > 0 {
259            content
260                .push_str("\n\n[note: the subagent attempted an outbound call that was blocked]");
261        }
262
263        let output = ToolOutput::ok(content);
264        // Marking the answer as external is what keeps the parent's interlock
265        // honest — see the module docs on why a summary is not laundering.
266        Ok(if self.capabilities.untrusted_input {
267            output.from_outside()
268        } else {
269            output
270        })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn profile_defaults_are_conservative() {
280        let p = SubagentProfile::default();
281        assert!(
282            p.tools.is_empty(),
283            "a profile grants no tools unless it says so"
284        );
285        assert!(
286            !p.trusted_output,
287            "child output is untrusted unless opted out"
288        );
289    }
290}