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