Skip to main content

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;