defect_agent/tool/spawn_agent.rs
1//! `spawn_agent`: delegates a task to a subagent.
2//!
3//! The subagent runs a nested [`TurnRunner`] in a **fresh, isolated context**, and only
4//! the final assistant text is returned as the tool result to the parent agent — the
5//! parent never sees the subagent's intermediate steps. See the design memo
6//! `project-subagent-design`.
7//!
8//! ## Two Gates
9//!
10//! - **Gate A (which tools are visible)**: each profile's `tool_allow` whitelist is a
11//! subset of the parent agent's tool set. `spawn_agent` **may** be in the whitelist —
12//! recursion is controlled by the **depth gate** (see below), not unconditionally
13//! excluded.
14//! - **Gate B (how much is allowed at runtime)**: the child turn's policy is
15//! [`NonInteractivePolicy`] wrapping the parent policy — `Ask` is downgraded to `Deny`,
16//! the child agent is non-interactive, never blocks on [`PermissionGate`], and its
17//! authorization is always ≤ the parent's.
18//!
19//! ## Recursion and the Depth Gate
20//!
21//! A subagent is simply "an agent with a parent" — parent and child run the same
22//! [`TurnRunner`]. Recursion depth is controlled by
23//! [`crate::tool::ToolContext::subagent_depth`]: the top-level turn injects a configured
24//! maximum (`TurnConfig::subagent_max_depth`), decremented by one for each level. If a
25//! level's `tool_allow` contains `spawn_agent` **and the remaining child depth > 0**, a
26//! freshly constructed `spawn_agent` tool is installed for the child agent (capturing the
27//! same base tool set as the subset source, so grandchildren can continue); when depth is
28//! exhausted (0), the tool is not installed — a structural cutoff. A turn with `depth ==
29//! 0` has no `spawn_agent` in its tool set; calling it fails loudly.
30//!
31//! ## Inheritance Principle
32//!
33//! Inherit "ability to reach the world" (provider registry / fs / shell / http), but
34//! **not** "identity and behavior" (parent's system prompt / hooks / task framework). The
35//! child agent's system prompt = inherited base_prompt + the profile's own `system.md`,
36//! and does **not** go through
37//! [`resolve_system_prompt`](crate::session::resolve_system_prompt) (which would crawl
38//! the workspace `AGENTS.md` — that is the parent's identity).
39
40use std::collections::BTreeMap;
41use std::pin::Pin;
42use std::sync::Arc;
43
44use agent_client_protocol_schema::{
45 Content, ContentBlock, SessionId, TextContent, ToolCallContent, ToolCallUpdateFields, ToolKind,
46};
47use futures::StreamExt;
48use futures::future::BoxFuture;
49use serde::Deserialize;
50use serde_json::json;
51
52use crate::error::BoxError;
53use crate::event::AgentEvent;
54use crate::hooks::{HookEngine, NoopHookEngine};
55use crate::llm::{HostedCapabilities, MessageContent, ProviderRegistry, Role, SamplingParams};
56use crate::policy::{NonInteractivePolicy, SandboxPolicy};
57use crate::session::{
58 EventEmitter, History, PermissionGate, RequestAuditTracker, StaticToolRegistry, ToolRegistry,
59 TurnConfig, TurnRequestLimit, TurnRunner, VecHistory,
60};
61use crate::tool::{
62 SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
63 ToolStream,
64};
65
66/// The name of the `spawn_agent` tool. A constant so it can be reused when pruning the
67/// tool set to exclude itself, preventing typos.
68pub(crate) const SPAWN_AGENT_TOOL_NAME: &str = "spawn_agent";
69
70/// A subagent profile that can be invoked by `spawn_agent` (agent-side representation).
71///
72/// `ProfileSpec` in `defect-config` is the source of truth on the config side; the CLI
73/// projects it into this struct during assembly before handing it to the tool. The two
74/// are kept separate because `defect-config` depends on `defect-agent` — the agent cannot
75/// depend on config in the opposite direction, or a cycle would result.
76#[derive(Clone)]
77pub struct SubagentProfile {
78 /// Selection-time description that goes into the tool schema's catalog, allowing the
79 /// LLM to choose a profile based on it.
80 pub description: String,
81 /// Optional model override; `None` falls back to the parent session's currently
82 /// selected model (`ctx.current_model`).
83 pub model: Option<String>,
84 /// The full system prompt for this profile.
85 pub system_prompt: String,
86 /// Tool allowlist — the child agent can only see these tools (`spawn_agent` is always
87 /// excluded).
88 pub tool_allow: Vec<String>,
89 /// Optional sampling overrides.
90 pub sampling: Option<SamplingParams>,
91 /// The hook engine for this profile — hooks that run when a sub-agent executes a
92 /// turn.
93 ///
94 /// Consistent with the "inherit world, not identity" principle: hooks belong to the
95 /// profile's identity and are declared by the profile's own configuration (the CLI
96 /// assembles `ProfileSpec.hooks` into an engine at build time). They are **not**
97 /// inherited from the parent session. `None` means the sub-agent has no hooks (falls
98 /// back to [`NoopHookEngine`]), preserving exactly the same behavior as before —
99 /// existing profiles without hooks are unaffected.
100 pub hooks: Option<Arc<dyn HookEngine>>,
101}
102
103// `Arc<dyn HookEngine>` is not `Debug`; manually implement `Debug` to skip it (only
104// indicate whether an engine is attached).
105impl std::fmt::Debug for SubagentProfile {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("SubagentProfile")
108 .field("description", &self.description)
109 .field("model", &self.model)
110 .field("system_prompt", &self.system_prompt)
111 .field("tool_allow", &self.tool_allow)
112 .field("sampling", &self.sampling)
113 .field("hooks", &self.hooks.as_ref().map(|_| "<engine>"))
114 .finish()
115 }
116}
117
118/// The `spawn_agent` tool. It is registered on `StaticToolRegistry` and shared across
119/// sessions of the owning `AgentCore` via `process_tools` (it is **not** a process-global
120/// singleton — a single process may host multiple `AgentCore` instances, each with its
121/// own copy). At construction time it captures everything needed to run a nested turn,
122/// because [`ToolContext`] only carries cwd/fs/shell/http/cancel/current_model, not the
123/// provider registry, policy, or tool set.
124pub struct SpawnAgentTool {
125 schema: ToolSchema,
126 profiles: Arc<BTreeMap<String, SubagentProfile>>,
127 registry: Arc<ProviderRegistry>,
128 /// The parent agent's policy (shared by all sessions in this core). The child turn
129 /// wraps it with [`NonInteractivePolicy`].
130 policy: Arc<dyn SandboxPolicy>,
131 /// Parent agent tool set — source for subsetting by profile allowlist.
132 process_tools: Arc<dyn ToolRegistry>,
133 /// The `base_prompt` text inherited by child agents (the "you are an agent that can
134 /// use tools" boilerplate).
135 base_prompt: Option<String>,
136}
137
138impl SpawnAgentTool {
139 /// Constructs a `spawn_agent` tool. When `profiles` is empty, the caller **should
140 /// not** register this tool (the `profile` enum in the schema will be an empty set,
141 /// so calls will always fail) — see [`Self::has_profiles`].
142 pub fn new(
143 profiles: Arc<BTreeMap<String, SubagentProfile>>,
144 registry: Arc<ProviderRegistry>,
145 policy: Arc<dyn SandboxPolicy>,
146 process_tools: Arc<dyn ToolRegistry>,
147 base_prompt: Option<String>,
148 ) -> Self {
149 let schema = build_schema(&profiles);
150 Self {
151 schema,
152 profiles,
153 registry,
154 policy,
155 process_tools,
156 base_prompt,
157 }
158 }
159
160 /// Whether any profiles were discovered. The assembler uses this to decide whether to
161 /// register this tool.
162 pub fn has_profiles(profiles: &BTreeMap<String, SubagentProfile>) -> bool {
163 !profiles.is_empty()
164 }
165}
166
167/// Dynamically build the schema: `profile` is an enum of discovered profile names (hard
168/// constraint), and the tool description embeds a catalog of `- <name>: <description>`
169/// entries (soft guidance). Both are required: the enum alone gives no usage context,
170/// while the catalog alone risks name typos.
171fn build_schema(profiles: &BTreeMap<String, SubagentProfile>) -> ToolSchema {
172 let names: Vec<&str> = profiles.keys().map(String::as_str).collect();
173 let catalog = profiles
174 .iter()
175 .map(|(name, p)| format!("- {name}: {}", p.description))
176 .collect::<Vec<_>>()
177 .join("\n");
178 let description = format!(
179 "Delegate a task to a specialized subagent that runs in a fresh, isolated context. \
180 The subagent returns only its final summary, not its intermediate work. \
181 Pick the profile whose description best matches the task.\n\n\
182 When you have multiple independent pieces of work, emit several `spawn_agent` \
183 calls in a single message: they run concurrently (fanout), so the total wait is \
184 the slowest subagent rather than their sum. Only spawn one at a time when a later \
185 task genuinely depends on an earlier subagent's result.\n\n\
186 Available profiles:\n{catalog}"
187 );
188 ToolSchema {
189 name: SPAWN_AGENT_TOOL_NAME.to_string(),
190 description,
191 input_schema: json!({
192 "type": "object",
193 "properties": {
194 "profile": {
195 "type": "string",
196 "enum": names,
197 "description": "Which subagent to spawn. See the tool description for what each profile does."
198 },
199 "task": {
200 "type": "string",
201 "description": "The complete task for the subagent, as a self-contained \
202 natural-language instruction. The subagent has none of this \
203 conversation's context — include everything it needs."
204 },
205 "model": {
206 "type": "string",
207 "description": "Optional model override for this subagent. When omitted, \
208 the profile's configured model is used, falling back to the \
209 parent session's current model. Only set this when a task \
210 needs a specifically more or less capable model than the default."
211 },
212 "run_in_background": {
213 "type": "boolean",
214 "description": "When true, spawn the subagent asynchronously and return \
215 immediately with a task id, without waiting for it to finish. \
216 The subagent's result is delivered back to you later, on a \
217 subsequent turn, so you can keep working in the meantime. \
218 Leave false (the default) when the next step depends on this \
219 subagent's result — then the call blocks until it completes."
220 }
221 },
222 "required": ["profile", "task"]
223 }),
224 }
225}
226
227#[derive(Debug, Deserialize)]
228struct SpawnArgs {
229 profile: String,
230 task: String,
231 /// Optional per-call model override. Takes highest priority (overrides
232 /// `profile.model` and parent model).
233 #[serde(default)]
234 model: Option<String>,
235 /// Whether to run in the background. When `true` and the context supports it
236 /// (`ToolContext::background` is `Some`), spawn returns the task id immediately
237 /// without waiting for the child agent to finish. Defaults to `false` (synchronous
238 /// blocking).
239 #[serde(default)]
240 run_in_background: bool,
241}
242
243impl Tool for SpawnAgentTool {
244 fn schema(&self) -> &ToolSchema {
245 &self.schema
246 }
247
248 fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
249 // Conservatively mark as Mutating: the "danger" of spawn itself is determined by
250 // the child agent's tool set (gate A) and `NonInteractivePolicy` (gate B), not
251 // subdivided at this layer.
252 SafetyClass::Mutating
253 }
254
255 fn describe<'a>(
256 &'a self,
257 args: &'a serde_json::Value,
258 _ctx: ToolContext<'a>,
259 ) -> BoxFuture<'a, ToolCallDescription> {
260 Box::pin(async move {
261 let profile = args.get("profile").and_then(|v| v.as_str()).unwrap_or("?");
262 let mut fields = ToolCallUpdateFields::default();
263 fields.title = Some(format!("Spawn subagent `{profile}`"));
264 fields.kind = Some(ToolKind::Think);
265 ToolCallDescription { fields }
266 })
267 }
268
269 fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
270 // Move captured dependencies from construction and runtime handles from `ctx`
271 // into a `'static` future — all borrows of the nested `TurnRunner` live inside
272 // this async block and do not escape.
273 let profiles = self.profiles.clone();
274 let registry = self.registry.clone();
275 // Prefer the active policy from the current turn's snapshot (injected via `ctx`),
276 // which reflects the session's current permission mode; fall back to the policy
277 // captured at construction time only when none was injected (e.g. in tests or
278 // when omitted).
279 let policy = ctx.policy.clone().unwrap_or_else(|| self.policy.clone());
280 let process_tools = self.process_tools.clone();
281 let base_prompt = self.base_prompt.clone();
282
283 let cwd = ctx.cwd.to_path_buf();
284 let fs = ctx.fs.clone();
285 let shell = ctx.shell.clone();
286 let http = ctx.http.clone();
287 let parent_model = ctx.current_model.to_string();
288 let parent_provider = ctx.current_provider.to_string();
289 let background = ctx.background.clone();
290 // Subagent event bridge: nest child-turn events back into the parent trace
291 // (observability).
292 let bridge = ctx.subagent_bridge.clone();
293 // Remaining subagent dispatch depth for this turn. Child turns receive `depth-1`;
294 // whether the child toolset includes `spawn_agent` is determined by `child_depth
295 // > 0` (see `run_subagent_core`).
296 let subagent_depth = ctx.subagent_depth;
297 // The synchronous path uses a turn child token (cancelled when the turn ends);
298 // the background path does not use it, instead using a session-level child token
299 // minted by `BackgroundTasks` at spawn time (see below).
300 let turn_cancel = ctx.cancel.child_token();
301
302 // First parse `run_in_background` and the profile name to decide whether to run
303 // synchronously or in the background. On parse failure, both paths treat it as
304 // `InvalidArgs`.
305 let parsed: Result<SpawnArgs, _> = serde_json::from_value(args.clone());
306
307 let fut = async move {
308 let parsed = match parsed {
309 Ok(p) => p,
310 Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
311 };
312
313 // Depth guard: the remaining dispatch depth for this turn is exhausted (0),
314 // so the `spawn_agent` tool should never have been visible —
315 // `run_subagent_core` does not include it in the child tool set when
316 // `child_depth == 0`. Reaching this point indicates a malformed `ctx`; fail
317 // loudly, do not silently swallow. The top-level turn injects the configured
318 // maximum, which is always > 0 under normal conditions.
319 if subagent_depth == 0 {
320 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(
321 "subagent recursion depth exhausted: this agent is not allowed to spawn \
322 further subagents"
323 .to_string(),
324 ))));
325 }
326
327 // Background path: requires `ctx` to support background (only injected at the
328 // top-level turn), and `run_in_background=true`.
329 if parsed.run_in_background {
330 let Some(bg) = background else {
331 // Background context is unavailable (nested subagent / test) — fail
332 // loud, do not silently fall back to synchronous execution, otherwise
333 // the model believes it is running in the background while actually
334 // blocking, contradicting the declared behavior.
335 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(io_err(
336 "run_in_background is not available in this context (nested subagents \
337 cannot spawn background tasks)"
338 .to_string(),
339 ))));
340 };
341 let label = parsed.profile.clone();
342 let deps = SubagentDeps {
343 profiles,
344 registry,
345 policy,
346 process_tools,
347 base_prompt,
348 cwd,
349 fs,
350 shell,
351 http,
352 parent_model,
353 parent_provider,
354 subagent_depth,
355 // The background path also uses the bridge — the same
356 // `AgentEvent::Subagent` mechanism as the foreground. The
357 // `spawn_agent` tool span that initiates it closes normally first
358 // (the `ToolCallFinished` "started" below), then the child turn
359 // events appear as an **adjacent** subagent span under the same
360 // `parent_tool_call_id` anchor, remaining open until the child turn
361 // truly ends. The projector naturally distinguishes foreground
362 // (nested) from background (adjacent) by checking whether the tool
363 // span is still in the table. The bridge's `parent_events` is a
364 // session-level `EventEmitter` that stays alive while the background
365 // task runs.
366 bridge,
367 // Only the background path exposes history — `task_handle` is
368 // obtained inside the spawn closure and injected later (see below).
369 task_handle: None,
370 };
371 // Spawn mints a session-level child token for the task, so the task's
372 // cancellation lifecycle is independent of the turn that spawned it —
373 // ending the turn does not kill it. Also obtains a `TaskHandle`, shares
374 // the child turn's `history` `Arc` into the task table, and lets the main
375 // agent inspect the child agent's **submitted-to-LLM message blocks**
376 // (not streaming deltas) via `inspect_background_task`.
377 let label_for_log = parsed.profile.clone();
378 let task_id = bg.spawn(label, move |task_cancel, task_handle| async move {
379 let mut deps = deps;
380 deps.task_handle = Some(task_handle);
381 match run_subagent_core(parsed, deps, task_cancel).await {
382 Ok(answer) => crate::session::BackgroundResult::Completed(answer),
383 Err(err) => {
384 // Log loudly: background failures were previously silently
385 // reduced to a `Failed` string, with no Langfuse event or log
386 // entry. This adds a `warn` with the task and error details.
387 tracing::warn!(
388 profile = %label_for_log,
389 error = %err,
390 "background subagent failed"
391 );
392 crate::session::BackgroundResult::Failed(err.to_string())
393 }
394 }
395 });
396 // Return synchronously with "started id=X" to satisfy the tool_use ↔
397 // tool_result pairing contract.
398 // Subagent profiles are indexed by source name at startup.
399 let msg = format!(
400 "Started background subagent `{}`, task id `{}`. Its result will arrive on a \
401 later turn.",
402 parsed_profile_for_msg(&args),
403 task_id
404 );
405 let mut fields = ToolCallUpdateFields::default();
406 fields.content = Some(vec![ToolCallContent::Content(Content::new(
407 ContentBlock::Text(TextContent::new(msg.clone())),
408 ))]);
409 fields.raw_output = Some(serde_json::Value::String(msg));
410 return ToolEvent::Completed(fields);
411 }
412
413 // Synchronous path: original behavior — block until the sub-turn finishes,
414 // then use the final text as the result.
415 let deps = SubagentDeps {
416 profiles,
417 registry,
418 policy,
419 process_tools,
420 base_prompt,
421 cwd,
422 fs,
423 shell,
424 http,
425 parent_model,
426 parent_provider,
427 subagent_depth,
428 // Synchronous path: the parent `spawn_agent` tool span remains open for
429 // the entire duration (blocking until the child turn completes), allowing
430 // child events to be nested under it.
431 bridge,
432 // Synchronous path: no background task, no history exposed (parent call
433 // blocks entirely; no need to "peek while running").
434 task_handle: None,
435 };
436 match run_subagent_core(parsed, deps, turn_cancel).await {
437 Ok(answer) => {
438 let mut fields = ToolCallUpdateFields::default();
439 fields.content = Some(vec![ToolCallContent::Content(Content::new(
440 ContentBlock::Text(TextContent::new(answer.clone())),
441 ))]);
442 fields.raw_output = Some(serde_json::Value::String(answer));
443 ToolEvent::Completed(fields)
444 }
445 Err(err) => ToolEvent::Failed(err),
446 }
447 };
448 let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
449 Box::pin(futures::stream::once(fut));
450 s
451 }
452}
453
454/// Dependency bundle for `run_subagent_core` — avoids a dozen positional parameters. All
455/// construction-time and ctx handles are moved in, fully owned, so they can cross await
456/// points or be sent to a background task.
457struct SubagentDeps {
458 profiles: Arc<BTreeMap<String, SubagentProfile>>,
459 registry: Arc<ProviderRegistry>,
460 policy: Arc<dyn SandboxPolicy>,
461 process_tools: Arc<dyn ToolRegistry>,
462 base_prompt: Option<String>,
463 cwd: std::path::PathBuf,
464 fs: Arc<dyn crate::fs::FsBackend>,
465 shell: Arc<dyn crate::shell::ShellBackend>,
466 http: Arc<dyn crate::http::HttpClient>,
467 parent_model: String,
468 /// The provider vendor currently selected in the parent session. Together with
469 /// `parent_model` this forms a `(vendor, model)` selection pair – when the child
470 /// agent's model falls back to the parent's choice, the entry is resolved exactly by
471 /// this pair. An empty string means the parent context did not inject a vendor
472 /// (legacy/test path), in which case the fallback picks the first entry by bare model
473 /// id.
474 parent_provider: String,
475 /// Remaining dispatch depth for this (initiator) turn. Child turns run at
476 /// `subagent_depth - 1`; the child toolset includes `spawn_agent` only when that
477 /// decremented value is `> 0` (see `run_subagent_core`).
478 subagent_depth: u32,
479 /// Subagent event bridge: when `Some`, nests child turn events back into the parent
480 /// trace. Only set on the synchronous path.
481 bridge: Option<crate::tool::SubagentBridge>,
482 /// Background task handle: when `Some`, shares the child turn's history `Arc` into
483 /// the task table so the main agent can inspect the child agent's **message chunks
484 /// submitted to the LLM** via `inspect_background_task`. Only set in the background
485 /// path — the synchronous path's parent `spawn_agent` call blocks entirely, so there
486 /// is no need to "peek while running".
487 task_handle: Option<crate::session::TaskHandle>,
488}
489
490/// Extracts the profile name from the raw args (used only for the background-start
491/// confirmation message; falls back to a placeholder on failure).
492fn parsed_profile_for_msg(args: &serde_json::Value) -> String {
493 args.get("profile")
494 .and_then(|v| v.as_str())
495 .unwrap_or("?")
496 .to_string()
497}
498
499/// Runs a sub-agent turn, returning the final text (`Ok`) or an error description
500/// (`Err`).
501///
502/// Both the synchronous and background paths share this core: the synchronous path wraps
503/// `Ok/Err` into `ToolEvent::Completed/Failed`, while the background path wraps them into
504/// `BackgroundResult::Completed/Failed`. The caller determines the lifecycle of `cancel`
505/// — the synchronous path passes a turn-level child token, and the background path passes
506/// a session-level child token.
507async fn run_subagent_core(
508 parsed: SpawnArgs,
509 deps: SubagentDeps,
510 cancel: tokio_util::sync::CancellationToken,
511) -> Result<String, ToolError> {
512 let SubagentDeps {
513 profiles,
514 registry,
515 policy,
516 process_tools,
517 base_prompt,
518 cwd,
519 fs,
520 shell,
521 http,
522 parent_model,
523 parent_provider,
524 subagent_depth,
525 bridge,
526 task_handle,
527 } = deps;
528
529 let Some(profile) = profiles.get(&parsed.profile) else {
530 return Err(ToolError::InvalidArgs(BoxError::new(io_err(format!(
531 "unknown profile `{}`; available: {}",
532 parsed.profile,
533 profiles.keys().cloned().collect::<Vec<_>>().join(", ")
534 )))));
535 };
536
537 // Model priority: call argument > profile > parent session's current model.
538 // Only when the model falls back to the parent (no explicit override) do we also
539 // inherit the parent's provider vendor, resolving precisely by `(vendor, model)` pair
540 // (so multiple gateways with the same model won't pick the wrong provider). When the
541 // model is explicitly overridden, there is no provider dimension information — fall
542 // back to taking the first entry by bare model id.
543 let model_override = parsed.model.clone().or_else(|| profile.model.clone());
544 let inherits_parent = model_override.is_none();
545 let model = model_override.unwrap_or(parent_model);
546 let entry = if inherits_parent && !parent_provider.is_empty() {
547 registry.entry_for(&parent_provider, &model)
548 } else {
549 registry.first_entry_for_model(&model)
550 };
551 let Some(entry) = entry else {
552 return Err(ToolError::Execution(BoxError::new(io_err(format!(
553 "subagent model `{model}` is not declared by any provider entry"
554 )))));
555 };
556 let provider = entry.provider().clone();
557
558 // The remaining dispatch depth for the child turn is this layer minus one. By this
559 // point `subagent_depth >= 1` (execute already fails loud on 0), so the child depth
560 // is >= 0.
561 let child_depth = subagent_depth - 1;
562
563 // Gate A: subset the parent tool set by the allowlist. `spawn_agent` is no longer
564 // unconditionally excluded — instead, a **depth gate** decides: only when
565 // `child_depth > 0` (the child agent can dispatch at least one more level) and the
566 // profile allowlist explicitly permits it, the child agent receives a **freshly
567 // constructed** `spawn_agent` tool (which captures the same base `process_tools` as
568 // the subset source, enabling grandchildren to continue recursion). When depth is
569 // exhausted (`child_depth == 0`), `spawn_agent` is ignored even if listed in the
570 // allowlist — a structural closure (same effect as the old hardcoded behavior, but
571 // configurable). Unknown tool names hard-fail (fail loud, not silently ignored).
572 let mut builder = StaticToolRegistry::builder();
573 for name in &profile.tool_allow {
574 if name == SPAWN_AGENT_TOOL_NAME {
575 if child_depth > 0 {
576 let child_spawn = SpawnAgentTool::new(
577 profiles.clone(),
578 registry.clone(),
579 // Pass the parent policy obtained at this layer to the child
580 // `SpawnAgentTool`, which captures it as a fallback at construction
581 // time; at runtime, the active policy injected via `ctx` still takes
582 // precedence. The child turn is further wrapped in `NonInteractive`.
583 policy.clone(),
584 process_tools.clone(),
585 base_prompt.clone(),
586 );
587 builder = builder.insert(Arc::new(child_spawn));
588 }
589 // child_depth == 0: depth exhausted, skip — structurally prevents further
590 // recursion.
591 continue;
592 }
593 match process_tools.get(name) {
594 Some(tool) => builder = builder.insert(tool),
595 None => {
596 return Err(ToolError::InvalidArgs(BoxError::new(io_err(format!(
597 "profile `{}` allows unknown tool `{name}`",
598 parsed.profile
599 )))));
600 }
601 }
602 }
603 let sub_tools = builder.build();
604
605 // System prompt: inherited `base_prompt` + profile's own `system.md`. Does not use
606 // `resolve_system_prompt` (to avoid crawling workspace `AGENTS.md` / provider·model
607 // overlay).
608 let mut sections = Vec::new();
609 if let Some(bp) = base_prompt.as_deref()
610 && !bp.is_empty()
611 {
612 sections.push(bp.to_string());
613 }
614 if !profile.system_prompt.is_empty() {
615 sections.push(profile.system_prompt.clone());
616 }
617 let system_prompt: Option<Arc<str>> =
618 (!sections.is_empty()).then(|| Arc::from(sections.join("\n\n").as_str()));
619
620 // All sub-turn state is local to this async block and dropped when it completes.
621 // `history` is wrapped in `Arc` so the background path can share the same history
622 // with the task table, allowing the control plane to peek at the message blocks the
623 // sub-agent submits to the LLM.
624 let history: Arc<dyn History> = Arc::new(VecHistory::new());
625 if let Some(handle) = &task_handle {
626 handle.attach_history(history.clone());
627 }
628 let events = Arc::new(EventEmitter::new());
629
630 // Observability bridge: wraps each event from the child turn into an
631 // `AgentEvent::Subagent` and forwards it back to the parent session's event stream,
632 // so that Langfuse can nest the child turn under the parent's `spawn_agent` tool
633 // span. This is observability-only — the isolation contract leaves `storage` / `wire`
634 // / `REPL` unchanged (they ignore `Subagent`). The bridge task subscribes to the
635 // child emitter; once the child turn finishes and this function returns, dropping
636 // `events` (the last strong reference) ends the child stream, and the task exits
637 // naturally without an explicit join.
638 let bridge_task = bridge.map(|b| {
639 let mut sub_events = events.subscribe();
640 let agent_type = parsed.profile.clone();
641 tokio::spawn(async move {
642 while let Some(ev) = sub_events.next().await {
643 // Recursive flattening: this bridge layer only prepends its own
644 // `tool_call_id`.
645 //
646 // - From a deeper layer that is **already** a `Subagent` (with a partial
647 // ancestor chain) → insert this layer's id at the head of the chain,
648 // keeping the deeper `agent_type` and leaf `inner` unchanged.
649 // - A **leaf** event from a child turn → wrap it as `Subagent{[this
650 // layer's id], this layer's profile, leaf}`.
651 //
652 // After the event passes through N layers, `ancestor_path` is exactly the
653 // complete chain from the top layer to the leaf.
654 let forwarded = match ev {
655 AgentEvent::Subagent {
656 mut ancestor_path,
657 agent_type: deeper,
658 inner,
659 } => {
660 ancestor_path.insert(0, b.parent_tool_call_id.clone());
661 AgentEvent::Subagent {
662 ancestor_path,
663 agent_type: deeper,
664 inner,
665 }
666 }
667 leaf => AgentEvent::Subagent {
668 ancestor_path: vec![b.parent_tool_call_id.clone()],
669 agent_type: agent_type.clone(),
670 inner: Box::new(leaf),
671 },
672 };
673 b.parent_events.emit(forwarded).await;
674 }
675 })
676 });
677
678 let permissions = PermissionGate::new();
679 let sub_policy: Arc<dyn SandboxPolicy> = Arc::new(NonInteractivePolicy::new(policy));
680 // Use the hook engine declared in the profile, or fall back to `NoopHookEngine` (same
681 // behavior as before the change).
682 let noop = NoopHookEngine;
683 let hooks: &dyn HookEngine = match &profile.hooks {
684 Some(engine) => engine.as_ref(),
685 None => &noop,
686 };
687 let session_id = SessionId::new(format!("subagent-{}", parsed.profile));
688 let audit = RequestAuditTracker::new();
689
690 let config = TurnConfig {
691 model: model.clone(),
692 sampling: profile.sampling.clone().unwrap_or_default(),
693 // Limit subagent to a fixed number of steps to prevent runaway nested loops.
694 request_limit: TurnRequestLimit::Fixed(32),
695 // Depth decreases by one per level: the child turn's tool driver uses this to
696 // decide whether grandchildren can be dispatched. When `child_depth == 0`, the
697 // child turn's tool set already lacks `spawn_agent` (gate A above is not
698 // installed), so redundantly setting it to 0 here is self-consistent.
699 subagent_max_depth: child_depth,
700 ..TurnConfig::default()
701 };
702
703 let runner = TurnRunner {
704 history: history.as_ref(),
705 tools: &sub_tools,
706 provider: provider.as_ref(),
707 policy: sub_policy,
708 events: events.clone(),
709 permissions: &permissions,
710 cancel: cancel.clone(),
711 config: &config,
712 system_prompt,
713 cwd: &cwd,
714 fs,
715 shell,
716 http,
717 hosted_capabilities: HostedCapabilities::default(),
718 hooks,
719 session_id: &session_id,
720 request_audit: &audit,
721 // Sub‑agent turns carry no background handle: structurally prevents background
722 // tasks from spawning themselves (same anti‑recursion design as "whitelist never
723 // contains spawn_agent itself").
724 background: None,
725 // Sub‑agent does not participate in the parent’s goal loop: the parent’s
726 // `goal_done` / `goal‑gate` only apply at the top‑level turn; the sub‑agent has
727 // its own finite step limit (`request_limit`) as a safety net.
728 goal: None,
729 // Sub-agent turns skip background compaction: the context is short and its
730 // lifetime ends with the tool call, so no cross-turn background summary is
731 // needed. It still benefits from the hard-watermark synchronous compaction
732 // fallback (the `compact_hard` path requires `provider_arc`), so we give it
733 // `provider_arc` and leave the other background compaction fields empty.
734 compaction_slot: None,
735 history_arc: None,
736 provider_arc: Some(provider.clone()),
737 session_cancel: None,
738 // The sub-agent's task is its "user input".
739 ingest_source: crate::hooks::step::IngestSource::User,
740 };
741
742 let prompt = vec![ContentBlock::Text(TextContent::new(parsed.task))];
743 let run_result = runner.run(prompt).await;
744
745 // End of sub-turn: drop `runner` and the local strong reference to `events`, allowing
746 // the child event stream to close. The bridge task flushes any buffered events to the
747 // parent emitter and then exits. Awaiting it ensures all child events arrive before
748 // the parent `spawn_agent` tool span finishes (this function returns →
749 // `ToolCallFinished`).
750 drop(runner);
751 drop(events);
752 if let Some(task) = bridge_task {
753 let _ = task.await;
754 }
755
756 if let Err(err) = run_result {
757 return Err(ToolError::Execution(BoxError::new(io_err(format!(
758 "subagent turn failed: {err}"
759 )))));
760 }
761
762 // Take the text of the last assistant message as the result.
763 Ok(last_assistant_text(&history.snapshot()))
764}
765
766/// Take the **last** [`Role::Assistant`] message from the history and concatenate all its
767/// `Text` segments (skipping thinking / tool_use). The tool-use loop may append multiple
768/// assistant messages; the last one corresponds to the "final answer".
769fn last_assistant_text(history: &[crate::llm::Message]) -> String {
770 history
771 .iter()
772 .rev()
773 .find(|m| m.role == Role::Assistant)
774 .map(|m| {
775 m.content
776 .iter()
777 .filter_map(|c| match c {
778 MessageContent::Text { text } => Some(text.as_str()),
779 _ => None,
780 })
781 .collect::<Vec<_>>()
782 .join("")
783 })
784 .unwrap_or_default()
785}
786
787fn io_err(msg: String) -> std::io::Error {
788 std::io::Error::other(msg)
789}
790
791#[cfg(test)]
792mod tests;