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, ToolError, ToolExecutionContext, 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 tool_error_from_child_session(error: ChildSessionError) -> ToolError {
224 match error {
225 ChildSessionError::NotFound(id) => ToolError::Execution(format!("session not found: {id}")),
226 ChildSessionError::NotRootSession(id) => {
227 ToolError::Execution(format!("session is not a root session: {id}"))
228 }
229 ChildSessionError::InvalidArguments(msg) => ToolError::InvalidArguments(msg),
230 ChildSessionError::Execution(msg) => ToolError::Execution(msg),
231 other => ToolError::Execution(other.to_string()),
232 }
233}
234
235pub struct SubAgentTool {
240 sessions: Arc<dyn ChildSessionPort>,
242 resolver: Arc<dyn SubagentResolutionPort>,
244 catalog: Option<Arc<dyn ModelCatalogPort>>,
248}
249
250impl SubAgentTool {
251 pub fn new(
252 sessions: Arc<dyn ChildSessionPort>,
253 resolver: Arc<dyn SubagentResolutionPort>,
254 ) -> Self {
255 Self {
256 sessions,
257 resolver,
258 catalog: None,
259 }
260 }
261
262 pub fn with_model_catalog(mut self, catalog: Arc<dyn ModelCatalogPort>) -> Self {
265 self.catalog = Some(catalog);
266 self
267 }
268}
269
270fn parse_model_spec(
275 spec: &str,
276 parent: &bamboo_agent_core::Session,
277 default_provider: Option<String>,
278) -> Result<bamboo_domain::ProviderModelRef, ToolError> {
279 let spec = spec.trim();
280 if spec.is_empty() {
281 return Err(ToolError::InvalidArguments(
282 "model must be non-empty when provided".to_string(),
283 ));
284 }
285 if let Some((provider, model)) = spec.split_once(':') {
286 let (provider, model) = (provider.trim(), model.trim());
287 if provider.is_empty() || model.is_empty() {
288 return Err(ToolError::InvalidArguments(format!(
289 "model '{spec}' must be 'provider:model' with both parts non-empty"
290 )));
291 }
292 return Ok(bamboo_domain::ProviderModelRef::new(provider, model));
293 }
294 let provider = parent
296 .model_ref
297 .as_ref()
298 .map(|r| r.provider.clone())
299 .filter(|p| !p.trim().is_empty())
300 .or(default_provider)
301 .ok_or_else(|| {
302 ToolError::InvalidArguments(format!(
303 "model '{spec}' has no provider prefix and no default provider is known; \
304 use 'provider:model' (see action=list_models)"
305 ))
306 })?;
307 Ok(bamboo_domain::ProviderModelRef::new(provider, spec))
308}
309
310pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
315
316pub fn subagent_tool_description() -> &'static str {
319 "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. \
320PARALLEL 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. \
321Use 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."
322}
323
324pub fn subagent_parameters_schema() -> serde_json::Value {
329 json!({
330 "type": "object",
331 "properties": {
332 "action": {
333 "type": "string",
334 "enum": ["create", "wait", "list", "get", "update", "run", "send_message", "cancel", "delete", "list_models"],
335 "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. \
336 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\"}."
337 },
338 "child_session_id": {
339 "type": "string",
340 "description": "Existing child session id. Required for get/update/run/send_message/cancel/delete."
341 },
342 "child_session_ids": {
343 "type": "array",
344 "items": { "type": "string" },
345 "description": "For wait: optional explicit subset of child sessions to wait on. Omit to wait on every currently-active child."
346 },
347 "wait_for": {
348 "type": "string",
349 "enum": ["all", "any", "first_error"],
350 "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."
351 },
352 "wait": {
353 "type": "boolean",
354 "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."
355 },
356 "title": {
357 "type": "string",
358 "description": "Short title for a new or updated child session. Required for create. Displayed in the Sub-agents panel."
359 },
360 "description": {
361 "type": "string",
362 "description": "Legacy alias of title; prefer title."
363 },
364 "responsibility": {
365 "type": "string",
366 "description": "Single explicit responsibility for the child session. Required for create. Keep this narrow and non-overlapping with other child sessions."
367 },
368 "prompt": {
369 "type": "string",
370 "description": "Detailed task instructions, context, constraints, and expected output for the child session. Required for create; optional for update."
371 },
372 "subagent_type": {
373 "type": "string",
374 "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."
375 },
376 "workspace": {
377 "type": "string",
378 "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."
379 },
380 "auto_run": {
381 "type": "boolean",
382 "description": "For create/send_message/update: whether to enqueue the child session immediately. Defaults to true for create/send_message and false for update."
383 },
384 "fork_last_messages": {
385 "type": "integer",
386 "minimum": 0,
387 "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."
388 },
389 "reset_after_update": {
390 "type": "boolean",
391 "description": "For update: whether to truncate messages after refreshed assignment. Defaults to true."
392 },
393 "reset_to_last_user": {
394 "type": "boolean",
395 "description": "For run: whether to truncate messages after the last user message before rerun. Defaults to true."
396 },
397 "message": {
398 "type": "string",
399 "description": "Follow-up instruction to append as a new user message for send_message. Required for send_message."
400 },
401 "interrupt_running": {
402 "type": "boolean",
403 "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."
404 },
405 "reasoning_effort": {
406 "type": "string",
407 "enum": ["low", "medium", "high", "xhigh", "max"],
408 "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."
409 },
410 "model": {
411 "type": "string",
412 "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."
413 },
414 "lifecycle": {
415 "type": "string",
416 "enum": ["oneshot", "resident"],
417 "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."
418 },
419 "name": {
420 "type": "string",
421 "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."
422 },
423 "context": {
424 "type": "string",
425 "enum": ["reset", "accumulate"],
426 "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."
427 }
428 },
429 "required": ["action"],
430 "additionalProperties": false
431 })
432}
433
434#[async_trait]
435impl Tool for SubAgentTool {
436 fn name(&self) -> &str {
437 "SubAgent"
438 }
439
440 fn description(&self) -> &str {
441 subagent_tool_description()
442 }
443
444 fn parameters_schema(&self) -> serde_json::Value {
445 subagent_parameters_schema()
446 }
447
448 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
449 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
450 .await
451 }
452
453 async fn execute_with_context(
454 &self,
455 args: serde_json::Value,
456 ctx: ToolExecutionContext<'_>,
457 ) -> Result<ToolResult, ToolError> {
458 let parent_session_id = ctx.session_id.ok_or_else(|| {
459 ToolError::Execution("SubAgent requires a session_id in tool context".to_string())
460 })?;
461
462 let mut args = args;
466 if args.get("action").is_none() {
467 args["action"] = json!("create");
468 }
469
470 let parsed: SubAgentArgs = serde_json::from_value(args).map_err(|error| {
471 ToolError::InvalidArguments(format!("Invalid SubAgent args: {error}"))
472 })?;
473
474 if let SubAgentArgs::ListModels = parsed {
476 let Some(catalog) = self.catalog.as_ref() else {
477 return Err(ToolError::Execution(
478 "model catalog is not configured on this server".to_string(),
479 ));
480 };
481 let providers = catalog.list_models().await;
482 return tool_result(json!({
483 "default_provider": catalog.default_provider(),
484 "providers": providers,
485 "usage": "Pass create.model as 'provider:model' (or a bare model id to use the parent's provider).",
486 }));
487 }
488
489 let parent = self
490 .sessions
491 .as_ref()
492 .load_root_session(parent_session_id)
493 .await
494 .map_err(tool_error_from_child_session)?;
495
496 match parsed {
497 SubAgentArgs::Create {
498 title,
499 description,
500 responsibility,
501 prompt,
502 subagent_type,
503 workspace,
504 auto_run,
505 wait,
506 reasoning_effort,
507 model,
508 lifecycle,
509 name,
510 context,
511 fork_last_messages,
512 } => {
513 if parent.spawn_depth >= DEFAULT_MAX_SPAWN_DEPTH {
519 return Err(ToolError::InvalidArguments(format!(
520 "spawn depth limit ({}) reached: this agent is at depth {} and cannot create more sub-agents. Finish the work here, or delegate to a sibling.",
521 DEFAULT_MAX_SPAWN_DEPTH, parent.spawn_depth
522 )));
523 }
524 let title = normalize_title(title, description)?;
525 let responsibility = normalize_required_text(responsibility, "responsibility")?;
526 let prompt = normalize_required_text(Some(prompt), "prompt")?;
527 let subagent_type = subagent_type
531 .map(|value| value.trim().to_string())
532 .filter(|value| !value.is_empty())
533 .unwrap_or_else(|| "worker".to_string());
534 let workspace = workspace
536 .map(|value| value.trim().to_string())
537 .filter(|value| !value.is_empty())
538 .or_else(|| parent.workspace.clone())
539 .ok_or_else(|| {
540 ToolError::InvalidArguments(
541 "workspace must be non-empty (parent has no workspace to inherit)"
542 .to_string(),
543 )
544 })?;
545
546 if parent.model.trim().is_empty() {
547 return Err(ToolError::Execution(
548 "parent session model is empty".to_string(),
549 ));
550 }
551
552 let should_auto_run = auto_run.unwrap_or(true);
553
554 let is_resident = lifecycle.as_deref().map(str::trim) == Some("resident");
562 let resident_name = is_resident.then(|| {
563 name.as_deref()
564 .map(str::trim)
565 .filter(|n| !n.is_empty())
566 .map(str::to_string)
567 .unwrap_or_else(|| subagent_type.clone())
568 });
569 let resident_context = context
570 .as_deref()
571 .map(str::trim)
572 .filter(|c| matches!(*c, "reset" | "accumulate"))
573 .unwrap_or("reset")
574 .to_string();
575 let existing_resident = match resident_name.as_deref() {
576 Some(rname) => {
577 self.sessions.find_resident_child(&parent.id, rname).await
580 }
581 None => None,
582 };
583
584 let (child_session_id, child_model, reused) =
585 if let Some(existing_id) = existing_resident {
586 if self.sessions.is_child_running(&existing_id).await {
594 self.sessions
595 .cancel_child_run_and_wait(&existing_id)
596 .await
597 .map_err(tool_error_from_child_session)?;
598 }
599 {
608 let mut child = self
609 .sessions
610 .load_child_for_parent(&parent.id, &existing_id)
611 .await
612 .map_err(tool_error_from_child_session)?;
613 let (parent_bypass, parent_no_human) = parent
614 .agent_runtime_state
615 .as_ref()
616 .map(|s| (s.bypass_permissions, s.no_human_approver))
617 .unwrap_or((false, false));
618 let rs = child
619 .agent_runtime_state
620 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
621 rs.bypass_permissions = parent_bypass;
622 rs.no_human_approver = parent_no_human;
623 self.sessions
624 .save_child_session(&mut child)
625 .await
626 .map_err(tool_error_from_child_session)?;
627 }
628 if resident_context == "accumulate" {
631 child_session::send_message_to_child_action(
632 self.sessions.as_ref(),
633 &parent,
634 existing_id.clone(),
635 format!("# Task: {title}\n\n{responsibility}\n\n{prompt}"),
636 Some(should_auto_run),
637 Some(false),
638 )
639 .await
640 .map_err(tool_error_from_child_session)?;
641 } else {
642 child_session::update_child_action(
643 self.sessions.as_ref(),
644 &parent.id,
645 existing_id.clone(),
646 Some(title.clone()),
647 Some(responsibility.clone()),
648 Some(prompt.clone()),
649 Some(subagent_type.clone()),
650 Some(true),
651 reasoning_effort,
652 )
653 .await
654 .map_err(tool_error_from_child_session)?;
655 if should_auto_run {
656 let child = self
657 .sessions
658 .load_child_for_parent(&parent.id, &existing_id)
659 .await
660 .map_err(tool_error_from_child_session)?;
661 self.sessions
662 .enqueue_child_run(&parent, &child)
663 .await
664 .map_err(tool_error_from_child_session)?;
665 }
666 }
667 let model = self
668 .sessions
669 .load_child_for_parent(&parent.id, &existing_id)
670 .await
671 .map(|c| c.model)
672 .unwrap_or_default();
673 (existing_id, model, true)
674 } else {
675 let child_id = Uuid::new_v4().to_string();
676 let model_ref_override =
679 match model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
680 Some(spec) => Some(parse_model_spec(
681 spec,
682 &parent,
683 self.catalog.as_ref().map(|c| c.default_provider()),
684 )?),
685 None => self.resolver.resolve_subagent_model(&subagent_type).await,
686 };
687 let model_override = model_ref_override
688 .as_ref()
689 .map(|model_ref| model_ref.model.clone());
690 let runtime_metadata =
691 self.resolver.resolve_runtime_metadata(&subagent_type).await;
692 let result = child_session::create_child_action(
693 self.sessions.as_ref(),
694 CreateChildInput {
695 parent_session: parent.clone(),
696 child_id: child_id.clone(),
697 title: title.clone(),
698 responsibility: responsibility.clone(),
699 assignment_prompt: prompt.clone(),
700 subagent_type: subagent_type.clone(),
701 workspace: workspace.clone(),
702 model_override,
703 model_ref_override,
704 runtime_metadata,
705 auto_run: should_auto_run,
706 reasoning_effort,
707 lifecycle: resident_name.as_ref().map(|_| "resident".to_string()),
708 resident_name: resident_name.clone(),
709 resident_context: resident_name
710 .as_ref()
711 .map(|_| resident_context.clone()),
712 disabled_tools: None,
713 context_fork: fork_last_messages.filter(|n| *n > 0),
716 },
717 )
718 .await
719 .map_err(tool_error_from_child_session)?;
720 (result.child_session_id, result.model, false)
721 };
722
723 self.sessions.ensure_child_indexed(&child_session_id).await;
725
726 ctx.emit_tool_token(if reused {
727 format!("Reused resident agent: {child_session_id}")
728 } else {
729 format!("Spawned child session: {child_session_id}")
730 })
731 .await;
732
733 let should_wait = should_auto_run && wait.unwrap_or(false);
738 if should_wait {
739 self.sessions
740 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
741 .await
742 .map_err(tool_error_from_child_session)?;
743 }
744
745 let status = if !should_auto_run {
746 "created"
747 } else if should_wait {
748 "queued"
749 } else {
750 "running_in_background"
751 };
752 let note = if should_wait {
753 "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."
754 } else if should_auto_run {
755 "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."
756 } else {
757 "Child session created (not started). Use action=run to start it. Use send_message (not create) to correct a child in place."
758 };
759 let payload = json!({
760 "title": title.clone(),
761 "description": title,
762 "responsibility": responsibility,
763 "prompt": prompt,
764 "subagent_type": subagent_type,
765 "child_session_id": child_session_id,
766 "parent_session_id": parent_session_id,
767 "model": child_model,
768 "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
769 "status": status,
770 "lifecycle": resident_name.as_ref().map(|_| "resident"),
771 "resident_name": resident_name.clone(),
772 "reused": reused,
773 "note": note,
774 });
775 if should_wait {
776 waiting_for_children_tool_result(payload)
777 } else {
778 tool_result(payload)
779 }
780 }
781 SubAgentArgs::Wait {
782 child_session_ids,
783 wait_for,
784 } => {
785 let policy = wait_for.unwrap_or(ChildWaitPolicy::All);
786 let targets = match child_session_ids {
789 Some(ids) if !ids.is_empty() => ids,
790 _ => self.sessions.active_child_ids(&parent.id).await,
791 };
792
793 if targets.is_empty() {
794 return tool_result(json!({
797 "status": "no_active_children",
798 "parent_session_id": parent_session_id,
799 "note": "No active child sessions to wait for; the parent continues running.",
800 }));
801 }
802
803 let count = self
804 .sessions
805 .register_parent_wait_for_children(&parent.id, &targets, policy)
806 .await
807 .map_err(tool_error_from_child_session)?;
808
809 waiting_for_children_tool_result(json!({
810 "status": "waiting",
811 "parent_session_id": parent_session_id,
812 "child_session_ids": targets,
813 "wait_for": policy.as_str(),
814 "waiting_on": count,
815 }))
816 }
817 SubAgentArgs::List => {
818 let result =
819 child_session::list_children_action(self.sessions.as_ref(), &parent.id).await;
820 tool_result(result)
821 }
822 SubAgentArgs::Get { child_session_id } => {
823 let result = child_session::get_child_action(
824 self.sessions.as_ref(),
825 &parent.id,
826 child_session_id,
827 )
828 .await
829 .map_err(tool_error_from_child_session)?;
830 tool_result(result)
831 }
832 SubAgentArgs::Update {
833 child_session_id,
834 title,
835 responsibility,
836 prompt,
837 subagent_type,
838 reset_after_update,
839 auto_run,
840 reasoning_effort,
841 } => {
842 let result = child_session::update_child_action(
843 self.sessions.as_ref(),
844 &parent.id,
845 child_session_id.clone(),
846 title,
847 responsibility,
848 prompt,
849 subagent_type,
850 reset_after_update,
851 reasoning_effort,
852 )
853 .await
854 .map_err(tool_error_from_child_session)?;
855
856 let should_auto_run = auto_run.unwrap_or(false);
857 if should_auto_run {
858 let child = self
859 .sessions
860 .load_child_for_parent(&parent.id, &child_session_id)
861 .await
862 .map_err(tool_error_from_child_session)?;
863 self.sessions
864 .enqueue_child_run(&parent, &child)
865 .await
866 .map_err(tool_error_from_child_session)?;
867 self.sessions
871 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
872 .await
873 .map_err(tool_error_from_child_session)?;
874 }
875
876 if should_auto_run {
877 waiting_for_children_tool_result(result)
878 } else {
879 tool_result(result)
880 }
881 }
882 SubAgentArgs::Run {
883 child_session_id,
884 reset_to_last_user,
885 } => {
886 let result = child_session::run_child_action(
887 self.sessions.as_ref(),
888 &parent,
889 child_session_id.clone(),
890 reset_to_last_user,
891 )
892 .await
893 .map_err(tool_error_from_child_session)?;
894 self.sessions
896 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
897 .await
898 .map_err(tool_error_from_child_session)?;
899 waiting_for_children_tool_result(result)
900 }
901 SubAgentArgs::SendMessage {
902 child_session_id,
903 message,
904 auto_run,
905 interrupt_running,
906 } => {
907 let should_auto_run = auto_run.unwrap_or(true);
908 let result = child_session::send_message_to_child_action(
909 self.sessions.as_ref(),
910 &parent,
911 child_session_id.clone(),
912 message,
913 auto_run,
914 interrupt_running,
915 )
916 .await
917 .map_err(tool_error_from_child_session)?;
918 let queued = should_auto_run
919 && result
920 .get("status")
921 .and_then(|value| value.as_str())
922 .is_some_and(|status| status == "queued");
923 if queued {
924 self.sessions
927 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
928 .await
929 .map_err(tool_error_from_child_session)?;
930 waiting_for_children_tool_result(result)
931 } else {
932 tool_result(result)
933 }
934 }
935 SubAgentArgs::Cancel { child_session_id } => {
936 let result = child_session::cancel_child_action(
937 self.sessions.as_ref(),
938 &parent.id,
939 child_session_id,
940 )
941 .await
942 .map_err(tool_error_from_child_session)?;
943 tool_result(result)
944 }
945 SubAgentArgs::Delete { child_session_id } => {
946 let result = child_session::delete_child_action(
947 self.sessions.as_ref(),
948 &parent.id,
949 child_session_id,
950 )
951 .await
952 .map_err(tool_error_from_child_session)?;
953 tool_result(result)
954 }
955 SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
957 }
958 }
959}
960
961#[cfg(test)]
971mod tests {
972 use super::*;
973
974 #[test]
975 fn normalize_title_accepts_legacy_description() {
976 let title = normalize_title(None, "Search refs".to_string()).unwrap();
977 assert_eq!(title, "Search refs");
978 }
979
980 #[test]
981 fn normalize_title_prefers_title_over_description() {
982 let title =
983 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
984 assert_eq!(title, "Real title");
985 }
986
987 #[test]
988 fn normalize_title_rejects_both_empty() {
989 let err = normalize_title(None, "".to_string()).unwrap_err();
990 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
991 }
992
993 fn parent_session(
996 model_ref: Option<bamboo_domain::ProviderModelRef>,
997 ) -> bamboo_agent_core::Session {
998 let mut session = bamboo_agent_core::Session::new("p1", "gpt-test");
999 session.model_ref = model_ref;
1000 session
1001 }
1002
1003 #[test]
1004 fn model_spec_provider_colon_model_is_explicit() {
1005 let parent = parent_session(None);
1006 let r = parse_model_spec("anthropic:claude-sonnet-4-6", &parent, None).unwrap();
1007 assert_eq!(r.provider, "anthropic");
1008 assert_eq!(r.model, "claude-sonnet-4-6");
1009 }
1010
1011 #[test]
1012 fn model_spec_bare_inherits_parent_provider() {
1013 let parent = parent_session(Some(bamboo_domain::ProviderModelRef::new(
1014 "openai", "gpt-test",
1015 )));
1016 let r = parse_model_spec("o4-mini", &parent, Some("anthropic".to_string())).unwrap();
1017 assert_eq!(r.provider, "openai"); assert_eq!(r.model, "o4-mini");
1019 }
1020
1021 #[test]
1022 fn model_spec_bare_falls_back_to_default_provider() {
1023 let parent = parent_session(None);
1024 let r =
1025 parse_model_spec("claude-haiku-4-5", &parent, Some("anthropic".to_string())).unwrap();
1026 assert_eq!(r.provider, "anthropic");
1027 }
1028
1029 #[test]
1030 fn model_spec_bare_without_any_provider_errors() {
1031 let parent = parent_session(None);
1032 let err = parse_model_spec("mystery-model", &parent, None).unwrap_err();
1033 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("provider")));
1034 }
1035
1036 #[test]
1037 fn model_spec_rejects_malformed() {
1038 let parent = parent_session(None);
1039 assert!(parse_model_spec(" ", &parent, None).is_err());
1040 assert!(parse_model_spec("anthropic:", &parent, None).is_err());
1041 assert!(parse_model_spec(":model", &parent, None).is_err());
1042 }
1043}