Skip to main content

bamboo_server_tools/
sub_agent.rs

1use async_trait::async_trait;
2use serde::Deserialize;
3use serde_json::json;
4use std::sync::Arc;
5use uuid::Uuid;
6
7use bamboo_agent_core::tools::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
8use bamboo_domain::session::runtime_state::ChildWaitPolicy;
9use bamboo_domain::ReasoningEffort;
10use bamboo_engine::session_app::child_session::{
11    self, ChildSessionError, ChildSessionPort, CreateChildInput, ModelCatalogPort,
12    SubagentResolutionPort,
13};
14
15// ---------------------------------------------------------------------------
16// Args enum
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Deserialize)]
20#[serde(tag = "action", rename_all = "snake_case")]
21enum SubAgentArgs {
22    Create {
23        #[serde(default)]
24        title: Option<String>,
25        #[serde(default)]
26        description: String,
27        #[serde(default)]
28        responsibility: Option<String>,
29        prompt: String,
30        /// Optional free-text label for this child (cosmetic only — used for
31        /// display and as the warm-worker reuse key). It has NO effect on the
32        /// child's tools or system prompt; every sub-agent is a full agent.
33        #[serde(default)]
34        subagent_type: Option<String>,
35        /// Working directory for the child. Optional: defaults to the parent
36        /// session's workspace when omitted.
37        #[serde(default)]
38        workspace: Option<String>,
39        #[serde(default)]
40        auto_run: Option<bool>,
41        /// When `true`, the parent suspends immediately and waits for THIS child
42        /// to finish (the legacy one-shot behavior). Defaults to `false`:
43        /// `create` runs the child in the background and returns right away so
44        /// the parent can spawn more children. Call `action=wait` once, after
45        /// spawning everything, to suspend until they finish.
46        #[serde(default)]
47        wait: Option<bool>,
48        /// Optional reasoning effort for the child session. When omitted,
49        /// the child stays at `None` so the provider's default applies
50        /// (it does NOT inherit the parent's reasoning_effort). The LLM
51        /// should pass an explicit value (e.g. `"low"` for cheap fan-outs,
52        /// `"high"`/`"max"` for hard reasoning) when it has a preference.
53        #[serde(default)]
54        reasoning_effort: Option<ReasoningEffort>,
55        /// Optional explicit model for the child, `"provider:model"`
56        /// (e.g. `"anthropic:claude-sonnet-4-6"`) or a bare model id (resolved
57        /// against the parent's provider, falling back to the default
58        /// provider). Takes precedence over per-`subagent_type` model routing.
59        /// Call `list_models` to see what is available.
60        #[serde(default)]
61        model: Option<String>,
62        /// Lifecycle: `"oneshot"` (default) creates a fresh throwaway child for
63        /// this task. `"resident"` reuses a long-lived agent identified by
64        /// `name` (scoped to this conversation): the FIRST resident create spins
65        /// one up; later creates with the same `name` route the new task to that
66        /// same agent instead of spawning another — so repeated similar work
67        /// (e.g. an "essayist" handling many essays) stays one agent/one entry,
68        /// not N. Use resident for recurring task types; one-shot for
69        /// independent throwaway work.
70        #[serde(default)]
71        lifecycle: Option<String>,
72        /// Resident reuse key (required when `lifecycle="resident"`; defaults to
73        /// `subagent_type`). The stable name of the resident agent to create or
74        /// reuse, e.g. `"essayist"`.
75        #[serde(default)]
76        name: Option<String>,
77        /// For a resident agent, how successive tasks treat prior context:
78        /// `"reset"` (default — each task is independent, prior context cleared)
79        /// or `"accumulate"` (the agent remembers earlier tasks). Set on first
80        /// create; honored on reuse.
81        #[serde(default)]
82        context: Option<String>,
83        /// Phase 3 model-controllable context fork: when `> 0`, carry the last N
84        /// of the parent's messages into the child's task brief. `None`/0 (the
85        /// default) gives the child a clean, freshly-seeded context.
86        #[serde(default)]
87        fork_last_messages: Option<usize>,
88    },
89    /// Suspend the parent run until its background child sessions finish.
90    ///
91    /// Spawn children with `action=create` (which no longer suspends), then call
92    /// this once. By default it waits on every currently-active child; pass
93    /// explicit `child_session_ids` to wait on a subset. If no children are
94    /// active it is a no-op (the parent keeps running).
95    Wait {
96        #[serde(default)]
97        child_session_ids: Option<Vec<String>>,
98        /// Wait policy: `all` (default) resumes when every tracked child is
99        /// terminal; `any` resumes on the first; `first_error` resumes early on
100        /// any error/timeout/cancel.
101        #[serde(default)]
102        wait_for: Option<ChildWaitPolicy>,
103    },
104    List,
105    Get {
106        child_session_id: String,
107    },
108    Update {
109        child_session_id: String,
110        #[serde(default)]
111        title: Option<String>,
112        #[serde(default)]
113        responsibility: Option<String>,
114        #[serde(default)]
115        prompt: Option<String>,
116        #[serde(default)]
117        subagent_type: Option<String>,
118        #[serde(default)]
119        reset_after_update: Option<bool>,
120        #[serde(default)]
121        auto_run: Option<bool>,
122        /// Optional reasoning effort to apply to the existing child session.
123        /// `Some(level)` overrides the current value; `None` (the default)
124        /// leaves it unchanged.
125        #[serde(default)]
126        reasoning_effort: Option<ReasoningEffort>,
127    },
128    Run {
129        child_session_id: String,
130        #[serde(default)]
131        reset_to_last_user: Option<bool>,
132    },
133    SendMessage {
134        child_session_id: String,
135        message: String,
136        #[serde(default)]
137        auto_run: Option<bool>,
138        #[serde(default)]
139        interrupt_running: Option<bool>,
140    },
141    Cancel {
142        child_session_id: String,
143    },
144    Delete {
145        child_session_id: String,
146    },
147    /// Enumerate the models the parent can pin a child to via
148    /// `create.model`. Read-only; best-effort per configured provider.
149    ListModels,
150}
151
152// ---------------------------------------------------------------------------
153// Normalization helpers (ported from legacy SpawnSessionTool)
154// ---------------------------------------------------------------------------
155
156fn normalize_required_text(value: Option<String>, field_name: &str) -> Result<String, ToolError> {
157    let Some(value) = value else {
158        return Err(ToolError::InvalidArguments(format!(
159            "{field_name} must be non-empty"
160        )));
161    };
162    let trimmed = value.trim();
163    if trimmed.is_empty() {
164        return Err(ToolError::InvalidArguments(format!(
165            "{field_name} must be non-empty"
166        )));
167    }
168    Ok(trimmed.to_string())
169}
170
171fn normalize_title(title: Option<String>, legacy_description: String) -> Result<String, ToolError> {
172    let title = title.and_then(|value| {
173        let trimmed = value.trim();
174        if trimmed.is_empty() {
175            None
176        } else {
177            Some(trimmed.to_string())
178        }
179    });
180    let legacy_description = {
181        let trimmed = legacy_description.trim();
182        if trimmed.is_empty() {
183            None
184        } else {
185            Some(trimmed.to_string())
186        }
187    };
188    normalize_required_text(title.or(legacy_description), "title")
189}
190
191fn tool_result(value: serde_json::Value) -> Result<ToolResult, ToolError> {
192    Ok(ToolResult {
193        success: true,
194        result: value.to_string(),
195        display_preference: Some("Collapsible".to_string()),
196        images: Vec::new(),
197    })
198}
199
200fn waiting_for_children_tool_result(mut value: serde_json::Value) -> Result<ToolResult, ToolError> {
201    if let Some(object) = value.as_object_mut() {
202        object.insert("runtime_control".to_string(), json!("waiting_for_children"));
203        // Don't clobber a caller-provided policy (e.g. action=wait with
204        // wait_for=any); only default it when absent.
205        object
206            .entry("wait_for".to_string())
207            .or_insert_with(|| json!("all"));
208        object.insert(
209            "note".to_string(),
210            json!("Child session queued. The parent run is suspended and will resume automatically when the child finishes or times out."),
211        );
212    }
213
214    Ok(ToolResult {
215        success: true,
216        result: value.to_string(),
217        display_preference: Some("runtime_control:waiting_for_children".to_string()),
218        images: Vec::new(),
219    })
220}
221
222/// Split an explicit `SubAgent.wait` id list into `(targets, dropped)`:
223/// `dropped` = `(id, status)` pairs the index positively reported terminal
224/// (waiting on them could never be satisfied — issue #546), `targets` =
225/// everything else, including unknown ids (kept: the watchdog rescues a bogus
226/// id at runtime; an index-less backend reports nothing terminal and filters
227/// nothing).
228fn partition_wait_targets(
229    requested: Vec<String>,
230    known_terminal: &[(String, String)],
231) -> (Vec<String>, Vec<(String, String)>) {
232    let mut targets = Vec::new();
233    let mut dropped = Vec::new();
234    for id in requested {
235        match known_terminal
236            .iter()
237            .find(|(terminal_id, _)| *terminal_id == id)
238        {
239            Some((_, status)) => dropped.push((id, status.clone())),
240            None => targets.push(id),
241        }
242    }
243    (targets, dropped)
244}
245
246/// Whether the dropped (already-terminal) ids of an explicit wait ALREADY
247/// satisfy the requested policy, so the wait must short-circuit to a
248/// non-suspending result instead of arming over the remainder (issue #546):
249/// `any` is satisfied by any terminal child; `first_error` by any error-like
250/// terminal child. For `all`, waiting on the remainder is equivalent, so the
251/// residual wait proceeds.
252fn wait_already_satisfied_by_dropped(
253    policy: ChildWaitPolicy,
254    dropped: &[(String, String)],
255) -> bool {
256    match policy {
257        ChildWaitPolicy::All => false,
258        ChildWaitPolicy::Any => !dropped.is_empty(),
259        ChildWaitPolicy::FirstError => dropped
260            .iter()
261            .any(|(_, status)| matches!(status.as_str(), "error" | "timeout" | "cancelled")),
262    }
263}
264
265/// Map a `ChildSessionError` to a `ToolError`.
266fn tool_error_from_child_session(error: ChildSessionError) -> ToolError {
267    match error {
268        ChildSessionError::NotFound(id) => ToolError::Execution(format!("session not found: {id}")),
269        ChildSessionError::NotRootSession(id) => {
270            ToolError::Execution(format!("session is not a root session: {id}"))
271        }
272        ChildSessionError::InvalidArguments(msg) => ToolError::InvalidArguments(msg),
273        ChildSessionError::Execution(msg) => ToolError::Execution(msg),
274        other => ToolError::Execution(other.to_string()),
275    }
276}
277
278// ---------------------------------------------------------------------------
279// Tool struct
280// ---------------------------------------------------------------------------
281
282pub struct SubAgentTool {
283    /// Child-session CRUD/lifecycle operations (load/save/run/cancel/…).
284    sessions: Arc<dyn ChildSessionPort>,
285    /// Subagent-type resolution (model, runtime metadata, active ids).
286    resolver: Arc<dyn SubagentResolutionPort>,
287    /// Optional model catalog consulted by `action=list_models` and used to
288    /// resolve a bare `create.model` id to a provider. `None` keeps the tool
289    /// constructible without a live provider registry (tests, embedded use).
290    catalog: Option<Arc<dyn ModelCatalogPort>>,
291}
292
293impl SubAgentTool {
294    pub fn new(
295        sessions: Arc<dyn ChildSessionPort>,
296        resolver: Arc<dyn SubagentResolutionPort>,
297    ) -> Self {
298        Self {
299            sessions,
300            resolver,
301            catalog: None,
302        }
303    }
304
305    /// Attach a model catalog, enabling `action=list_models` and bare-model
306    /// resolution for `create.model`.
307    pub fn with_model_catalog(mut self, catalog: Arc<dyn ModelCatalogPort>) -> Self {
308        self.catalog = Some(catalog);
309        self
310    }
311}
312
313/// Parse an explicit `create.model` spec into a `ProviderModelRef`.
314///
315/// `"provider:model"` is explicit; a bare model id falls back to the parent
316/// session's provider, then the catalog's default provider.
317fn parse_model_spec(
318    spec: &str,
319    parent: &bamboo_agent_core::Session,
320    default_provider: Option<String>,
321) -> Result<bamboo_domain::ProviderModelRef, ToolError> {
322    let spec = spec.trim();
323    if spec.is_empty() {
324        return Err(ToolError::InvalidArguments(
325            "model must be non-empty when provided".to_string(),
326        ));
327    }
328    if let Some((provider, model)) = spec.split_once(':') {
329        let (provider, model) = (provider.trim(), model.trim());
330        if provider.is_empty() || model.is_empty() {
331            return Err(ToolError::InvalidArguments(format!(
332                "model '{spec}' must be 'provider:model' with both parts non-empty"
333            )));
334        }
335        return Ok(bamboo_domain::ProviderModelRef::new(provider, model));
336    }
337    // Bare model id: inherit the parent's provider, else the default provider.
338    let provider = parent
339        .model_ref
340        .as_ref()
341        .map(|r| r.provider.clone())
342        .filter(|p| !p.trim().is_empty())
343        .or(default_provider)
344        .ok_or_else(|| {
345            ToolError::InvalidArguments(format!(
346                "model '{spec}' has no provider prefix and no default provider is known; \
347                 use 'provider:model' (see action=list_models)"
348            ))
349        })?;
350    Ok(bamboo_domain::ProviderModelRef::new(provider, spec))
351}
352
353/// Default max nesting depth for sub-agent spawning (Phase 6: direct nested
354/// execution). An agent at `spawn_depth >= this` may not create more children,
355/// bounding worker→worker→… recursion. Root orchestrator = depth 0, so this
356/// allows 4 levels of sub-agents below the root.
357pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
358
359/// The `SubAgent` tool description. Exposed standalone so a nested worker's
360/// SubAgent proxy can advertise the identical tool to its own LLM (no drift).
361pub fn subagent_tool_description() -> &'static str {
362    "Create, inspect, and manage child sessions for explicitly requested delegated, parallel, or sub-agent work. A child session is a full agent that runs independently under the current root session with its own conversation context and the full toolset, streams progress back to the parent via sub_agent_* events, and can be reopened from the Sub-agents panel. \
363PARALLEL FAN-OUT (important): action=create now runs the child in the BACKGROUND and returns immediately WITHOUT suspending the parent. To launch several agents in parallel, call create once per child (ideally several creates in a single turn), then call action=wait ONCE to suspend until they finish. Do NOT pass wait=true on each create for parallel work — that would serialize them (suspend after the first). action=wait defaults to waiting on every active child; if you forget to call it, the runtime auto-waits at the end of the turn so results are never lost. \
364Use list/get to inspect existing children; use update/run/send_message/cancel/delete to manage existing children. Use only when the user explicitly asks for delegation/parallelism or when a side task would otherwise flood the main context. Do not use for simple one-step tasks. IMPORTANT: When a child fails or needs redirection, prefer send_message over creating a duplicate child. Use list before create to avoid spawning redundant children."
365}
366
367/// The `SubAgent` parameters schema. Exposed standalone (mirroring
368/// [`subagent_tool_description`]) so a nested worker's SubAgent proxy advertises
369/// the IDENTICAL schema to its own LLM — no drift between the real tool and the
370/// proxy.
371pub fn subagent_parameters_schema() -> serde_json::Value {
372    json!({
373        "type": "object",
374        "properties": {
375            "action": {
376                "type": "string",
377                "enum": ["create", "wait", "list", "get", "update", "run", "send_message", "cancel", "delete", "list_models"],
378                "description": "Sub-agent lifecycle operation. To run work in parallel: call create once per child (this no longer suspends the parent — children run in the background), then call wait ONCE to suspend until they all finish. Use list/get to inspect; update/run/send_message/cancel/delete to manage existing children; list_models to enumerate the models you can pin a child to via create.model. \
379        A create call requires: title, responsibility, and prompt (workspace is optional and defaults to the parent's workspace). EXAMPLE create: {\"action\":\"create\",\"title\":\"Analyze auth module\",\"responsibility\":\"Map the auth flow and list its public API\",\"prompt\":\"Read crates/auth/src/lib.rs, summarize the login flow, and list every pub fn.\",\"workspace\":\"/abs/path/to/repo\"}. Then EXAMPLE wait: {\"action\":\"wait\"}."
380            },
381            "child_session_id": {
382                "type": "string",
383                "description": "Existing child session id. Required for get/update/run/send_message/cancel/delete."
384            },
385            "child_session_ids": {
386                "type": "array",
387                "items": { "type": "string" },
388                "description": "For wait: optional explicit subset of child sessions to wait on. Omit to wait on every currently-active child."
389            },
390            "wait_for": {
391                "type": "string",
392                "enum": ["all", "any", "first_error"],
393                "description": "For wait: resume policy. all (default) resumes when every tracked child is done; any resumes on the first; first_error resumes early on any error/timeout/cancel."
394            },
395            "wait": {
396                "type": "boolean",
397                "description": "For create: if true, suspend immediately and wait for just THIS child (legacy one-shot behavior). Defaults to false — create returns immediately and the child runs in the background; suspend later with action=wait."
398            },
399            "title": {
400                "type": "string",
401                "description": "Short title for a new or updated child session. Required for create. Displayed in the Sub-agents panel."
402            },
403            "description": {
404                "type": "string",
405                "description": "Legacy alias of title; prefer title."
406            },
407            "responsibility": {
408                "type": "string",
409                "description": "Single explicit responsibility for the child session. Required for create. Keep this narrow and non-overlapping with other child sessions."
410            },
411            "prompt": {
412                "type": "string",
413                "description": "Detailed task instructions, context, constraints, and expected output for the child session. Required for create; optional for update."
414            },
415            "subagent_type": {
416                "type": "string",
417                "description": "For create: an optional free-text label for this child (e.g. \"researcher\", \"impl\"), used only for display and as the warm-worker reuse key. Cosmetic — it does NOT change the child's tools or system prompt; every sub-agent is a full agent. Optional; omit it if you have no useful label."
418            },
419            "workspace": {
420                "type": "string",
421                "description": "For create: absolute path to the child session's working directory for file operations. Optional — defaults to the parent session's workspace when omitted."
422            },
423            "auto_run": {
424                "type": "boolean",
425                "description": "For create/send_message/update: whether to enqueue the child session immediately. Defaults to true for create/send_message and false for update."
426            },
427            "fork_last_messages": {
428                "type": "integer",
429                "minimum": 0,
430                "description": "For create: model-controllable context fork. When > 0, the last N messages of YOUR (the parent's) conversation are carried into the child's task brief as a 'Forked context from parent' block, so the child starts with the recent context it needs. Omit/0 (default) gives the child a clean, freshly-seeded context. Use a small N (e.g. 2-6) to share just the immediately relevant turns; omit it when the task brief is already self-contained."
431            },
432            "reset_after_update": {
433                "type": "boolean",
434                "description": "For update: whether to truncate messages after refreshed assignment. Defaults to true."
435            },
436            "reset_to_last_user": {
437                "type": "boolean",
438                "description": "For run: whether to truncate messages after the last user message before rerun. Defaults to true."
439            },
440            "message": {
441                "type": "string",
442                "description": "Follow-up instruction to append as a new user message for send_message. Required for send_message."
443            },
444            "interrupt_running": {
445                "type": "boolean",
446                "description": "For send_message/cancel: if true, cancel a currently running child session before appending or returning. Defaults to false for send_message. When false on a running child, the message is queued and will be picked up at the next turn boundary without canceling progress."
447            },
448            "reasoning_effort": {
449                "type": "string",
450                "enum": ["low", "medium", "high", "xhigh", "max"],
451                "description": "For create/update: reasoning effort level applied to the child session's own LLM calls. Use \"low\" for trivial fan-outs (e.g. simple lookups), \"medium\"/\"high\" for normal coding/analysis, \"xhigh\"/\"max\" for deep reasoning tasks. Omit to leave at provider default; the child does NOT inherit the parent's reasoning_effort."
452            },
453            "model": {
454                "type": "string",
455                "description": "For create: explicit model for the child as 'provider:model' (e.g. 'anthropic:claude-sonnet-4-6'), or a bare model id to use the parent's provider. Takes precedence over per-subagent_type model routing. Pick a cheaper/faster model for simple fan-outs and a stronger model for hard reasoning. Call list_models first to see what is available; omit to use the configured default for the given subagent_type label."
456            },
457            "lifecycle": {
458                "type": "string",
459                "enum": ["oneshot", "resident"],
460                "description": "For create: 'oneshot' (default) spins up a fresh throwaway child for this task. 'resident' reuses ONE long-lived agent (identified by 'name', scoped to this conversation) across many tasks — the first resident create spins it up, later creates with the same name route the new task to that same agent instead of spawning another. Use resident for recurring task types (e.g. an 'essayist' that writes many essays — one agent, one panel entry, not N); use oneshot for independent throwaway work."
461            },
462            "name": {
463                "type": "string",
464                "description": "For create with lifecycle=resident: the resident agent's stable reuse key, e.g. 'essayist'. Required to reuse a resident; defaults to subagent_type when omitted. Reusing the same name routes the new task to the existing resident agent."
465            },
466            "context": {
467                "type": "string",
468                "enum": ["reset", "accumulate"],
469                "description": "For create with lifecycle=resident: how the resident treats prior tasks. 'reset' (default) makes each task independent (clears prior context). 'accumulate' makes the agent remember earlier tasks (useful for a researcher building up knowledge). Set on first create; honored on reuse."
470            }
471        },
472        "required": ["action"],
473        "additionalProperties": false
474    })
475}
476
477#[async_trait]
478impl Tool for SubAgentTool {
479    fn name(&self) -> &str {
480        "SubAgent"
481    }
482
483    fn description(&self) -> &str {
484        subagent_tool_description()
485    }
486
487    fn parameters_schema(&self) -> serde_json::Value {
488        subagent_parameters_schema()
489    }
490
491    async fn invoke(
492        &self,
493        args: serde_json::Value,
494        ctx: ToolCtx,
495    ) -> Result<ToolOutcome, ToolError> {
496        let parent_session_id = ctx.session_id().ok_or_else(|| {
497            ToolError::Execution("SubAgent requires a session_id in tool context".to_string())
498        })?;
499
500        // Backward compatibility: legacy SubAgent calls did not include an
501        // "action" field and always meant "create". If action is missing,
502        // default to "create" before deserializing the tagged enum.
503        let mut args = args;
504        if args.get("action").is_none() {
505            args["action"] = json!("create");
506        }
507
508        let parsed: SubAgentArgs = serde_json::from_value(args).map_err(|error| {
509            ToolError::InvalidArguments(format!("Invalid SubAgent args: {error}"))
510        })?;
511
512        // `list_models` is read-only and session-independent.
513        if let SubAgentArgs::ListModels = parsed {
514            let Some(catalog) = self.catalog.as_ref() else {
515                return Err(ToolError::Execution(
516                    "model catalog is not configured on this server".to_string(),
517                ));
518            };
519            let providers = catalog.list_models().await;
520            return tool_result(json!({
521                "default_provider": catalog.default_provider(),
522                "providers": providers,
523                "usage": "Pass create.model as 'provider:model' (or a bare model id to use the parent's provider).",
524            }))
525            .map(ToolOutcome::Completed);
526        }
527
528        let parent = self
529            .sessions
530            .as_ref()
531            .load_root_session(parent_session_id)
532            .await
533            .map_err(tool_error_from_child_session)?;
534
535        match parsed {
536            SubAgentArgs::Create {
537                title,
538                description,
539                responsibility,
540                prompt,
541                subagent_type,
542                workspace,
543                auto_run,
544                wait,
545                reasoning_effort,
546                model,
547                lifecycle,
548                name,
549                context,
550                fork_last_messages,
551            } => {
552                // Phase 6: enforce the max nesting-depth cap. `parent` is this
553                // agent's run session; its `spawn_depth` is the current nesting
554                // level (workers stamp it from the actor spec, so it accumulates
555                // across the actor boundary). Refuse to spawn beyond the cap so
556                // worker→worker→… recursion is bounded.
557                if parent.spawn_depth >= DEFAULT_MAX_SPAWN_DEPTH {
558                    return Err(ToolError::InvalidArguments(format!(
559                        "spawn depth limit ({}) reached: this agent is at depth {} and cannot create more sub-agents. Finish the work here, or delegate to a sibling.",
560                        DEFAULT_MAX_SPAWN_DEPTH, parent.spawn_depth
561                    )));
562                }
563                let title = normalize_title(title, description)?;
564                let responsibility = normalize_required_text(responsibility, "responsibility")?;
565                let prompt = normalize_required_text(Some(prompt), "prompt")?;
566                // subagent_type is an optional cosmetic label only (display +
567                // warm-worker reuse key); it has no behavioral effect. An
568                // omitted/blank value falls back to the neutral "worker" label.
569                let subagent_type = subagent_type
570                    .map(|value| value.trim().to_string())
571                    .filter(|value| !value.is_empty())
572                    .unwrap_or_else(|| "worker".to_string());
573                // workspace is optional: default to the parent's workspace.
574                let workspace = workspace
575                    .map(|value| value.trim().to_string())
576                    .filter(|value| !value.is_empty())
577                    .or_else(|| parent.workspace.clone())
578                    .ok_or_else(|| {
579                        ToolError::InvalidArguments(
580                            "workspace must be non-empty (parent has no workspace to inherit)"
581                                .to_string(),
582                        )
583                    })?;
584
585                if parent.model.trim().is_empty() {
586                    return Err(ToolError::Execution(
587                        "parent session model is empty".to_string(),
588                    ));
589                }
590
591                let should_auto_run = auto_run.unwrap_or(true);
592
593                // Resident routing: `lifecycle="resident"` reuses the existing
594                // resident agent of the same `name` in this root tree (one stable
595                // agent/entry for recurring work) instead of minting a new child.
596                // `reset` (default) replaces the resident's task and reruns it;
597                // `accumulate` appends the task to its history. The FIRST resident
598                // create (none found yet) falls through to a normal create tagged
599                // as resident.
600                let is_resident = lifecycle.as_deref().map(str::trim) == Some("resident");
601                let resident_name = is_resident.then(|| {
602                    name.as_deref()
603                        .map(str::trim)
604                        .filter(|n| !n.is_empty())
605                        .map(str::to_string)
606                        .unwrap_or_else(|| subagent_type.clone())
607                });
608                let resident_context = context
609                    .as_deref()
610                    .map(str::trim)
611                    .filter(|c| matches!(*c, "reset" | "accumulate"))
612                    .unwrap_or("reset")
613                    .to_string();
614                let existing_resident = match resident_name.as_deref() {
615                    Some(rname) => {
616                        // Children are created with `root_session_id == parent.id`,
617                        // so the parent's id is the tree root key for the lookup.
618                        self.sessions.find_resident_child(&parent.id, rname).await
619                    }
620                    None => None,
621                };
622
623                let (child_session_id, child_model, reused) =
624                    if let Some(existing_id) = existing_resident {
625                        // A resident processes tasks serially. If it is still running
626                        // a previous task, stop it first: otherwise `reset` would
627                        // truncate the session while the runner writes back (a
628                        // corrupting race + a possible duplicate spawn job), and
629                        // `accumulate` would queue a message into a run that may end
630                        // before picking it up (the task would never execute). After
631                        // cancel the resident is idle, so both paths apply cleanly.
632                        if self.sessions.is_child_running(&existing_id).await {
633                            self.sessions
634                                .cancel_child_run_and_wait(&existing_id)
635                                .await
636                                .map_err(tool_error_from_child_session)?;
637                        }
638                        // #74: re-seed the reused resident's posture from the LIVE
639                        // parent. The resident-reuse path bypasses
640                        // `create_child_action` (which seeds `bypass_permissions` /
641                        // `no_human_approver` on the child's first run), so without
642                        // this a resident created under one posture keeps a stale
643                        // flag when reused under another (e.g. parent flipped from
644                        // headless to interactive, or toggled bypass). Mirror BOTH
645                        // flags so the reused resident matches the current parent.
646                        {
647                            let mut child = self
648                                .sessions
649                                .load_child_for_parent(&parent.id, &existing_id)
650                                .await
651                                .map_err(tool_error_from_child_session)?;
652                            let (parent_bypass, parent_no_human) = parent
653                                .agent_runtime_state
654                                .as_ref()
655                                .map(|s| (s.bypass_permissions, s.no_human_approver))
656                                .unwrap_or((false, false));
657                            let rs = child
658                                .agent_runtime_state
659                                .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
660                            rs.bypass_permissions = parent_bypass;
661                            rs.no_human_approver = parent_no_human;
662                            // Authoritative flag write: persist the freshly
663                            // mirrored posture as-is; the adopting save would
664                            // revert bypass to the child's stale disk value. #540/#74.
665                            self.sessions
666                                .save_child_session_authoritative_flags(&mut child)
667                                .await
668                                .map_err(tool_error_from_child_session)?;
669                        }
670                        // Reuse: reset => update (truncate + new task) then rerun;
671                        // accumulate => send the task as a new message (auto-runs).
672                        if resident_context == "accumulate" {
673                            child_session::send_message_to_child_action(
674                                self.sessions.as_ref(),
675                                &parent,
676                                existing_id.clone(),
677                                format!("# Task: {title}\n\n{responsibility}\n\n{prompt}"),
678                                Some(should_auto_run),
679                                Some(false),
680                            )
681                            .await
682                            .map_err(tool_error_from_child_session)?;
683                        } else {
684                            child_session::update_child_action(
685                                self.sessions.as_ref(),
686                                &parent.id,
687                                existing_id.clone(),
688                                Some(title.clone()),
689                                Some(responsibility.clone()),
690                                Some(prompt.clone()),
691                                Some(subagent_type.clone()),
692                                Some(true),
693                                reasoning_effort,
694                            )
695                            .await
696                            .map_err(tool_error_from_child_session)?;
697                            if should_auto_run {
698                                let child = self
699                                    .sessions
700                                    .load_child_for_parent(&parent.id, &existing_id)
701                                    .await
702                                    .map_err(tool_error_from_child_session)?;
703                                self.sessions
704                                    .enqueue_child_run(&parent, &child)
705                                    .await
706                                    .map_err(tool_error_from_child_session)?;
707                            }
708                        }
709                        let model = self
710                            .sessions
711                            .load_child_for_parent(&parent.id, &existing_id)
712                            .await
713                            .map(|c| c.model)
714                            .unwrap_or_default();
715                        (existing_id, model, true)
716                    } else {
717                        let child_id = Uuid::new_v4().to_string();
718                        // Model precedence: explicit `model` arg > per-subagent_type
719                        // routing (resolver) > engine defaults (None).
720                        let model_ref_override =
721                            match model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
722                                Some(spec) => Some(parse_model_spec(
723                                    spec,
724                                    &parent,
725                                    self.catalog.as_ref().map(|c| c.default_provider()),
726                                )?),
727                                None => self.resolver.resolve_subagent_model(&subagent_type).await,
728                            };
729                        let model_override = model_ref_override
730                            .as_ref()
731                            .map(|model_ref| model_ref.model.clone());
732                        let runtime_metadata =
733                            self.resolver.resolve_runtime_metadata(&subagent_type).await;
734                        let result = child_session::create_child_action(
735                            self.sessions.as_ref(),
736                            CreateChildInput {
737                                parent_session: parent.clone(),
738                                child_id: child_id.clone(),
739                                title: title.clone(),
740                                responsibility: responsibility.clone(),
741                                assignment_prompt: prompt.clone(),
742                                subagent_type: subagent_type.clone(),
743                                workspace: workspace.clone(),
744                                model_override,
745                                model_ref_override,
746                                runtime_metadata,
747                                auto_run: should_auto_run,
748                                reasoning_effort,
749                                lifecycle: resident_name.as_ref().map(|_| "resident".to_string()),
750                                resident_name: resident_name.clone(),
751                                resident_context: resident_name
752                                    .as_ref()
753                                    .map(|_| resident_context.clone()),
754                                disabled_tools: None,
755                                // Phase 3: model-controllable context fork — carry
756                                // the last N parent messages into the child's brief.
757                                context_fork: fork_last_messages.filter(|n| *n > 0),
758                            },
759                        )
760                        .await
761                        .map_err(tool_error_from_child_session)?;
762                        (result.child_session_id, result.model, false)
763                    };
764
765                // Ensure index entry is visible immediately (best-effort).
766                self.sessions.ensure_child_indexed(&child_session_id).await;
767
768                ctx.emit_tool_token(if reused {
769                    format!("Reused resident agent: {child_session_id}")
770                } else {
771                    format!("Spawned child session: {child_session_id}")
772                })
773                .await;
774
775                // `wait=true` preserves the legacy one-shot behavior: register a
776                // wait for THIS child and suspend now. Default (`wait=false`) runs
777                // the child in the background and returns immediately, so the
778                // parent can keep spawning; it suspends later via `action=wait`.
779                let should_wait = should_auto_run && wait.unwrap_or(false);
780                if should_wait {
781                    self.sessions
782                        .register_parent_wait_for_child(&parent.id, &child_session_id, None)
783                        .await
784                        .map_err(tool_error_from_child_session)?;
785                }
786
787                let status = if !should_auto_run {
788                    "created"
789                } else if should_wait {
790                    "queued"
791                } else {
792                    "running_in_background"
793                };
794                let note = if should_wait {
795                    "Child session queued (typically 30-120 seconds); the parent is suspended until it finishes. Use send_message (not create) to correct a child in place."
796                } else if should_auto_run {
797                    "Child session is running in the background (typically 30-120 seconds). Spawn any other children you need, then call action=wait once to suspend until they finish. Use send_message (not create) to correct a child in place."
798                } else {
799                    "Child session created (not started). Use action=run to start it. Use send_message (not create) to correct a child in place."
800                };
801                let payload = json!({
802                    "title": title.clone(),
803                    "description": title,
804                    "responsibility": responsibility,
805                    "prompt": prompt,
806                    "subagent_type": subagent_type,
807                    "child_session_id": child_session_id,
808                    "parent_session_id": parent_session_id,
809                    "model": child_model,
810                    "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
811                    "status": status,
812                    "lifecycle": resident_name.as_ref().map(|_| "resident"),
813                    "resident_name": resident_name.clone(),
814                    "reused": reused,
815                    "note": note,
816                });
817                if should_wait {
818                    waiting_for_children_tool_result(payload)
819                } else {
820                    tool_result(payload)
821                }
822            }
823            SubAgentArgs::Wait {
824                child_session_ids,
825                wait_for,
826            } => {
827                let policy = wait_for.unwrap_or(ChildWaitPolicy::All);
828                // Default to every currently-active child; honor an explicit
829                // subset when provided. Explicit ids the index POSITIVELY
830                // reports terminal are dropped (issue #546): a terminal child
831                // fires no further completion, so a wait registered over it
832                // previously suspended the parent forever. Unknown ids are
833                // KEPT (an index-less backend or a not-yet-indexed child must
834                // not be mistaken for finished); if such an id turns out to be
835                // bogus, the child-wait watchdog rescues the parent at runtime.
836                let (targets, dropped): (Vec<String>, Vec<(String, String)>) =
837                    match child_session_ids {
838                        Some(ids) if !ids.is_empty() => {
839                            let terminal =
840                                self.sessions.terminal_child_ids(&parent.id, &ids).await;
841                            partition_wait_targets(ids, &terminal)
842                        }
843                        _ => (
844                            self.sessions.active_child_ids(&parent.id).await,
845                            Vec::new(),
846                        ),
847                    };
848                let dropped_ids: Vec<String> =
849                    dropped.iter().map(|(id, _)| id.clone()).collect();
850
851                // Policy short-circuit (issue #546): if the already-terminal
852                // ids satisfy the policy on their own (`any` — any terminal;
853                // `first_error` — any error-like terminal), suspending on the
854                // remainder would sleep past an answer the model already has.
855                if wait_already_satisfied_by_dropped(policy, &dropped) {
856                    return tool_result(json!({
857                        "status": "already_satisfied",
858                        "parent_session_id": parent_session_id,
859                        "satisfied_by": dropped
860                            .iter()
861                            .map(|(id, status)| json!({ "child_session_id": id, "status": status }))
862                            .collect::<Vec<_>>(),
863                        "still_active_child_ids": targets,
864                        "wait_for": policy.as_str(),
865                        "note": "The wait policy is already satisfied by finished child \
866                                 session(s) — the parent was NOT suspended. Use SubAgent.get \
867                                 to read their results; call wait again (without those ids) \
868                                 if you still need the remaining children.",
869                    }))
870                    .map(ToolOutcome::Completed);
871                }
872
873                if targets.is_empty() {
874                    // Nothing left to wait on — never register an empty wait
875                    // (that would suspend the parent with no child able to
876                    // resume it). Any explicitly named children are already
877                    // terminal: tell the model to read their results instead
878                    // of suspending.
879                    let note = if dropped_ids.is_empty() {
880                        "No active child sessions to wait for; the parent continues running."
881                            .to_string()
882                    } else {
883                        format!(
884                            "The requested child session(s) [{}] are already finished; nothing \
885                             to wait for. Use SubAgent.get to read their results.",
886                            dropped_ids.join(", ")
887                        )
888                    };
889                    return tool_result(json!({
890                        "status": "no_active_children",
891                        "parent_session_id": parent_session_id,
892                        "already_terminal_child_ids": dropped_ids,
893                        "note": note,
894                    }))
895                    .map(ToolOutcome::Completed);
896                }
897
898                let count = self
899                    .sessions
900                    .register_parent_wait_for_children(&parent.id, &targets, policy)
901                    .await
902                    .map_err(tool_error_from_child_session)?;
903
904                waiting_for_children_tool_result(json!({
905                    "status": "waiting",
906                    "parent_session_id": parent_session_id,
907                    "child_session_ids": targets,
908                    "already_terminal_child_ids": dropped_ids,
909                    "wait_for": policy.as_str(),
910                    "waiting_on": count,
911                }))
912            }
913            SubAgentArgs::List => {
914                let result =
915                    child_session::list_children_action(self.sessions.as_ref(), &parent.id).await;
916                tool_result(result)
917            }
918            SubAgentArgs::Get { child_session_id } => {
919                let result = child_session::get_child_action(
920                    self.sessions.as_ref(),
921                    &parent.id,
922                    child_session_id,
923                )
924                .await
925                .map_err(tool_error_from_child_session)?;
926                tool_result(result)
927            }
928            SubAgentArgs::Update {
929                child_session_id,
930                title,
931                responsibility,
932                prompt,
933                subagent_type,
934                reset_after_update,
935                auto_run,
936                reasoning_effort,
937            } => {
938                let result = child_session::update_child_action(
939                    self.sessions.as_ref(),
940                    &parent.id,
941                    child_session_id.clone(),
942                    title,
943                    responsibility,
944                    prompt,
945                    subagent_type,
946                    reset_after_update,
947                    reasoning_effort,
948                )
949                .await
950                .map_err(tool_error_from_child_session)?;
951
952                let should_auto_run = auto_run.unwrap_or(false);
953                if should_auto_run {
954                    let child = self
955                        .sessions
956                        .load_child_for_parent(&parent.id, &child_session_id)
957                        .await
958                        .map_err(tool_error_from_child_session)?;
959                    self.sessions
960                        .enqueue_child_run(&parent, &child)
961                        .await
962                        .map_err(tool_error_from_child_session)?;
963                    // Re-running an existing child keeps its synchronous "wait for
964                    // the answer" semantics: register the wait + suspend. (enqueue
965                    // itself no longer registers — that is now explicit.)
966                    self.sessions
967                        .register_parent_wait_for_child(&parent.id, &child_session_id, None)
968                        .await
969                        .map_err(tool_error_from_child_session)?;
970                }
971
972                if should_auto_run {
973                    waiting_for_children_tool_result(result)
974                } else {
975                    tool_result(result)
976                }
977            }
978            SubAgentArgs::Run {
979                child_session_id,
980                reset_to_last_user,
981            } => {
982                let result = child_session::run_child_action(
983                    self.sessions.as_ref(),
984                    &parent,
985                    child_session_id.clone(),
986                    reset_to_last_user,
987                )
988                .await
989                .map_err(tool_error_from_child_session)?;
990                // `run` keeps the synchronous retry semantics: wait for this child.
991                self.sessions
992                    .register_parent_wait_for_child(&parent.id, &child_session_id, None)
993                    .await
994                    .map_err(tool_error_from_child_session)?;
995                waiting_for_children_tool_result(result)
996            }
997            SubAgentArgs::SendMessage {
998                child_session_id,
999                message,
1000                auto_run,
1001                interrupt_running,
1002            } => {
1003                let should_auto_run = auto_run.unwrap_or(true);
1004                let result = child_session::send_message_to_child_action(
1005                    self.sessions.as_ref(),
1006                    &parent,
1007                    child_session_id.clone(),
1008                    message,
1009                    auto_run,
1010                    interrupt_running,
1011                )
1012                .await
1013                .map_err(tool_error_from_child_session)?;
1014                let queued = should_auto_run
1015                    && result
1016                        .get("status")
1017                        .and_then(|value| value.as_str())
1018                        .is_some_and(|status| status == "queued");
1019                if queued {
1020                    // Sending + running keeps synchronous semantics: wait for the
1021                    // child's response. (enqueue no longer registers the wait.)
1022                    self.sessions
1023                        .register_parent_wait_for_child(&parent.id, &child_session_id, None)
1024                        .await
1025                        .map_err(tool_error_from_child_session)?;
1026                    waiting_for_children_tool_result(result)
1027                } else {
1028                    tool_result(result)
1029                }
1030            }
1031            SubAgentArgs::Cancel { child_session_id } => {
1032                let result = child_session::cancel_child_action(
1033                    self.sessions.as_ref(),
1034                    &parent.id,
1035                    child_session_id,
1036                )
1037                .await
1038                .map_err(tool_error_from_child_session)?;
1039                tool_result(result)
1040            }
1041            SubAgentArgs::Delete { child_session_id } => {
1042                let result = child_session::delete_child_action(
1043                    self.sessions.as_ref(),
1044                    &parent.id,
1045                    child_session_id,
1046                )
1047                .await
1048                .map_err(tool_error_from_child_session)?;
1049                tool_result(result)
1050            }
1051            // Handled by the session-independent short-circuit above.
1052            SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
1053        }
1054        .map(ToolOutcome::Completed)
1055    }
1056}
1057
1058// ---------------------------------------------------------------------------
1059// Tests
1060//
1061// Pure unit tests for the framework-agnostic helpers live here. Integration
1062// tests that wire `SubAgentTool` to a real `ChildSessionAdapter` live in
1063// `bamboo-server` (`tools/sub_agent_tests.rs`), where the adapter + AppState
1064// types are available.
1065// ---------------------------------------------------------------------------
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    #[test]
1072    fn partition_wait_targets_drops_only_known_terminal_ids() {
1073        let (targets, dropped) = partition_wait_targets(
1074            vec!["done".into(), "running".into(), "unknown".into()],
1075            &[("done".to_string(), "completed".to_string())],
1076        );
1077        assert_eq!(targets, vec!["running".to_string(), "unknown".to_string()]);
1078        assert_eq!(dropped, vec![("done".to_string(), "completed".to_string())]);
1079
1080        // Index-less backend: nothing reported terminal → nothing filtered.
1081        let (targets, dropped) = partition_wait_targets(vec!["a".into(), "b".into()], &[]);
1082        assert_eq!(targets, vec!["a".to_string(), "b".to_string()]);
1083        assert!(dropped.is_empty());
1084
1085        // Everything already finished → nothing left to wait on.
1086        let (targets, dropped) = partition_wait_targets(
1087            vec!["a".into(), "b".into()],
1088            &[
1089                ("a".to_string(), "completed".to_string()),
1090                ("b".to_string(), "error".to_string()),
1091            ],
1092        );
1093        assert!(targets.is_empty());
1094        assert_eq!(dropped.len(), 2);
1095    }
1096
1097    #[test]
1098    fn wait_short_circuits_when_dropped_ids_satisfy_the_policy() {
1099        let completed = [("a".to_string(), "completed".to_string())];
1100        let errored = [("a".to_string(), "timeout".to_string())];
1101
1102        // `all`: waiting on the remainder is equivalent — never short-circuit.
1103        assert!(!wait_already_satisfied_by_dropped(
1104            ChildWaitPolicy::All,
1105            &completed
1106        ));
1107        assert!(!wait_already_satisfied_by_dropped(
1108            ChildWaitPolicy::All,
1109            &errored
1110        ));
1111
1112        // `any`: ANY terminal child satisfies the wait before it is armed.
1113        assert!(wait_already_satisfied_by_dropped(
1114            ChildWaitPolicy::Any,
1115            &completed
1116        ));
1117        assert!(!wait_already_satisfied_by_dropped(
1118            ChildWaitPolicy::Any,
1119            &[]
1120        ));
1121
1122        // `first_error`: only an error-like terminal child short-circuits; a
1123        // completed one still waits on the remainder (all-complete fallback).
1124        assert!(wait_already_satisfied_by_dropped(
1125            ChildWaitPolicy::FirstError,
1126            &errored
1127        ));
1128        assert!(!wait_already_satisfied_by_dropped(
1129            ChildWaitPolicy::FirstError,
1130            &completed
1131        ));
1132    }
1133
1134    #[test]
1135    fn normalize_title_accepts_legacy_description() {
1136        let title = normalize_title(None, "Search refs".to_string()).unwrap();
1137        assert_eq!(title, "Search refs");
1138    }
1139
1140    #[test]
1141    fn normalize_title_prefers_title_over_description() {
1142        let title =
1143            normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
1144        assert_eq!(title, "Real title");
1145    }
1146
1147    #[test]
1148    fn normalize_title_rejects_both_empty() {
1149        let err = normalize_title(None, "".to_string()).unwrap_err();
1150        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
1151    }
1152
1153    // ---- parse_model_spec ----
1154
1155    fn parent_session(
1156        model_ref: Option<bamboo_domain::ProviderModelRef>,
1157    ) -> bamboo_agent_core::Session {
1158        let mut session = bamboo_agent_core::Session::new("p1", "gpt-test");
1159        session.model_ref = model_ref;
1160        session
1161    }
1162
1163    #[test]
1164    fn model_spec_provider_colon_model_is_explicit() {
1165        let parent = parent_session(None);
1166        let r = parse_model_spec("anthropic:claude-sonnet-4-6", &parent, None).unwrap();
1167        assert_eq!(r.provider, "anthropic");
1168        assert_eq!(r.model, "claude-sonnet-4-6");
1169    }
1170
1171    #[test]
1172    fn model_spec_bare_inherits_parent_provider() {
1173        let parent = parent_session(Some(bamboo_domain::ProviderModelRef::new(
1174            "openai", "gpt-test",
1175        )));
1176        let r = parse_model_spec("o4-mini", &parent, Some("anthropic".to_string())).unwrap();
1177        assert_eq!(r.provider, "openai"); // parent wins over default
1178        assert_eq!(r.model, "o4-mini");
1179    }
1180
1181    #[test]
1182    fn model_spec_bare_falls_back_to_default_provider() {
1183        let parent = parent_session(None);
1184        let r =
1185            parse_model_spec("claude-haiku-4-5", &parent, Some("anthropic".to_string())).unwrap();
1186        assert_eq!(r.provider, "anthropic");
1187    }
1188
1189    #[test]
1190    fn model_spec_bare_without_any_provider_errors() {
1191        let parent = parent_session(None);
1192        let err = parse_model_spec("mystery-model", &parent, None).unwrap_err();
1193        assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("provider")));
1194    }
1195
1196    #[test]
1197    fn model_spec_rejects_malformed() {
1198        let parent = parent_session(None);
1199        assert!(parse_model_spec("  ", &parent, None).is_err());
1200        assert!(parse_model_spec("anthropic:", &parent, None).is_err());
1201        assert!(parse_model_spec(":model", &parent, None).is_err());
1202    }
1203}