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::subagent::SubagentProfileRegistry;
10use bamboo_domain::ReasoningEffort;
11use bamboo_engine::session_app::child_session::{
12 self, ChildSessionError, ChildSessionPort, CreateChildInput, ModelCatalogPort,
13 SubagentResolutionPort,
14};
15
16#[derive(Debug, Deserialize)]
21#[serde(tag = "action", rename_all = "snake_case")]
22enum SubAgentArgs {
23 Create {
24 #[serde(default)]
25 title: Option<String>,
26 #[serde(default)]
27 description: String,
28 #[serde(default)]
29 responsibility: Option<String>,
30 prompt: String,
31 #[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 },
84 Wait {
91 #[serde(default)]
92 child_session_ids: Option<Vec<String>>,
93 #[serde(default)]
97 wait_for: Option<ChildWaitPolicy>,
98 },
99 List,
100 Get {
101 child_session_id: String,
102 },
103 Update {
104 child_session_id: String,
105 #[serde(default)]
106 title: Option<String>,
107 #[serde(default)]
108 responsibility: Option<String>,
109 #[serde(default)]
110 prompt: Option<String>,
111 #[serde(default)]
112 subagent_type: Option<String>,
113 #[serde(default)]
114 reset_after_update: Option<bool>,
115 #[serde(default)]
116 auto_run: Option<bool>,
117 #[serde(default)]
121 reasoning_effort: Option<ReasoningEffort>,
122 },
123 Run {
124 child_session_id: String,
125 #[serde(default)]
126 reset_to_last_user: Option<bool>,
127 },
128 SendMessage {
129 child_session_id: String,
130 message: String,
131 #[serde(default)]
132 auto_run: Option<bool>,
133 #[serde(default)]
134 interrupt_running: Option<bool>,
135 },
136 Cancel {
137 child_session_id: String,
138 },
139 Delete {
140 child_session_id: String,
141 },
142 ListProfiles,
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 profiles: Arc<SubagentProfileRegistry>,
247 catalog: Option<Arc<dyn ModelCatalogPort>>,
251}
252
253impl SubAgentTool {
254 pub fn new(
255 sessions: Arc<dyn ChildSessionPort>,
256 resolver: Arc<dyn SubagentResolutionPort>,
257 profiles: Arc<SubagentProfileRegistry>,
258 ) -> Self {
259 Self {
260 sessions,
261 resolver,
262 profiles,
263 catalog: None,
264 }
265 }
266
267 pub fn with_model_catalog(mut self, catalog: Arc<dyn ModelCatalogPort>) -> Self {
270 self.catalog = Some(catalog);
271 self
272 }
273}
274
275fn parse_model_spec(
280 spec: &str,
281 parent: &bamboo_agent_core::Session,
282 default_provider: Option<String>,
283) -> Result<bamboo_domain::ProviderModelRef, ToolError> {
284 let spec = spec.trim();
285 if spec.is_empty() {
286 return Err(ToolError::InvalidArguments(
287 "model must be non-empty when provided".to_string(),
288 ));
289 }
290 if let Some((provider, model)) = spec.split_once(':') {
291 let (provider, model) = (provider.trim(), model.trim());
292 if provider.is_empty() || model.is_empty() {
293 return Err(ToolError::InvalidArguments(format!(
294 "model '{spec}' must be 'provider:model' with both parts non-empty"
295 )));
296 }
297 return Ok(bamboo_domain::ProviderModelRef::new(provider, model));
298 }
299 let provider = parent
301 .model_ref
302 .as_ref()
303 .map(|r| r.provider.clone())
304 .filter(|p| !p.trim().is_empty())
305 .or(default_provider)
306 .ok_or_else(|| {
307 ToolError::InvalidArguments(format!(
308 "model '{spec}' has no provider prefix and no default provider is known; \
309 use 'provider:model' (see action=list_models)"
310 ))
311 })?;
312 Ok(bamboo_domain::ProviderModelRef::new(provider, spec))
313}
314
315#[async_trait]
316impl Tool for SubAgentTool {
317 fn name(&self) -> &str {
318 "SubAgent"
319 }
320
321 fn description(&self) -> &str {
322 "Create, inspect, and manage child sessions for explicitly requested delegated, parallel, or sub-agent work. A child session runs independently under the current root session with its own conversation context, can use a specialized subagent profile, streams progress back to the parent via sub_agent_* events, and can be reopened from the Sub-agents panel. \
323PARALLEL 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. \
324Use list/get to inspect existing children; use update/run/send_message/cancel/delete to manage existing children; use list_profiles to enumerate subagent roles. 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. Child sessions cannot spawn nested child sessions. 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."
325 }
326
327 fn parameters_schema(&self) -> serde_json::Value {
328 json!({
329 "type": "object",
330 "properties": {
331 "action": {
332 "type": "string",
333 "enum": ["create", "wait", "list", "get", "update", "run", "send_message", "cancel", "delete", "list_profiles", "list_models"],
334 "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_profiles to enumerate available subagent roles before choosing subagent_type; list_models to enumerate the models you can pin a child to via create.model. \
335 A create call requires: title, responsibility, prompt, and subagent_type (workspace and subagent_type are optional and default to the parent's workspace / general-purpose). EXAMPLE create: {\"action\":\"create\",\"subagent_type\":\"researcher\",\"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\"}."
336 },
337 "child_session_id": {
338 "type": "string",
339 "description": "Existing child session id. Required for get/update/run/send_message/cancel/delete."
340 },
341 "child_session_ids": {
342 "type": "array",
343 "items": { "type": "string" },
344 "description": "For wait: optional explicit subset of child sessions to wait on. Omit to wait on every currently-active child."
345 },
346 "wait_for": {
347 "type": "string",
348 "enum": ["all", "any", "first_error"],
349 "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."
350 },
351 "wait": {
352 "type": "boolean",
353 "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."
354 },
355 "title": {
356 "type": "string",
357 "description": "Short title for a new or updated child session. Required for create. Displayed in the Sub-agents panel."
358 },
359 "description": {
360 "type": "string",
361 "description": "Legacy alias of title; prefer title."
362 },
363 "responsibility": {
364 "type": "string",
365 "description": "Single explicit responsibility for the child session. Required for create. Keep this narrow and non-overlapping with other child sessions."
366 },
367 "prompt": {
368 "type": "string",
369 "description": "Detailed task instructions, context, constraints, and expected output for the child session. Required for create; optional for update."
370 },
371 "subagent_type": {
372 "type": "string",
373 "description": "For create: the specialized child agent profile/role, e.g. general-purpose, researcher, coder, plan. Use plan/researcher for read-only exploration and coder/general-purpose for implementation when allowed. Optional — omitting it defaults to general-purpose — but you should pick the most fitting role. Call list_profiles to see the available roles."
374 },
375 "workspace": {
376 "type": "string",
377 "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."
378 },
379 "auto_run": {
380 "type": "boolean",
381 "description": "For create/send_message/update: whether to enqueue the child session immediately. Defaults to true for create/send_message and false for update."
382 },
383 "reset_after_update": {
384 "type": "boolean",
385 "description": "For update: whether to truncate messages after refreshed assignment. Defaults to true."
386 },
387 "reset_to_last_user": {
388 "type": "boolean",
389 "description": "For run: whether to truncate messages after the last user message before rerun. Defaults to true."
390 },
391 "message": {
392 "type": "string",
393 "description": "Follow-up instruction to append as a new user message for send_message. Required for send_message."
394 },
395 "interrupt_running": {
396 "type": "boolean",
397 "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."
398 },
399 "reasoning_effort": {
400 "type": "string",
401 "enum": ["low", "medium", "high", "xhigh", "max"],
402 "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."
403 },
404 "model": {
405 "type": "string",
406 "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-role 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 chosen subagent_type."
407 },
408 "lifecycle": {
409 "type": "string",
410 "enum": ["oneshot", "resident"],
411 "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."
412 },
413 "name": {
414 "type": "string",
415 "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."
416 },
417 "context": {
418 "type": "string",
419 "enum": ["reset", "accumulate"],
420 "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."
421 }
422 },
423 "required": ["action"],
424 "additionalProperties": false
425 })
426 }
427
428 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
429 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
430 .await
431 }
432
433 async fn execute_with_context(
434 &self,
435 args: serde_json::Value,
436 ctx: ToolExecutionContext<'_>,
437 ) -> Result<ToolResult, ToolError> {
438 let parent_session_id = ctx.session_id.ok_or_else(|| {
439 ToolError::Execution("SubAgent requires a session_id in tool context".to_string())
440 })?;
441
442 let mut args = args;
446 if args.get("action").is_none() {
447 args["action"] = json!("create");
448 }
449
450 let parsed: SubAgentArgs = serde_json::from_value(args).map_err(|error| {
451 ToolError::InvalidArguments(format!("Invalid SubAgent args: {error}"))
452 })?;
453
454 if let SubAgentArgs::ListProfiles = parsed {
459 return tool_result(self.list_profiles_payload());
460 }
461
462 if let SubAgentArgs::ListModels = parsed {
464 let Some(catalog) = self.catalog.as_ref() else {
465 return Err(ToolError::Execution(
466 "model catalog is not configured on this server".to_string(),
467 ));
468 };
469 let providers = catalog.list_models().await;
470 return tool_result(json!({
471 "default_provider": catalog.default_provider(),
472 "providers": providers,
473 "usage": "Pass create.model as 'provider:model' (or a bare model id to use the parent's provider).",
474 }));
475 }
476
477 let parent = self
478 .sessions
479 .as_ref()
480 .load_root_session(parent_session_id)
481 .await
482 .map_err(tool_error_from_child_session)?;
483
484 match parsed {
485 SubAgentArgs::Create {
486 title,
487 description,
488 responsibility,
489 prompt,
490 subagent_type,
491 workspace,
492 auto_run,
493 wait,
494 reasoning_effort,
495 model,
496 lifecycle,
497 name,
498 context,
499 } => {
500 let title = normalize_title(title, description)?;
501 let responsibility = normalize_required_text(responsibility, "responsibility")?;
502 let prompt = normalize_required_text(Some(prompt), "prompt")?;
503 let subagent_type = subagent_type
508 .map(|value| value.trim().to_string())
509 .filter(|value| !value.is_empty())
510 .unwrap_or_else(|| "general-purpose".to_string());
511 let workspace = workspace
513 .map(|value| value.trim().to_string())
514 .filter(|value| !value.is_empty())
515 .or_else(|| parent.workspace.clone())
516 .ok_or_else(|| {
517 ToolError::InvalidArguments(
518 "workspace must be non-empty (parent has no workspace to inherit)"
519 .to_string(),
520 )
521 })?;
522
523 if parent.model.trim().is_empty() {
524 return Err(ToolError::Execution(
525 "parent session model is empty".to_string(),
526 ));
527 }
528
529 let should_auto_run = auto_run.unwrap_or(true);
530
531 let is_resident = lifecycle.as_deref().map(str::trim) == Some("resident");
539 let resident_name = is_resident.then(|| {
540 name.as_deref()
541 .map(str::trim)
542 .filter(|n| !n.is_empty())
543 .map(str::to_string)
544 .unwrap_or_else(|| subagent_type.clone())
545 });
546 let resident_context = context
547 .as_deref()
548 .map(str::trim)
549 .filter(|c| matches!(*c, "reset" | "accumulate"))
550 .unwrap_or("reset")
551 .to_string();
552 let existing_resident = match resident_name.as_deref() {
553 Some(rname) => {
554 self.sessions.find_resident_child(&parent.id, rname).await
558 }
559 None => None,
560 };
561
562 let (child_session_id, child_model, reused) =
563 if let Some(existing_id) = existing_resident {
564 if self.sessions.is_child_running(&existing_id).await {
572 self.sessions
573 .cancel_child_run_and_wait(&existing_id)
574 .await
575 .map_err(tool_error_from_child_session)?;
576 }
577 if resident_context == "accumulate" {
580 child_session::send_message_to_child_action(
581 self.sessions.as_ref(),
582 &parent,
583 existing_id.clone(),
584 format!("# Task: {title}\n\n{responsibility}\n\n{prompt}"),
585 Some(should_auto_run),
586 Some(false),
587 )
588 .await
589 .map_err(tool_error_from_child_session)?;
590 } else {
591 child_session::update_child_action(
592 self.sessions.as_ref(),
593 &parent.id,
594 existing_id.clone(),
595 Some(title.clone()),
596 Some(responsibility.clone()),
597 Some(prompt.clone()),
598 Some(subagent_type.clone()),
599 Some(true),
600 reasoning_effort,
601 )
602 .await
603 .map_err(tool_error_from_child_session)?;
604 if should_auto_run {
605 let child = self
606 .sessions
607 .load_child_for_parent(&parent.id, &existing_id)
608 .await
609 .map_err(tool_error_from_child_session)?;
610 self.sessions
611 .enqueue_child_run(&parent, &child)
612 .await
613 .map_err(tool_error_from_child_session)?;
614 }
615 }
616 let model = self
617 .sessions
618 .load_child_for_parent(&parent.id, &existing_id)
619 .await
620 .map(|c| c.model)
621 .unwrap_or_default();
622 (existing_id, model, true)
623 } else {
624 let child_id = Uuid::new_v4().to_string();
625 let model_ref_override =
628 match model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
629 Some(spec) => Some(parse_model_spec(
630 spec,
631 &parent,
632 self.catalog.as_ref().map(|c| c.default_provider()),
633 )?),
634 None => self.resolver.resolve_subagent_model(&subagent_type).await,
635 };
636 let model_override = model_ref_override
637 .as_ref()
638 .map(|model_ref| model_ref.model.clone());
639 let runtime_metadata =
640 self.resolver.resolve_runtime_metadata(&subagent_type).await;
641 let system_prompt_override =
642 Some(self.resolver.resolve_subagent_prompt(&subagent_type));
643 let result = child_session::create_child_action(
644 self.sessions.as_ref(),
645 CreateChildInput {
646 parent_session: parent.clone(),
647 child_id: child_id.clone(),
648 title: title.clone(),
649 responsibility: responsibility.clone(),
650 assignment_prompt: prompt.clone(),
651 subagent_type: subagent_type.clone(),
652 workspace: workspace.clone(),
653 model_override,
654 model_ref_override,
655 runtime_metadata,
656 system_prompt_override,
657 auto_run: should_auto_run,
658 reasoning_effort,
659 lifecycle: resident_name.as_ref().map(|_| "resident".to_string()),
660 resident_name: resident_name.clone(),
661 resident_context: resident_name
662 .as_ref()
663 .map(|_| resident_context.clone()),
664 },
665 )
666 .await
667 .map_err(tool_error_from_child_session)?;
668 (result.child_session_id, result.model, false)
669 };
670
671 self.sessions.ensure_child_indexed(&child_session_id).await;
673
674 ctx.emit_tool_token(if reused {
675 format!("Reused resident agent: {child_session_id}")
676 } else {
677 format!("Spawned child session: {child_session_id}")
678 })
679 .await;
680
681 let should_wait = should_auto_run && wait.unwrap_or(false);
686 if should_wait {
687 self.sessions
688 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
689 .await
690 .map_err(tool_error_from_child_session)?;
691 }
692
693 let status = if !should_auto_run {
694 "created"
695 } else if should_wait {
696 "queued"
697 } else {
698 "running_in_background"
699 };
700 let note = if should_wait {
701 "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."
702 } else if should_auto_run {
703 "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."
704 } else {
705 "Child session created (not started). Use action=run to start it. Use send_message (not create) to correct a child in place."
706 };
707 let payload = json!({
708 "title": title.clone(),
709 "description": title,
710 "responsibility": responsibility,
711 "prompt": prompt,
712 "subagent_type": subagent_type,
713 "child_session_id": child_session_id,
714 "parent_session_id": parent_session_id,
715 "model": child_model,
716 "reasoning_effort": reasoning_effort.map(|effort| effort.as_str()),
717 "status": status,
718 "lifecycle": resident_name.as_ref().map(|_| "resident"),
719 "resident_name": resident_name.clone(),
720 "reused": reused,
721 "note": note,
722 });
723 if should_wait {
724 waiting_for_children_tool_result(payload)
725 } else {
726 tool_result(payload)
727 }
728 }
729 SubAgentArgs::Wait {
730 child_session_ids,
731 wait_for,
732 } => {
733 let policy = wait_for.unwrap_or(ChildWaitPolicy::All);
734 let targets = match child_session_ids {
737 Some(ids) if !ids.is_empty() => ids,
738 _ => self.sessions.active_child_ids(&parent.id).await,
739 };
740
741 if targets.is_empty() {
742 return tool_result(json!({
745 "status": "no_active_children",
746 "parent_session_id": parent_session_id,
747 "note": "No active child sessions to wait for; the parent continues running.",
748 }));
749 }
750
751 let count = self
752 .sessions
753 .register_parent_wait_for_children(&parent.id, &targets, policy)
754 .await
755 .map_err(tool_error_from_child_session)?;
756
757 waiting_for_children_tool_result(json!({
758 "status": "waiting",
759 "parent_session_id": parent_session_id,
760 "child_session_ids": targets,
761 "wait_for": policy.as_str(),
762 "waiting_on": count,
763 }))
764 }
765 SubAgentArgs::List => {
766 let result =
767 child_session::list_children_action(self.sessions.as_ref(), &parent.id).await;
768 tool_result(result)
769 }
770 SubAgentArgs::Get { child_session_id } => {
771 let result = child_session::get_child_action(
772 self.sessions.as_ref(),
773 &parent.id,
774 child_session_id,
775 )
776 .await
777 .map_err(tool_error_from_child_session)?;
778 tool_result(result)
779 }
780 SubAgentArgs::Update {
781 child_session_id,
782 title,
783 responsibility,
784 prompt,
785 subagent_type,
786 reset_after_update,
787 auto_run,
788 reasoning_effort,
789 } => {
790 let result = child_session::update_child_action(
791 self.sessions.as_ref(),
792 &parent.id,
793 child_session_id.clone(),
794 title,
795 responsibility,
796 prompt,
797 subagent_type,
798 reset_after_update,
799 reasoning_effort,
800 )
801 .await
802 .map_err(tool_error_from_child_session)?;
803
804 let should_auto_run = auto_run.unwrap_or(false);
805 if should_auto_run {
806 let child = self
807 .sessions
808 .load_child_for_parent(&parent.id, &child_session_id)
809 .await
810 .map_err(tool_error_from_child_session)?;
811 self.sessions
812 .enqueue_child_run(&parent, &child)
813 .await
814 .map_err(tool_error_from_child_session)?;
815 self.sessions
819 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
820 .await
821 .map_err(tool_error_from_child_session)?;
822 }
823
824 if should_auto_run {
825 waiting_for_children_tool_result(result)
826 } else {
827 tool_result(result)
828 }
829 }
830 SubAgentArgs::Run {
831 child_session_id,
832 reset_to_last_user,
833 } => {
834 let result = child_session::run_child_action(
835 self.sessions.as_ref(),
836 &parent,
837 child_session_id.clone(),
838 reset_to_last_user,
839 )
840 .await
841 .map_err(tool_error_from_child_session)?;
842 self.sessions
844 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
845 .await
846 .map_err(tool_error_from_child_session)?;
847 waiting_for_children_tool_result(result)
848 }
849 SubAgentArgs::SendMessage {
850 child_session_id,
851 message,
852 auto_run,
853 interrupt_running,
854 } => {
855 let should_auto_run = auto_run.unwrap_or(true);
856 let result = child_session::send_message_to_child_action(
857 self.sessions.as_ref(),
858 &parent,
859 child_session_id.clone(),
860 message,
861 auto_run,
862 interrupt_running,
863 )
864 .await
865 .map_err(tool_error_from_child_session)?;
866 let queued = should_auto_run
867 && result
868 .get("status")
869 .and_then(|value| value.as_str())
870 .is_some_and(|status| status == "queued");
871 if queued {
872 self.sessions
875 .register_parent_wait_for_child(&parent.id, &child_session_id, None)
876 .await
877 .map_err(tool_error_from_child_session)?;
878 waiting_for_children_tool_result(result)
879 } else {
880 tool_result(result)
881 }
882 }
883 SubAgentArgs::Cancel { child_session_id } => {
884 let result = child_session::cancel_child_action(
885 self.sessions.as_ref(),
886 &parent.id,
887 child_session_id,
888 )
889 .await
890 .map_err(tool_error_from_child_session)?;
891 tool_result(result)
892 }
893 SubAgentArgs::Delete { child_session_id } => {
894 let result = child_session::delete_child_action(
895 self.sessions.as_ref(),
896 &parent.id,
897 child_session_id,
898 )
899 .await
900 .map_err(tool_error_from_child_session)?;
901 tool_result(result)
902 }
903 SubAgentArgs::ListProfiles => tool_result(self.list_profiles_payload()),
906 SubAgentArgs::ListModels => unreachable!("list_models short-circuits earlier"),
908 }
909 }
910}
911
912impl SubAgentTool {
913 fn list_profiles_payload(&self) -> serde_json::Value {
940 let profiles: Vec<serde_json::Value> = self
944 .profiles
945 .iter()
946 .map(|p| {
947 json!({
948 "id": p.id,
949 "display_name": p.display_name,
950 "description": p.description,
951 "tools": p.tools,
952 "model_hint": p.model_hint,
953 "default_responsibility": p.default_responsibility,
954 "ui": p.ui,
955 })
956 })
957 .collect();
958 json!({
959 "profiles": profiles,
960 "fallback_id": self.profiles.fallback_id(),
961 "count": self.profiles.len(),
962 })
963 }
964}
965
966#[cfg(test)]
976mod tests {
977 use super::*;
978
979 #[test]
980 fn normalize_title_accepts_legacy_description() {
981 let title = normalize_title(None, "Search refs".to_string()).unwrap();
982 assert_eq!(title, "Search refs");
983 }
984
985 #[test]
986 fn normalize_title_prefers_title_over_description() {
987 let title =
988 normalize_title(Some("Real title".to_string()), "Legacy desc".to_string()).unwrap();
989 assert_eq!(title, "Real title");
990 }
991
992 #[test]
993 fn normalize_title_rejects_both_empty() {
994 let err = normalize_title(None, "".to_string()).unwrap_err();
995 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("title")));
996 }
997
998 fn parent_session(
1001 model_ref: Option<bamboo_domain::ProviderModelRef>,
1002 ) -> bamboo_agent_core::Session {
1003 let mut session = bamboo_agent_core::Session::new("p1", "gpt-test");
1004 session.model_ref = model_ref;
1005 session
1006 }
1007
1008 #[test]
1009 fn model_spec_provider_colon_model_is_explicit() {
1010 let parent = parent_session(None);
1011 let r = parse_model_spec("anthropic:claude-sonnet-4-6", &parent, None).unwrap();
1012 assert_eq!(r.provider, "anthropic");
1013 assert_eq!(r.model, "claude-sonnet-4-6");
1014 }
1015
1016 #[test]
1017 fn model_spec_bare_inherits_parent_provider() {
1018 let parent = parent_session(Some(bamboo_domain::ProviderModelRef::new(
1019 "openai", "gpt-test",
1020 )));
1021 let r = parse_model_spec("o4-mini", &parent, Some("anthropic".to_string())).unwrap();
1022 assert_eq!(r.provider, "openai"); assert_eq!(r.model, "o4-mini");
1024 }
1025
1026 #[test]
1027 fn model_spec_bare_falls_back_to_default_provider() {
1028 let parent = parent_session(None);
1029 let r =
1030 parse_model_spec("claude-haiku-4-5", &parent, Some("anthropic".to_string())).unwrap();
1031 assert_eq!(r.provider, "anthropic");
1032 }
1033
1034 #[test]
1035 fn model_spec_bare_without_any_provider_errors() {
1036 let parent = parent_session(None);
1037 let err = parse_model_spec("mystery-model", &parent, None).unwrap_err();
1038 assert!(matches!(err, ToolError::InvalidArguments(msg) if msg.contains("provider")));
1039 }
1040
1041 #[test]
1042 fn model_spec_rejects_malformed() {
1043 let parent = parent_session(None);
1044 assert!(parse_model_spec(" ", &parent, None).is_err());
1045 assert!(parse_model_spec("anthropic:", &parent, None).is_err());
1046 assert!(parse_model_spec(":model", &parent, None).is_err());
1047 }
1048}