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
278fn require_resident_project_identity(
279 parent_project_id: Option<&bamboo_domain::ProjectId>,
280 child: &bamboo_agent_core::Session,
281) -> Result<(), ToolError> {
282 let child_project_id =
283 match bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
284 child,
285 ) {
286 bamboo_engine::project_context::SessionProjectIdentity::Assigned(project_id) => {
287 Some(project_id)
288 }
289 bamboo_engine::project_context::SessionProjectIdentity::Unassigned => None,
290 bamboo_engine::project_context::SessionProjectIdentity::Invalid { raw, message } => {
291 return Err(ToolError::InvalidArguments(format!(
292 "resident session carries an invalid Project identity '{raw}': {message}"
293 )));
294 }
295 };
296 if child_project_id.as_ref() != parent_project_id {
297 return Err(ToolError::InvalidArguments(format!(
298 "resident_project_scope_conflict: resident Project '{}' does not match parent Project '{}'",
299 child_project_id
300 .as_ref()
301 .map(ToString::to_string)
302 .unwrap_or_else(|| "unassigned".to_string()),
303 parent_project_id
304 .map(ToString::to_string)
305 .unwrap_or_else(|| "unassigned".to_string()),
306 )));
307 }
308 Ok(())
309}
310
311pub struct SubAgentTool {
316 sessions: Arc<dyn ChildSessionPort>,
318 resolver: Arc<dyn SubagentResolutionPort>,
320 catalog: Option<Arc<dyn ModelCatalogPort>>,
324}
325
326impl SubAgentTool {
327 pub fn new(
328 sessions: Arc<dyn ChildSessionPort>,
329 resolver: Arc<dyn SubagentResolutionPort>,
330 ) -> Self {
331 Self {
332 sessions,
333 resolver,
334 catalog: None,
335 }
336 }
337
338 pub fn with_model_catalog(mut self, catalog: Arc<dyn ModelCatalogPort>) -> Self {
341 self.catalog = Some(catalog);
342 self
343 }
344}
345
346fn parse_model_spec(
351 spec: &str,
352 parent: &bamboo_agent_core::Session,
353 default_provider: Option<String>,
354) -> Result<bamboo_domain::ProviderModelRef, ToolError> {
355 let spec = spec.trim();
356 if spec.is_empty() {
357 return Err(ToolError::InvalidArguments(
358 "model must be non-empty when provided".to_string(),
359 ));
360 }
361 if let Some((provider, model)) = spec.split_once(':') {
362 let (provider, model) = (provider.trim(), model.trim());
363 if provider.is_empty() || model.is_empty() {
364 return Err(ToolError::InvalidArguments(format!(
365 "model '{spec}' must be 'provider:model' with both parts non-empty"
366 )));
367 }
368 return Ok(bamboo_domain::ProviderModelRef::new(provider, model));
369 }
370 let provider = parent
372 .model_ref
373 .as_ref()
374 .map(|r| r.provider.clone())
375 .filter(|p| !p.trim().is_empty())
376 .or(default_provider)
377 .ok_or_else(|| {
378 ToolError::InvalidArguments(format!(
379 "model '{spec}' has no provider prefix and no default provider is known; \
380 use 'provider:model' (see action=list_models)"
381 ))
382 })?;
383 Ok(bamboo_domain::ProviderModelRef::new(provider, spec))
384}
385
386pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 4;
391
392pub fn subagent_tool_description() -> &'static str {
395 "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. \
396PARALLEL 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. \
397Use 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."
398}
399
400pub fn subagent_parameters_schema() -> serde_json::Value {
405 json!({
406 "type": "object",
407 "properties": {
408 "action": {
409 "type": "string",
410 "enum": ["create", "wait", "list", "get", "update", "run", "send_message", "cancel", "delete", "list_models"],
411 "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. \
412 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\"}."
413 },
414 "child_session_id": {
415 "type": "string",
416 "description": "Existing child session id. Required for get/update/run/send_message/cancel/delete."
417 },
418 "child_session_ids": {
419 "type": "array",
420 "items": { "type": "string" },
421 "description": "For wait: optional explicit subset of child sessions to wait on. Omit to wait on every currently-active child."
422 },
423 "wait_for": {
424 "type": "string",
425 "enum": ["all", "any", "first_error"],
426 "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."
427 },
428 "wait": {
429 "type": "boolean",
430 "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."
431 },
432 "title": {
433 "type": "string",
434 "description": "Short title for a new or updated child session. Required for create. Displayed in the Sub-agents panel."
435 },
436 "description": {
437 "type": "string",
438 "description": "Legacy alias of title; prefer title."
439 },
440 "responsibility": {
441 "type": "string",
442 "description": "Single explicit responsibility for the child session. Required for create. Keep this narrow and non-overlapping with other child sessions."
443 },
444 "prompt": {
445 "type": "string",
446 "description": "Detailed task instructions, context, constraints, and expected output for the child session. Required for create; optional for update."
447 },
448 "subagent_type": {
449 "type": "string",
450 "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."
451 },
452 "workspace": {
453 "type": "string",
454 "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."
455 },
456 "auto_run": {
457 "type": "boolean",
458 "description": "For create/send_message/update: whether to enqueue the child session immediately. Defaults to true for create/send_message and false for update."
459 },
460 "fork_last_messages": {
461 "type": "integer",
462 "minimum": 0,
463 "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."
464 },
465 "reset_after_update": {
466 "type": "boolean",
467 "description": "For update: whether to truncate messages after refreshed assignment. Defaults to true."
468 },
469 "reset_to_last_user": {
470 "type": "boolean",
471 "description": "For run: whether to truncate messages after the last user message before rerun. Defaults to true."
472 },
473 "message": {
474 "type": "string",
475 "description": "Follow-up instruction to append as a new user message for send_message. Required for send_message."
476 },
477 "interrupt_running": {
478 "type": "boolean",
479 "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."
480 },
481 "reasoning_effort": {
482 "type": "string",
483 "enum": ["low", "medium", "high", "xhigh", "max"],
484 "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."
485 },
486 "model": {
487 "type": "string",
488 "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."
489 },
490 "lifecycle": {
491 "type": "string",
492 "enum": ["oneshot", "resident"],
493 "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."
494 },
495 "name": {
496 "type": "string",
497 "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."
498 },
499 "context": {
500 "type": "string",
501 "enum": ["reset", "accumulate"],
502 "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."
503 }
504 },
505 "required": ["action"],
506 "additionalProperties": false
507 })
508}
509
510#[async_trait]
511impl Tool for SubAgentTool {
512 fn name(&self) -> &str {
513 "SubAgent"
514 }
515
516 fn description(&self) -> &str {
517 subagent_tool_description()
518 }
519
520 fn parameters_schema(&self) -> serde_json::Value {
521 subagent_parameters_schema()
522 }
523
524 async fn invoke(
525 &self,
526 args: serde_json::Value,
527 ctx: ToolCtx,
528 ) -> Result<ToolOutcome, ToolError> {
529 let parent_session_id = ctx.session_id().ok_or_else(|| {
530 ToolError::Execution("SubAgent requires a session_id in tool context".to_string())
531 })?;
532
533 let mut args = args;
537 if args.get("action").is_none() {
538 args["action"] = json!("create");
539 }
540
541 let parsed: SubAgentArgs = serde_json::from_value(args).map_err(|error| {
542 ToolError::InvalidArguments(format!("Invalid SubAgent args: {error}"))
543 })?;
544
545 if let SubAgentArgs::ListModels = parsed {
547 let Some(catalog) = self.catalog.as_ref() else {
548 return Err(ToolError::Execution(
549 "model catalog is not configured on this server".to_string(),
550 ));
551 };
552 let providers = catalog.list_models().await;
553 return tool_result(json!({
554 "default_provider": catalog.default_provider(),
555 "providers": providers,
556 "usage": "Pass create.model as 'provider:model' (or a bare model id to use the parent's provider).",
557 }))
558 .map(ToolOutcome::Completed);
559 }
560
561 let parent = self
562 .sessions
563 .as_ref()
564 .load_root_session(parent_session_id)
565 .await
566 .map_err(tool_error_from_child_session)?;
567
568 match parsed {
569 SubAgentArgs::Create {
570 title,
571 description,
572 responsibility,
573 prompt,
574 subagent_type,
575 workspace,
576 auto_run,
577 wait,
578 reasoning_effort,
579 model,
580 lifecycle,
581 name,
582 context,
583 fork_last_messages,
584 } => {
585 if parent.spawn_depth >= DEFAULT_MAX_SPAWN_DEPTH {
591 return Err(ToolError::InvalidArguments(format!(
592 "spawn depth limit ({}) reached: this agent is at depth {} and cannot create more sub-agents. Finish the work here, or delegate to a sibling.",
593 DEFAULT_MAX_SPAWN_DEPTH, parent.spawn_depth
594 )));
595 }
596 let title = normalize_title(title, description)?;
597 let responsibility = normalize_required_text(responsibility, "responsibility")?;
598 let prompt = normalize_required_text(Some(prompt), "prompt")?;
599 let subagent_type = subagent_type
603 .map(|value| value.trim().to_string())
604 .filter(|value| !value.is_empty())
605 .unwrap_or_else(|| "worker".to_string());
606 let explicit_workspace = workspace
608 .map(|value| value.trim().to_string())
609 .filter(|value| !value.is_empty());
610 let workspace_was_explicit = explicit_workspace.is_some();
611 let parent_workspace_is_project_default = parent
612 .metadata
613 .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
614 .map(String::as_str)
615 == Some(
616 bamboo_engine::project_context::WorkspaceSource::ProjectDefault.as_str(),
617 );
618 let requested_workspace = explicit_workspace
619 .or_else(|| {
620 (!parent_workspace_is_project_default)
621 .then(|| parent.workspace.clone())
622 .flatten()
623 })
624 .unwrap_or_default();
625 let parent_project_id =
626 match bamboo_engine::project_context::ProjectContextResolver::session_project_identity(&parent) {
627 bamboo_engine::project_context::SessionProjectIdentity::Assigned(
628 project_id,
629 ) => Some(project_id),
630 bamboo_engine::project_context::SessionProjectIdentity::Unassigned => None,
631 bamboo_engine::project_context::SessionProjectIdentity::Invalid {
632 raw,
633 message,
634 } => {
635 return Err(ToolError::InvalidArguments(format!(
636 "parent session carries an invalid Project identity '{raw}': {message}"
637 )));
638 }
639 };
640 let workspace_source = if workspace_was_explicit {
641 bamboo_engine::project_context::WorkspaceSource::Explicit
642 } else if parent_workspace_is_project_default
643 || (requested_workspace.is_empty() && parent_project_id.is_some())
644 {
645 bamboo_engine::project_context::WorkspaceSource::ProjectDefault
646 } else {
647 match parent
648 .metadata
649 .get(bamboo_engine::project_context::WORKSPACE_SOURCE_METADATA_KEY)
650 .map(String::as_str)
651 {
652 Some("project_default") => {
653 bamboo_engine::project_context::WorkspaceSource::ProjectDefault
654 }
655 _ => bamboo_engine::project_context::WorkspaceSource::Session,
656 }
657 };
658 let workspace = self
663 .sessions
664 .validate_child_workspace(
665 parent_project_id.as_ref(),
666 &requested_workspace,
667 )
668 .await
669 .map_err(tool_error_from_child_session)?;
670
671 if parent.model.trim().is_empty() {
672 return Err(ToolError::Execution(
673 "parent session model is empty".to_string(),
674 ));
675 }
676
677 let should_auto_run = auto_run.unwrap_or(true);
678
679 let is_resident = lifecycle.as_deref().map(str::trim) == Some("resident");
687 let resident_name = is_resident.then(|| {
688 name.as_deref()
689 .map(str::trim)
690 .filter(|n| !n.is_empty())
691 .map(str::to_string)
692 .unwrap_or_else(|| subagent_type.clone())
693 });
694 let resident_context = context
695 .as_deref()
696 .map(str::trim)
697 .filter(|c| matches!(*c, "reset" | "accumulate"))
698 .unwrap_or("reset")
699 .to_string();
700 let existing_resident = match resident_name.as_deref() {
701 Some(rname) => {
702 self.sessions.find_resident_child(&parent.id, rname).await
705 }
706 None => None,
707 };
708
709 let (child_session_id, child_model, reused) =
710 if let Some(existing_id) = existing_resident {
711 let mut child = self
717 .sessions
718 .load_child_for_parent(&parent.id, &existing_id)
719 .await
720 .map_err(tool_error_from_child_session)?;
721 require_resident_project_identity(parent_project_id.as_ref(), &child)?;
722
723 if self.sessions.is_child_running(&existing_id).await {
731 self.sessions
732 .cancel_child_run_and_wait(&existing_id)
733 .await
734 .map_err(tool_error_from_child_session)?;
735 child = self
736 .sessions
737 .load_child_for_parent(&parent.id, &existing_id)
738 .await
739 .map_err(tool_error_from_child_session)?;
740 require_resident_project_identity(
741 parent_project_id.as_ref(),
742 &child,
743 )?;
744 }
745 let (parent_permission_mode, parent_no_human, parent_plan_active) = parent
754 .agent_runtime_state
755 .as_ref()
756 .map(|s| {
757 (
758 s.effective_permission_mode(),
759 s.no_human_approver,
760 s.plan_mode.is_some(),
761 )
762 })
763 .unwrap_or_default();
764 let inherited_audit =
765 bamboo_domain::PermissionAuditSnapshot::from_metadata(&parent.metadata);
766 let policy_revision = inherited_audit
767 .as_ref()
768 .map(|audit| audit.policy_revision)
769 .unwrap_or_default();
770 let inherited_effective = if parent_plan_active {
771 bamboo_domain::PermissionMode::Plan
772 } else {
773 inherited_audit
774 .as_ref()
775 .filter(|audit| {
776 audit.resolution.requested == parent_permission_mode
777 && audit.resolution.is_consistent()
778 })
779 .map(|audit| audit.resolution.effective)
780 .unwrap_or_else(|| {
781 bamboo_domain::resolve_permission_mode(
782 parent_permission_mode,
783 bamboo_domain::PermissionMode::Default,
784 )
785 .effective
786 })
787 };
788 let resolution = bamboo_domain::PermissionModeResolution {
789 requested: parent_permission_mode,
790 effective: inherited_effective,
791 };
792 let permission_audit = bamboo_domain::PermissionAuditSeed::bamboo_runtime(
793 policy_revision,
794 resolution,
795 );
796 self.sessions
800 .save_resident_reuse_state(
801 &mut child,
802 &workspace,
803 workspace_source,
804 permission_audit,
805 parent_no_human,
806 )
807 .await
808 .map_err(tool_error_from_child_session)?;
809 if resident_context == "accumulate" {
812 child_session::send_message_to_child_action(
813 self.sessions.as_ref(),
814 &parent,
815 existing_id.clone(),
816 format!("# Task: {title}\n\n{responsibility}\n\n{prompt}"),
817 Some(should_auto_run),
818 Some(false),
819 Some(ctx.tool_call_id.as_ref()),
820 )
821 .await
822 .map_err(tool_error_from_child_session)?;
823 } else {
824 child_session::update_child_action(
825 self.sessions.as_ref(),
826 &parent.id,
827 existing_id.clone(),
828 Some(title.clone()),
829 Some(responsibility.clone()),
830 Some(prompt.clone()),
831 Some(subagent_type.clone()),
832 Some(true),
833 reasoning_effort,
834 )
835 .await
836 .map_err(tool_error_from_child_session)?;
837 if should_auto_run {
838 let child = self
839 .sessions
840 .load_child_for_parent(&parent.id, &existing_id)
841 .await
842 .map_err(tool_error_from_child_session)?;
843 self.sessions
844 .enqueue_child_run(&parent, &child)
845 .await
846 .map_err(tool_error_from_child_session)?;
847 }
848 }
849 let model = self
850 .sessions
851 .load_child_for_parent(&parent.id, &existing_id)
852 .await
853 .map(|c| c.model)
854 .unwrap_or_default();
855 (existing_id, model, true)
856 } else {
857 let child_id = Uuid::new_v4().to_string();
858 let model_ref_override =
861 match model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
862 Some(spec) => Some(parse_model_spec(
863 spec,
864 &parent,
865 self.catalog.as_ref().map(|c| c.default_provider()),
866 )?),
867 None => self.resolver.resolve_subagent_model(&subagent_type).await,
868 };
869 let model_override = model_ref_override
870 .as_ref()
871 .map(|model_ref| model_ref.model.clone());
872 let runtime_metadata =
873 self.resolver.resolve_runtime_metadata(&subagent_type).await;
874 let result = child_session::create_child_action(
875 self.sessions.as_ref(),
876 CreateChildInput {
877 parent_session: parent.clone(),
878 child_id: child_id.clone(),
879 title: title.clone(),
880 responsibility: responsibility.clone(),
881 assignment_prompt: prompt.clone(),
882 subagent_type: subagent_type.clone(),
883 workspace: workspace.clone(),
884 workspace_source,
885 model_override,
886 model_ref_override,
887 runtime_metadata,
888 auto_run: should_auto_run,
889 reasoning_effort,
890 lifecycle: resident_name.as_ref().map(|_| "resident".to_string()),
891 resident_name: resident_name.clone(),
892 resident_context: resident_name
893 .as_ref()
894 .map(|_| resident_context.clone()),
895 disabled_tools: None,
896 context_fork: fork_last_messages.filter(|n| *n > 0),
899 },
900 )
901 .await
902 .map_err(tool_error_from_child_session)?;
903 (result.child_session_id, result.model, false)
904 };
905
906 self.sessions.ensure_child_indexed(&child_session_id).await;
908
909 ctx.emit_tool_token(if reused {
910 format!("Reused resident agent: {child_session_id}")
911 } else {
912 format!("Spawned child session: {child_session_id}")
913 })
914 .await;
915
916 let should_wait = should_auto_run && wait.unwrap_or(false);
921 if should_wait {
922 self.sessions
923 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
924 .await
925 .map_err(tool_error_from_child_session)?;
926 }
927
928 let status = if !should_auto_run {
929 "created"
930 } else if should_wait {
931 "queued"
932 } else {
933 "running_in_background"
934 };
935 let note = if should_wait {
936 "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."
937 } else if should_auto_run {
938 "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."
939 } else {
940 "Child session created (not started). Use action=run to start it. Use send_message (not create) to correct a child in place."
941 };
942 let payload = json!({
943 "title": title.clone(),
944 "description": title,
945 "responsibility": responsibility,
946 "prompt": prompt,
947 "subagent_type": subagent_type,
948 "child_session_id": child_session_id,
949 "parent_session_id": parent_session_id,
950 "model": child_model,
951 "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
952 "status": status,
953 "lifecycle": resident_name.as_ref().map(|_| "resident"),
954 "resident_name": resident_name.clone(),
955 "reused": reused,
956 "note": note,
957 });
958 if should_wait {
959 waiting_for_children_tool_result(payload)
960 } else {
961 tool_result(payload)
962 }
963 }
964 SubAgentArgs::Wait {
965 child_session_ids,
966 wait_for,
967 } => {
968 let policy = wait_for.unwrap_or(ChildWaitPolicy::All);
969 let (targets, dropped): (Vec<String>, Vec<(String, String)>) =
978 match child_session_ids {
979 Some(ids) if !ids.is_empty() => {
980 let terminal =
981 self.sessions.terminal_child_ids(&parent.id, &ids).await;
982 partition_wait_targets(ids, &terminal)
983 }
984 _ => (
985 self.sessions.active_child_ids(&parent.id).await,
986 Vec::new(),
987 ),
988 };
989 let dropped_ids: Vec<String> =
990 dropped.iter().map(|(id, _)| id.clone()).collect();
991
992 if wait_already_satisfied_by_dropped(policy, &dropped) {
997 return tool_result(json!({
998 "status": "already_satisfied",
999 "parent_session_id": parent_session_id,
1000 "satisfied_by": dropped
1001 .iter()
1002 .map(|(id, status)| json!({ "child_session_id": id, "status": status }))
1003 .collect::<Vec<_>>(),
1004 "still_active_child_ids": targets,
1005 "wait_for": policy.as_str(),
1006 "note": "The wait policy is already satisfied by finished child \
1007 session(s) — the parent was NOT suspended. Use SubAgent.get \
1008 to read their results; call wait again (without those ids) \
1009 if you still need the remaining children.",
1010 }))
1011 .map(ToolOutcome::Completed);
1012 }
1013
1014 if targets.is_empty() {
1015 let note = if dropped_ids.is_empty() {
1021 "No active child sessions to wait for; the parent continues running."
1022 .to_string()
1023 } else {
1024 format!(
1025 "The requested child session(s) [{}] are already finished; nothing \
1026 to wait for. Use SubAgent.get to read their results.",
1027 dropped_ids.join(", ")
1028 )
1029 };
1030 return tool_result(json!({
1031 "status": "no_active_children",
1032 "parent_session_id": parent_session_id,
1033 "already_terminal_child_ids": dropped_ids,
1034 "note": note,
1035 }))
1036 .map(ToolOutcome::Completed);
1037 }
1038
1039 let count = self
1040 .sessions
1041 .register_parent_wait_for_children(&parent.id, &targets, policy)
1042 .await
1043 .map_err(tool_error_from_child_session)?;
1044
1045 waiting_for_children_tool_result(json!({
1046 "status": "waiting",
1047 "parent_session_id": parent_session_id,
1048 "child_session_ids": targets,
1049 "already_terminal_child_ids": dropped_ids,
1050 "wait_for": policy.as_str(),
1051 "waiting_on": count,
1052 }))
1053 }
1054 SubAgentArgs::List => {
1055 let result =
1056 child_session::list_children_action(self.sessions.as_ref(), &parent.id).await;
1057 tool_result(result)
1058 }
1059 SubAgentArgs::Get { child_session_id } => {
1060 let result = child_session::get_child_action(
1061 self.sessions.as_ref(),
1062 &parent.id,
1063 child_session_id,
1064 )
1065 .await
1066 .map_err(tool_error_from_child_session)?;
1067 tool_result(result)
1068 }
1069 SubAgentArgs::Update {
1070 child_session_id,
1071 title,
1072 responsibility,
1073 prompt,
1074 subagent_type,
1075 reset_after_update,
1076 auto_run,
1077 reasoning_effort,
1078 } => {
1079 let result = child_session::update_child_action(
1080 self.sessions.as_ref(),
1081 &parent.id,
1082 child_session_id.clone(),
1083 title,
1084 responsibility,
1085 prompt,
1086 subagent_type,
1087 reset_after_update,
1088 reasoning_effort,
1089 )
1090 .await
1091 .map_err(tool_error_from_child_session)?;
1092
1093 let should_auto_run = auto_run.unwrap_or(false);
1094 if should_auto_run {
1095 let child = self
1096 .sessions
1097 .load_child_for_parent(&parent.id, &child_session_id)
1098 .await
1099 .map_err(tool_error_from_child_session)?;
1100 self.sessions
1101 .enqueue_child_run(&parent, &child)
1102 .await
1103 .map_err(tool_error_from_child_session)?;
1104 self.sessions
1108 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
1109 .await
1110 .map_err(tool_error_from_child_session)?;
1111 }
1112
1113 if should_auto_run {
1114 waiting_for_children_tool_result(result)
1115 } else {
1116 tool_result(result)
1117 }
1118 }
1119 SubAgentArgs::Run {
1120 child_session_id,
1121 reset_to_last_user,
1122 } => {
1123 let result = child_session::run_child_action(
1124 self.sessions.as_ref(),
1125 &parent,
1126 child_session_id.clone(),
1127 reset_to_last_user,
1128 )
1129 .await
1130 .map_err(tool_error_from_child_session)?;
1131 self.sessions
1133 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
1134 .await
1135 .map_err(tool_error_from_child_session)?;
1136 waiting_for_children_tool_result(result)
1137 }
1138 SubAgentArgs::SendMessage {
1139 child_session_id,
1140 message,
1141 auto_run,
1142 interrupt_running,
1143 } => {
1144 let should_auto_run = auto_run.unwrap_or(true);
1145 let result = child_session::send_message_to_child_action(
1146 self.sessions.as_ref(),
1147 &parent,
1148 child_session_id.clone(),
1149 message,
1150 auto_run,
1151 interrupt_running,
1152 Some(ctx.tool_call_id.as_ref()),
1153 )
1154 .await
1155 .map_err(tool_error_from_child_session)?;
1156 let queued = should_auto_run
1157 && result
1158 .get("status")
1159 .and_then(|value| value.as_str())
1160 .is_some_and(|status| status == "queued");
1161 if queued {
1162 self.sessions
1165 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
1166 .await
1167 .map_err(tool_error_from_child_session)?;
1168 waiting_for_children_tool_result(result)
1169 } else {
1170 tool_result(result)
1171 }
1172 }
1173 SubAgentArgs::Cancel { child_session_id } => {
1174 let result = child_session::cancel_child_action(
1175 self.sessions.as_ref(),
1176 &parent.id,
1177 child_session_id,
1178 )
1179 .await
1180 .map_err(tool_error_from_child_session)?;
1181 tool_result(result)
1182 }
1183 SubAgentArgs::Delete { child_session_id } => {
1184 let result = child_session::delete_child_action(
1185 self.sessions.as_ref(),
1186 &parent.id,
1187 child_session_id,
1188 )
1189 .await
1190 .map_err(tool_error_from_child_session)?;
1191 tool_result(result)
1192 }
1193 SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
1195 }
1196 .map(ToolOutcome::Completed)
1197 }
1198}
1199
1200#[cfg(test)]
1210mod tests {
1211 use super::*;
1212
1213 #[test]
1214 fn partition_wait_targets_drops_only_known_terminal_ids() {
1215 let (targets, dropped) = partition_wait_targets(
1216 vec!["done".into(), "running".into(), "unknown".into()],
1217 &[("done".to_string(), "completed".to_string())],
1218 );
1219 assert_eq!(targets, vec!["running".to_string(), "unknown".to_string()]);
1220 assert_eq!(dropped, vec![("done".to_string(), "completed".to_string())]);
1221
1222 let (targets, dropped) = partition_wait_targets(vec!["a".into(), "b".into()], &[]);
1224 assert_eq!(targets, vec!["a".to_string(), "b".to_string()]);
1225 assert!(dropped.is_empty());
1226
1227 let (targets, dropped) = partition_wait_targets(
1229 vec!["a".into(), "b".into()],
1230 &[
1231 ("a".to_string(), "completed".to_string()),
1232 ("b".to_string(), "error".to_string()),
1233 ],
1234 );
1235 assert!(targets.is_empty());
1236 assert_eq!(dropped.len(), 2);
1237 }
1238
1239 #[test]
1240 fn wait_short_circuits_when_dropped_ids_satisfy_the_policy() {
1241 let completed = [("a".to_string(), "completed".to_string())];
1242 let errored = [("a".to_string(), "timeout".to_string())];
1243
1244 assert!(!wait_already_satisfied_by_dropped(
1246 ChildWaitPolicy::All,
1247 &completed
1248 ));
1249 assert!(!wait_already_satisfied_by_dropped(
1250 ChildWaitPolicy::All,
1251 &errored
1252 ));
1253
1254 assert!(wait_already_satisfied_by_dropped(
1256 ChildWaitPolicy::Any,
1257 &completed
1258 ));
1259 assert!(!wait_already_satisfied_by_dropped(
1260 ChildWaitPolicy::Any,
1261 &[]
1262 ));
1263
1264 assert!(wait_already_satisfied_by_dropped(
1267 ChildWaitPolicy::FirstError,
1268 &errored
1269 ));
1270 assert!(!wait_already_satisfied_by_dropped(
1271 ChildWaitPolicy::FirstError,
1272 &completed
1273 ));
1274 }
1275
1276 #[test]
1277 fn normalize_title_accepts_legacy_description() {
1278 let title = normalize_title(None, "Search refs".to_string()).unwrap();
1279 assert_eq!(title, "Search refs");
1280 }
1281
1282 #[test]
1283 fn normalize_title_prefers_title_over_description() {
1284 let title =
1285 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
1286 assert_eq!(title, "Real title");
1287 }
1288
1289 #[test]
1290 fn normalize_title_rejects_both_empty() {
1291 let err = normalize_title(None, "".to_string()).unwrap_err();
1292 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
1293 }
1294
1295 fn parent_session(
1298 model_ref: Option<bamboo_domain::ProviderModelRef>,
1299 ) -> bamboo_agent_core::Session {
1300 let mut session = bamboo_agent_core::Session::new("p1", "gpt-test");
1301 session.model_ref = model_ref;
1302 session
1303 }
1304
1305 #[test]
1306 fn model_spec_provider_colon_model_is_explicit() {
1307 let parent = parent_session(None);
1308 let r = parse_model_spec("anthropic:claude-sonnet-4-6", &parent, None).unwrap();
1309 assert_eq!(r.provider, "anthropic");
1310 assert_eq!(r.model, "claude-sonnet-4-6");
1311 }
1312
1313 #[test]
1314 fn model_spec_bare_inherits_parent_provider() {
1315 let parent = parent_session(Some(bamboo_domain::ProviderModelRef::new(
1316 "openai", "gpt-test",
1317 )));
1318 let r = parse_model_spec("o4-mini", &parent, Some("anthropic".to_string())).unwrap();
1319 assert_eq!(r.provider, "openai"); assert_eq!(r.model, "o4-mini");
1321 }
1322
1323 #[test]
1324 fn model_spec_bare_falls_back_to_default_provider() {
1325 let parent = parent_session(None);
1326 let r =
1327 parse_model_spec("claude-haiku-4-5", &parent, Some("anthropic".to_string())).unwrap();
1328 assert_eq!(r.provider, "anthropic");
1329 }
1330
1331 #[test]
1332 fn model_spec_bare_without_any_provider_errors() {
1333 let parent = parent_session(None);
1334 let err = parse_model_spec("mystery-model", &parent, None).unwrap_err();
1335 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("provider")));
1336 }
1337
1338 #[test]
1339 fn model_spec_rejects_malformed() {
1340 let parent = parent_session(None);
1341 assert!(parse_model_spec(" ", &parent, None).is_err());
1342 assert!(parse_model_spec("anthropic:", &parent, None).is_err());
1343 assert!(parse_model_spec(":model", &parent, None).is_err());
1344 }
1345}