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}