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#[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 #[serde(default)]
34 subagent_type: Option<String>,
35 #[serde(default)]
38 workspace: Option<String>,
39 #[serde(default)]
40 auto_run: Option<bool>,
41 #[serde(default)]
47 wait: Option<bool>,
48 #[serde(default)]
54 reasoning_effort: Option<ReasoningEffort>,
55 #[serde(default)]
61 model: Option<String>,
62 #[serde(default)]
71 lifecycle: Option<String>,
72 #[serde(default)]
76 name: Option<String>,
77 #[serde(default)]
82 context: Option<String>,
83 #[serde(default)]
87 fork_last_messages: Option<usize>,
88 },
89 Wait {
96 #[serde(default)]
97 child_session_ids: Option<Vec<String>>,
98 #[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 #[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 ListModels,
150}
151
152fn 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 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
222fn 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
246fn 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
265fn 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
278pub struct SubAgentTool {
283 sessions: Arc<dyn ChildSessionPort>,
285 resolver: Arc<dyn SubagentResolutionPort>,
287 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 pub fn with_model_catalog(mut self, catalog: Arc<dyn ModelCatalogPort>) -> Self {
308 self.catalog = Some(catalog);
309 self
310 }
311}
312
313fn 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 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
353pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
358
359pub 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
367pub 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 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 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 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 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 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 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 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 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 {
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 self.sessions
666 .save_child_session_authoritative_flags(&mut child)
667 .await
668 .map_err(tool_error_from_child_session)?;
669 }
670 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 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 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 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 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 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 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 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 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 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 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 SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
1053 }
1054 .map(ToolOutcome::Completed)
1055 }
1056}
1057
1058#[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 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 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 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 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 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 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"); 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}