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 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 invoke(
449 &self,
450 args: serde_json::Value,
451 ctx: ToolCtx,
452 ) -> Result<ToolOutcome, ToolError> {
453 let parent_session_id = ctx.session_id().ok_or_else(|| {
454 ToolError::Execution("SubAgent requires a session_id in tool context".to_string())
455 })?;
456
457 let mut args = args;
461 if args.get("action").is_none() {
462 args["action"] = json!("create");
463 }
464
465 let parsed: SubAgentArgs = serde_json::from_value(args).map_err(|error| {
466 ToolError::InvalidArguments(format!("Invalid SubAgent args: {error}"))
467 })?;
468
469 if let SubAgentArgs::ListModels = parsed {
471 let Some(catalog) = self.catalog.as_ref() else {
472 return Err(ToolError::Execution(
473 "model catalog is not configured on this server".to_string(),
474 ));
475 };
476 let providers = catalog.list_models().await;
477 return tool_result(json!({
478 "default_provider": catalog.default_provider(),
479 "providers": providers,
480 "usage": "Pass create.model as 'provider:model' (or a bare model id to use the parent's provider).",
481 }))
482 .map(ToolOutcome::Completed);
483 }
484
485 let parent = self
486 .sessions
487 .as_ref()
488 .load_root_session(parent_session_id)
489 .await
490 .map_err(tool_error_from_child_session)?;
491
492 match parsed {
493 SubAgentArgs::Create {
494 title,
495 description,
496 responsibility,
497 prompt,
498 subagent_type,
499 workspace,
500 auto_run,
501 wait,
502 reasoning_effort,
503 model,
504 lifecycle,
505 name,
506 context,
507 fork_last_messages,
508 } => {
509 if parent.spawn_depth >= DEFAULT_MAX_SPAWN_DEPTH {
515 return Err(ToolError::InvalidArguments(format!(
516 "spawn depth limit ({}) reached: this agent is at depth {} and cannot create more sub-agents. Finish the work here, or delegate to a sibling.",
517 DEFAULT_MAX_SPAWN_DEPTH, parent.spawn_depth
518 )));
519 }
520 let title = normalize_title(title, description)?;
521 let responsibility = normalize_required_text(responsibility, "responsibility")?;
522 let prompt = normalize_required_text(Some(prompt), "prompt")?;
523 let subagent_type = subagent_type
527 .map(|value| value.trim().to_string())
528 .filter(|value| !value.is_empty())
529 .unwrap_or_else(|| "worker".to_string());
530 let workspace = workspace
532 .map(|value| value.trim().to_string())
533 .filter(|value| !value.is_empty())
534 .or_else(|| parent.workspace.clone())
535 .ok_or_else(|| {
536 ToolError::InvalidArguments(
537 "workspace must be non-empty (parent has no workspace to inherit)"
538 .to_string(),
539 )
540 })?;
541
542 if parent.model.trim().is_empty() {
543 return Err(ToolError::Execution(
544 "parent session model is empty".to_string(),
545 ));
546 }
547
548 let should_auto_run = auto_run.unwrap_or(true);
549
550 let is_resident = lifecycle.as_deref().map(str::trim) == Some("resident");
558 let resident_name = is_resident.then(|| {
559 name.as_deref()
560 .map(str::trim)
561 .filter(|n| !n.is_empty())
562 .map(str::to_string)
563 .unwrap_or_else(|| subagent_type.clone())
564 });
565 let resident_context = context
566 .as_deref()
567 .map(str::trim)
568 .filter(|c| matches!(*c, "reset" | "accumulate"))
569 .unwrap_or("reset")
570 .to_string();
571 let existing_resident = match resident_name.as_deref() {
572 Some(rname) => {
573 self.sessions.find_resident_child(&parent.id, rname).await
576 }
577 None => None,
578 };
579
580 let (child_session_id, child_model, reused) =
581 if let Some(existing_id) = existing_resident {
582 if self.sessions.is_child_running(&existing_id).await {
590 self.sessions
591 .cancel_child_run_and_wait(&existing_id)
592 .await
593 .map_err(tool_error_from_child_session)?;
594 }
595 {
604 let mut child = self
605 .sessions
606 .load_child_for_parent(&parent.id, &existing_id)
607 .await
608 .map_err(tool_error_from_child_session)?;
609 let (parent_bypass, parent_no_human) = parent
610 .agent_runtime_state
611 .as_ref()
612 .map(|s| (s.bypass_permissions, s.no_human_approver))
613 .unwrap_or((false, false));
614 let rs = child
615 .agent_runtime_state
616 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
617 rs.bypass_permissions = parent_bypass;
618 rs.no_human_approver = parent_no_human;
619 self.sessions
620 .save_child_session(&mut child)
621 .await
622 .map_err(tool_error_from_child_session)?;
623 }
624 if resident_context == "accumulate" {
627 child_session::send_message_to_child_action(
628 self.sessions.as_ref(),
629 &parent,
630 existing_id.clone(),
631 format!("# Task: {title}\n\n{responsibility}\n\n{prompt}"),
632 Some(should_auto_run),
633 Some(false),
634 )
635 .await
636 .map_err(tool_error_from_child_session)?;
637 } else {
638 child_session::update_child_action(
639 self.sessions.as_ref(),
640 &parent.id,
641 existing_id.clone(),
642 Some(title.clone()),
643 Some(responsibility.clone()),
644 Some(prompt.clone()),
645 Some(subagent_type.clone()),
646 Some(true),
647 reasoning_effort,
648 )
649 .await
650 .map_err(tool_error_from_child_session)?;
651 if should_auto_run {
652 let child = self
653 .sessions
654 .load_child_for_parent(&parent.id, &existing_id)
655 .await
656 .map_err(tool_error_from_child_session)?;
657 self.sessions
658 .enqueue_child_run(&parent, &child)
659 .await
660 .map_err(tool_error_from_child_session)?;
661 }
662 }
663 let model = self
664 .sessions
665 .load_child_for_parent(&parent.id, &existing_id)
666 .await
667 .map(|c| c.model)
668 .unwrap_or_default();
669 (existing_id, model, true)
670 } else {
671 let child_id = Uuid::new_v4().to_string();
672 let model_ref_override =
675 match model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
676 Some(spec) => Some(parse_model_spec(
677 spec,
678 &parent,
679 self.catalog.as_ref().map(|c| c.default_provider()),
680 )?),
681 None => self.resolver.resolve_subagent_model(&subagent_type).await,
682 };
683 let model_override = model_ref_override
684 .as_ref()
685 .map(|model_ref| model_ref.model.clone());
686 let runtime_metadata =
687 self.resolver.resolve_runtime_metadata(&subagent_type).await;
688 let result = child_session::create_child_action(
689 self.sessions.as_ref(),
690 CreateChildInput {
691 parent_session: parent.clone(),
692 child_id: child_id.clone(),
693 title: title.clone(),
694 responsibility: responsibility.clone(),
695 assignment_prompt: prompt.clone(),
696 subagent_type: subagent_type.clone(),
697 workspace: workspace.clone(),
698 model_override,
699 model_ref_override,
700 runtime_metadata,
701 auto_run: should_auto_run,
702 reasoning_effort,
703 lifecycle: resident_name.as_ref().map(|_| "resident".to_string()),
704 resident_name: resident_name.clone(),
705 resident_context: resident_name
706 .as_ref()
707 .map(|_| resident_context.clone()),
708 disabled_tools: None,
709 context_fork: fork_last_messages.filter(|n| *n > 0),
712 },
713 )
714 .await
715 .map_err(tool_error_from_child_session)?;
716 (result.child_session_id, result.model, false)
717 };
718
719 self.sessions.ensure_child_indexed(&child_session_id).await;
721
722 ctx.emit_tool_token(if reused {
723 format!("Reused resident agent: {child_session_id}")
724 } else {
725 format!("Spawned child session: {child_session_id}")
726 })
727 .await;
728
729 let should_wait = should_auto_run && wait.unwrap_or(false);
734 if should_wait {
735 self.sessions
736 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
737 .await
738 .map_err(tool_error_from_child_session)?;
739 }
740
741 let status = if !should_auto_run {
742 "created"
743 } else if should_wait {
744 "queued"
745 } else {
746 "running_in_background"
747 };
748 let note = if should_wait {
749 "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."
750 } else if should_auto_run {
751 "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."
752 } else {
753 "Child session created (not started). Use action=run to start it. Use send_message (not create) to correct a child in place."
754 };
755 let payload = json!({
756 "title": title.clone(),
757 "description": title,
758 "responsibility": responsibility,
759 "prompt": prompt,
760 "subagent_type": subagent_type,
761 "child_session_id": child_session_id,
762 "parent_session_id": parent_session_id,
763 "model": child_model,
764 "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
765 "status": status,
766 "lifecycle": resident_name.as_ref().map(|_| "resident"),
767 "resident_name": resident_name.clone(),
768 "reused": reused,
769 "note": note,
770 });
771 if should_wait {
772 waiting_for_children_tool_result(payload)
773 } else {
774 tool_result(payload)
775 }
776 }
777 SubAgentArgs::Wait {
778 child_session_ids,
779 wait_for,
780 } => {
781 let policy = wait_for.unwrap_or(ChildWaitPolicy::All);
782 let targets = match child_session_ids {
785 Some(ids) if !ids.is_empty() => ids,
786 _ => self.sessions.active_child_ids(&parent.id).await,
787 };
788
789 if targets.is_empty() {
790 return tool_result(json!({
793 "status": "no_active_children",
794 "parent_session_id": parent_session_id,
795 "note": "No active child sessions to wait for; the parent continues running.",
796 }))
797 .map(ToolOutcome::Completed);
798 }
799
800 let count = self
801 .sessions
802 .register_parent_wait_for_children(&parent.id, &targets, policy)
803 .await
804 .map_err(tool_error_from_child_session)?;
805
806 waiting_for_children_tool_result(json!({
807 "status": "waiting",
808 "parent_session_id": parent_session_id,
809 "child_session_ids": targets,
810 "wait_for": policy.as_str(),
811 "waiting_on": count,
812 }))
813 }
814 SubAgentArgs::List => {
815 let result =
816 child_session::list_children_action(self.sessions.as_ref(), &parent.id).await;
817 tool_result(result)
818 }
819 SubAgentArgs::Get { child_session_id } => {
820 let result = child_session::get_child_action(
821 self.sessions.as_ref(),
822 &parent.id,
823 child_session_id,
824 )
825 .await
826 .map_err(tool_error_from_child_session)?;
827 tool_result(result)
828 }
829 SubAgentArgs::Update {
830 child_session_id,
831 title,
832 responsibility,
833 prompt,
834 subagent_type,
835 reset_after_update,
836 auto_run,
837 reasoning_effort,
838 } => {
839 let result = child_session::update_child_action(
840 self.sessions.as_ref(),
841 &parent.id,
842 child_session_id.clone(),
843 title,
844 responsibility,
845 prompt,
846 subagent_type,
847 reset_after_update,
848 reasoning_effort,
849 )
850 .await
851 .map_err(tool_error_from_child_session)?;
852
853 let should_auto_run = auto_run.unwrap_or(false);
854 if should_auto_run {
855 let child = self
856 .sessions
857 .load_child_for_parent(&parent.id, &child_session_id)
858 .await
859 .map_err(tool_error_from_child_session)?;
860 self.sessions
861 .enqueue_child_run(&parent, &child)
862 .await
863 .map_err(tool_error_from_child_session)?;
864 self.sessions
868 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
869 .await
870 .map_err(tool_error_from_child_session)?;
871 }
872
873 if should_auto_run {
874 waiting_for_children_tool_result(result)
875 } else {
876 tool_result(result)
877 }
878 }
879 SubAgentArgs::Run {
880 child_session_id,
881 reset_to_last_user,
882 } => {
883 let result = child_session::run_child_action(
884 self.sessions.as_ref(),
885 &parent,
886 child_session_id.clone(),
887 reset_to_last_user,
888 )
889 .await
890 .map_err(tool_error_from_child_session)?;
891 self.sessions
893 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
894 .await
895 .map_err(tool_error_from_child_session)?;
896 waiting_for_children_tool_result(result)
897 }
898 SubAgentArgs::SendMessage {
899 child_session_id,
900 message,
901 auto_run,
902 interrupt_running,
903 } => {
904 let should_auto_run = auto_run.unwrap_or(true);
905 let result = child_session::send_message_to_child_action(
906 self.sessions.as_ref(),
907 &parent,
908 child_session_id.clone(),
909 message,
910 auto_run,
911 interrupt_running,
912 )
913 .await
914 .map_err(tool_error_from_child_session)?;
915 let queued = should_auto_run
916 && result
917 .get("status")
918 .and_then(|value| value.as_str())
919 .is_some_and(|status| status == "queued");
920 if queued {
921 self.sessions
924 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
925 .await
926 .map_err(tool_error_from_child_session)?;
927 waiting_for_children_tool_result(result)
928 } else {
929 tool_result(result)
930 }
931 }
932 SubAgentArgs::Cancel { child_session_id } => {
933 let result = child_session::cancel_child_action(
934 self.sessions.as_ref(),
935 &parent.id,
936 child_session_id,
937 )
938 .await
939 .map_err(tool_error_from_child_session)?;
940 tool_result(result)
941 }
942 SubAgentArgs::Delete { child_session_id } => {
943 let result = child_session::delete_child_action(
944 self.sessions.as_ref(),
945 &parent.id,
946 child_session_id,
947 )
948 .await
949 .map_err(tool_error_from_child_session)?;
950 tool_result(result)
951 }
952 SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
954 }
955 .map(ToolOutcome::Completed)
956 }
957}
958
959#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn normalize_title_accepts_legacy_description() {
974 let title = normalize_title(None, "Search refs".to_string()).unwrap();
975 assert_eq!(title, "Search refs");
976 }
977
978 #[test]
979 fn normalize_title_prefers_title_over_description() {
980 let title =
981 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
982 assert_eq!(title, "Real title");
983 }
984
985 #[test]
986 fn normalize_title_rejects_both_empty() {
987 let err = normalize_title(None, "".to_string()).unwrap_err();
988 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
989 }
990
991 fn parent_session(
994 model_ref: Option<bamboo_domain::ProviderModelRef>,
995 ) -> bamboo_agent_core::Session {
996 let mut session = bamboo_agent_core::Session::new("p1", "gpt-test");
997 session.model_ref = model_ref;
998 session
999 }
1000
1001 #[test]
1002 fn model_spec_provider_colon_model_is_explicit() {
1003 let parent = parent_session(None);
1004 let r = parse_model_spec("anthropic:claude-sonnet-4-6", &parent, None).unwrap();
1005 assert_eq!(r.provider, "anthropic");
1006 assert_eq!(r.model, "claude-sonnet-4-6");
1007 }
1008
1009 #[test]
1010 fn model_spec_bare_inherits_parent_provider() {
1011 let parent = parent_session(Some(bamboo_domain::ProviderModelRef::new(
1012 "openai", "gpt-test",
1013 )));
1014 let r = parse_model_spec("o4-mini", &parent, Some("anthropic".to_string())).unwrap();
1015 assert_eq!(r.provider, "openai"); assert_eq!(r.model, "o4-mini");
1017 }
1018
1019 #[test]
1020 fn model_spec_bare_falls_back_to_default_provider() {
1021 let parent = parent_session(None);
1022 let r =
1023 parse_model_spec("claude-haiku-4-5", &parent, Some("anthropic".to_string())).unwrap();
1024 assert_eq!(r.provider, "anthropic");
1025 }
1026
1027 #[test]
1028 fn model_spec_bare_without_any_provider_errors() {
1029 let parent = parent_session(None);
1030 let err = parse_model_spec("mystery-model", &parent, None).unwrap_err();
1031 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("provider")));
1032 }
1033
1034 #[test]
1035 fn model_spec_rejects_malformed() {
1036 let parent = parent_session(None);
1037 assert!(parse_model_spec(" ", &parent, None).is_err());
1038 assert!(parse_model_spec("anthropic:", &parent, None).is_err());
1039 assert!(parse_model_spec(":model", &parent, None).is_err());
1040 }
1041}