1use bamboo_domain::Session;
4use chrono::Utc;
5use serde_json::json;
6
7use super::helpers::{
8 compute_status_guidance, format_child_assignment, map_child_entry, metadata_text,
9 normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
10 replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
11};
12use super::DELEGATION_NOTE;
13use super::{
14 ChildSessionEntry, ChildSessionError, ChildSessionPort, CreateChildInput, CreateChildResult,
15 QueuedInjectedMessage,
16};
17
18pub async fn create_child_action(
19 port: &dyn ChildSessionPort,
20 input: CreateChildInput,
21) -> Result<CreateChildResult, ChildSessionError> {
22 use crate::runner::refresh_prompt_snapshot;
23 use bamboo_agent_core::Message;
24
25 let inherited_project_id =
26 match crate::project_context::ProjectContextResolver::session_project_identity(
27 &input.parent_session,
28 ) {
29 crate::project_context::SessionProjectIdentity::Assigned(project_id) => {
30 Some(project_id)
31 }
32 crate::project_context::SessionProjectIdentity::Unassigned => None,
33 crate::project_context::SessionProjectIdentity::Invalid { raw, message } => {
34 return Err(ChildSessionError::InvalidArguments(format!(
35 "parent session carries an invalid Project identity '{raw}': {message}"
36 )));
37 }
38 };
39 let final_workspace = port
40 .validate_child_workspace(inherited_project_id.as_ref(), &input.workspace)
41 .await?;
42
43 let mut child = Session::new_child_of(
48 input.child_id.clone(),
49 &input.parent_session,
50 input
51 .model_ref_override
52 .as_ref()
53 .map(|model_ref| model_ref.model.clone())
54 .or_else(|| input.model_override.clone())
55 .unwrap_or_else(|| input.parent_session.model.clone()),
56 input.title.clone(),
57 );
58 if let Some(project_id) = inherited_project_id {
61 child.set_project_id_meta(project_id.to_string());
62 }
63
64 if let Some(model_ref) = input.model_ref_override.clone() {
65 child.model_ref = Some(model_ref.clone());
66 child
67 .metadata
68 .insert("provider_name".to_string(), model_ref.provider);
69 } else if let Some(parent_model_ref) = input.parent_session.model_ref.clone() {
70 child.model_ref = Some(parent_model_ref.clone());
71 child.set_provider_name(parent_model_ref.provider);
72 } else if let Some(parent_provider) = input.parent_session.provider_name() {
73 child.set_provider_name(parent_provider);
74 }
75
76 if let Some(effort) = input.reasoning_effort {
80 child.reasoning_effort = Some(effort);
81 }
82
83 if input
88 .parent_session
89 .agent_runtime_state
90 .as_ref()
91 .is_some_and(|state| state.bypass_permissions)
92 {
93 child
94 .agent_runtime_state
95 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
96 .bypass_permissions = true;
97 }
98
99 if input
104 .parent_session
105 .agent_runtime_state
106 .as_ref()
107 .is_some_and(|state| state.no_human_approver)
108 {
109 child
110 .agent_runtime_state
111 .get_or_insert_with(bamboo_domain::AgentRuntimeState::default)
112 .no_human_approver = true;
113 }
114
115 let stored_workspace = bamboo_agent_core::workspace_state::set_workspace(
120 &child.id,
121 std::path::PathBuf::from(final_workspace),
122 );
123 child.workspace = Some(stored_workspace.to_string_lossy().to_string());
124
125 child
126 .metadata
127 .insert("spawned_by".to_string(), "SubAgent".to_string());
128 child.set_subagent_type(input.subagent_type.clone());
129 child
130 .metadata
131 .insert("responsibility".to_string(), input.responsibility.clone());
132 child.metadata.insert(
133 "assignment_prompt".to_string(),
134 input.assignment_prompt.clone(),
135 );
136 if input.lifecycle.as_deref() == Some("resident") {
141 child
142 .metadata
143 .insert("lifecycle".to_string(), "resident".to_string());
144 if let Some(name) = input.resident_name.clone().filter(|n| !n.trim().is_empty()) {
145 child.metadata.insert("resident_name".to_string(), name);
146 }
147 child.metadata.insert(
148 "resident_context".to_string(),
149 input
150 .resident_context
151 .clone()
152 .filter(|c| matches!(c.as_str(), "reset" | "accumulate"))
153 .unwrap_or_else(|| "reset".to_string()),
154 );
155 }
156 child.set_last_run_status("pending");
157 child.clear_last_run_error();
158
159 for (key, value) in input.runtime_metadata {
161 child.metadata.insert(key, value);
162 }
163
164 let base_prompt = {
171 let global = crate::prompt_defaults::read_global_default_system_prompt_template();
172 if global.trim().is_empty() {
173 crate::context::DEFAULT_BASE_PROMPT.to_string()
174 } else {
175 global
176 }
177 };
178 let system_prompt = format!("{base_prompt}\n\n{DELEGATION_NOTE}");
179
180 child
181 .metadata
182 .insert("base_system_prompt".to_string(), system_prompt.clone());
183
184 child.add_message(Message::system(&system_prompt));
185
186 if let Some(parent_budget) = input.parent_session.effective_token_budget() {
190 let mut child_budget = parent_budget.clone();
191 child_budget.compression_trigger_percent = 70;
192 child_budget.compression_target_percent = 35;
193 child.token_budget = Some(child_budget);
194 }
195
196 refresh_prompt_snapshot(&mut child);
197 let assignment = format_child_assignment(
198 &input.title,
199 &input.responsibility,
200 &input.subagent_type,
201 &input.assignment_prompt,
202 );
203 let assignment = match input
207 .context_fork
208 .and_then(|n| render_forked_parent_context(&input.parent_session, n))
209 {
210 Some(forked) => format!("{forked}\n\n{assignment}"),
211 None => assignment,
212 };
213 child.add_message(Message::user(assignment));
214
215 if let Some(parent_task_list) = input.parent_session.task_list.clone() {
216 child.set_task_list(parent_task_list);
217 }
218
219 if let Some(ref disabled) = input.disabled_tools {
223 if !disabled.is_empty() {
224 child.metadata.insert(
225 "disabled_tools".to_string(),
226 serde_json::to_string(disabled).unwrap_or_default(),
227 );
228 }
229 }
230
231 let model = child.model.clone();
232 port.save_child_session(&mut child).await?;
233 if input.auto_run {
234 port.enqueue_child_run(&input.parent_session, &child)
235 .await?;
236 }
237
238 Ok(CreateChildResult {
239 child_session_id: child.id,
240 model,
241 })
242}
243
244pub async fn list_children_action(
245 port: &dyn ChildSessionPort,
246 parent_id: &str,
247) -> serde_json::Value {
248 let children = port.list_children(parent_id).await;
249 json!({
250 "parent_session_id": parent_id,
251 "children": children.iter().map(map_child_entry).collect::<Vec<_>>(),
252 "count": children.len(),
253 })
254}
255
256#[derive(Debug, Clone, PartialEq, serde::Serialize)]
259pub struct SessionTreeNode {
260 pub session_id: String,
261 pub title: String,
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub last_run_status: Option<String>,
264 pub depth: u32,
265 pub children: Vec<SessionTreeNode>,
266}
267
268pub fn assemble_session_tree(
273 root_id: &str,
274 root_title: &str,
275 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
276 max_depth: u32,
277) -> SessionTreeNode {
278 fn build(
279 id: &str,
280 title: &str,
281 status: Option<String>,
282 depth: u32,
283 max_depth: u32,
284 adjacency: &std::collections::HashMap<String, Vec<ChildSessionEntry>>,
285 visited: &mut std::collections::HashSet<String>,
286 ) -> SessionTreeNode {
287 let first_visit = visited.insert(id.to_string());
288 let mut children = Vec::new();
289 if first_visit && depth < max_depth {
290 if let Some(kids) = adjacency.get(id) {
291 for kid in kids {
292 children.push(build(
293 &kid.child_session_id,
294 &kid.title,
295 kid.last_run_status.clone(),
296 depth + 1,
297 max_depth,
298 adjacency,
299 visited,
300 ));
301 }
302 }
303 }
304 SessionTreeNode {
305 session_id: id.to_string(),
306 title: title.to_string(),
307 last_run_status: status,
308 depth,
309 children,
310 }
311 }
312 let mut visited = std::collections::HashSet::new();
313 build(
314 root_id,
315 root_title,
316 None,
317 0,
318 max_depth,
319 adjacency,
320 &mut visited,
321 )
322}
323
324pub async fn build_session_tree_action(
330 port: &dyn ChildSessionPort,
331 root_id: &str,
332 max_depth: u32,
333) -> SessionTreeNode {
334 use std::collections::{HashMap, HashSet, VecDeque};
335 const NODE_CAP: usize = 5000;
336
337 let root_title = port
338 .load_root_session(root_id)
339 .await
340 .map(|s| s.title)
341 .unwrap_or_default();
342
343 let mut adjacency: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
344 let mut visited: HashSet<String> = HashSet::new();
345 let mut queue: VecDeque<(String, u32)> = VecDeque::new();
346 queue.push_back((root_id.to_string(), 0));
347
348 while let Some((id, depth)) = queue.pop_front() {
349 if depth >= max_depth || adjacency.len() >= NODE_CAP || !visited.insert(id.clone()) {
350 continue;
351 }
352 let kids = port.list_children(&id).await;
353 for kid in &kids {
354 queue.push_back((kid.child_session_id.clone(), depth + 1));
355 }
356 adjacency.insert(id, kids);
357 }
358
359 assemble_session_tree(root_id, &root_title, &adjacency, max_depth)
360}
361
362pub async fn get_child_action(
363 port: &dyn ChildSessionPort,
364 parent_id: &str,
365 child_session_id: String,
366) -> Result<serde_json::Value, ChildSessionError> {
367 let child = port
368 .load_child_for_parent(parent_id, &child_session_id)
369 .await?;
370
371 let status = metadata_text(&child, "last_run_status");
372 let runner_info = port.get_child_runner_info(&child.id).await;
373
374 Ok(json!({
375 "child_session_id": child.id,
376 "title": child.title,
377 "model": child.model,
378 "pinned": child.pinned,
379 "message_count": child.messages.len(),
380 "is_running": port.is_child_running(&child.id).await,
381 "last_run_status": status,
382 "last_run_error": metadata_text(&child, "last_run_error"),
383 "responsibility": metadata_text(&child, "responsibility"),
384 "subagent_type": metadata_text(&child, "subagent_type"),
385 "prompt": metadata_text(&child, "assignment_prompt"),
386 "latest_user_message": child
387 .messages
388 .iter()
389 .rposition(|message| matches!(message.role, bamboo_agent_core::Role::User))
390 .and_then(|idx| child.messages.get(idx))
391 .map(|message| message.content.clone()),
392 "runtime_kind": metadata_text(&child, "runtime.kind"),
393 "external_protocol": metadata_text(&child, "external.protocol"),
394 "external_agent_id": metadata_text(&child, "external.agent_id"),
395 "a2a_context_id": metadata_text(&child, "a2a.context_id"),
396 "a2a_latest_task_id": metadata_text(&child, "a2a.latest_task_id"),
397 "a2a_last_state": metadata_text(&child, "a2a.last_state"),
398 "runner_started_at": runner_info.as_ref().and_then(|r| r.started_at.map(|t| t.to_rfc3339())),
399 "runner_completed_at": runner_info.as_ref().and_then(|r| r.completed_at.map(|t| t.to_rfc3339())),
400 "last_tool_name": runner_info.as_ref().and_then(|r| r.last_tool_name.clone()),
401 "last_tool_phase": runner_info.as_ref().and_then(|r| r.last_tool_phase.clone()),
402 "last_event_at": runner_info.as_ref().and_then(|r| r.last_event_at.map(|t| t.to_rfc3339())),
403 "round_count": runner_info.as_ref().map(|r| r.round_count).unwrap_or(0),
404 "has_pending_injected_messages": child.has_pending_injected_messages(),
405 "guidance": compute_status_guidance(status.as_deref(), runner_info.as_ref(), child.has_pending_injected_messages()),
406 }))
407}
408
409#[allow(clippy::too_many_arguments)]
410pub async fn update_child_action(
411 port: &dyn ChildSessionPort,
412 parent_id: &str,
413 child_session_id: String,
414 title: Option<String>,
415 responsibility: Option<String>,
416 prompt: Option<String>,
417 subagent_type: Option<String>,
418 reset_after_update: Option<bool>,
419 reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
420) -> Result<serde_json::Value, ChildSessionError> {
421 let mut child = port
422 .load_child_for_parent(parent_id, &child_session_id)
423 .await?;
424
425 let title = normalize_non_empty_optional(title, "title")?;
426 let responsibility = normalize_non_empty_optional(responsibility, "responsibility")?;
427 let prompt = normalize_non_empty_optional(prompt, "prompt")?;
428 let subagent_type = normalize_non_empty_optional(subagent_type, "subagent_type")?;
429
430 let should_refresh_assignment =
431 responsibility.is_some() || prompt.is_some() || subagent_type.is_some();
432
433 if title.is_none() && !should_refresh_assignment && reasoning_effort.is_none() {
434 return Err(ChildSessionError::InvalidArguments(
435 "update requires at least one field: title/responsibility/prompt/subagent_type/reasoning_effort"
436 .to_string(),
437 ));
438 }
439
440 if let Some(effort) = reasoning_effort {
441 child.reasoning_effort = Some(effort);
442 }
443
444 if let Some(title) = title {
445 child.title = title;
446 }
447
448 let mut messages_removed = 0usize;
449
450 if should_refresh_assignment {
451 let effective_responsibility = normalize_required_text(
452 responsibility.or_else(|| metadata_text(&child, "responsibility")),
453 "responsibility",
454 )?;
455 let effective_subagent_type = normalize_required_text(
456 subagent_type.or_else(|| metadata_text(&child, "subagent_type")),
457 "subagent_type",
458 )?;
459 let effective_prompt = normalize_required_text(
460 prompt.or_else(|| metadata_text(&child, "assignment_prompt")),
461 "prompt",
462 )?;
463
464 child.metadata.insert(
465 "responsibility".to_string(),
466 effective_responsibility.clone(),
467 );
468 child
469 .metadata
470 .insert("subagent_type".to_string(), effective_subagent_type.clone());
471 child
472 .metadata
473 .insert("assignment_prompt".to_string(), effective_prompt.clone());
474 child.set_last_run_status("pending");
475 child.clear_last_run_error();
476
477 let assignment = format_child_assignment(
478 &child.title,
479 &effective_responsibility,
480 &effective_subagent_type,
481 &effective_prompt,
482 );
483 let user_index = replace_or_append_last_user_message(&mut child, assignment);
484
485 if reset_after_update.unwrap_or(true) {
486 messages_removed = truncate_after_index(&mut child, user_index);
487 }
488 }
489
490 child.updated_at = Utc::now();
491 port.save_child_session(&mut child).await?;
492
493 Ok(json!({
494 "child_session_id": child.id,
495 "title": child.title,
496 "messages_removed": messages_removed,
497 "last_run_status": metadata_text(&child, "last_run_status"),
498 "note": "Child session updated in place. Use action=run to execute the same child session.",
499 }))
500}
501
502pub async fn run_child_action(
503 port: &dyn ChildSessionPort,
504 parent: &Session,
505 child_session_id: String,
506 reset_to_last_user: Option<bool>,
507) -> Result<serde_json::Value, ChildSessionError> {
508 let mut child = port
509 .load_child_for_parent(&parent.id, &child_session_id)
510 .await?;
511
512 if port.is_child_running(&child.id).await {
513 return Ok(json!({
514 "child_session_id": child.id,
515 "status": "already_running",
516 "note": "Child session is already running.",
517 }));
518 }
519
520 let mut messages_removed = 0usize;
521 if reset_to_last_user.unwrap_or(true) {
522 messages_removed = truncate_after_last_user(&mut child)?;
523 }
524
525 child.set_last_run_status("pending");
526 child.clear_last_run_error();
527 child.updated_at = Utc::now();
528 port.save_child_session(&mut child).await?;
529
530 port.enqueue_child_run(parent, &child).await?;
531
532 Ok(json!({
533 "child_session_id": child.id,
534 "status": "queued",
535 "messages_removed": messages_removed,
536 "note": "Queued existing child session for retry in place.",
537 }))
538}
539
540pub async fn send_message_to_child_action(
541 port: &dyn ChildSessionPort,
542 parent: &Session,
543 child_session_id: String,
544 message: String,
545 auto_run: Option<bool>,
546 interrupt_running: Option<bool>,
547) -> Result<serde_json::Value, ChildSessionError> {
548 let mut child = port
549 .load_child_for_parent(&parent.id, &child_session_id)
550 .await?;
551
552 let is_running = port.is_child_running(&child.id).await;
553 let should_interrupt = interrupt_running.unwrap_or(false);
554
555 if is_running && should_interrupt {
556 port.cancel_child_run_and_wait(&child.id).await?;
557 child = port
558 .load_child_for_parent(&parent.id, &child_session_id)
559 .await?;
560 }
561
562 let message = normalize_required_text(Some(message), "message")?;
563
564 if is_running && !should_interrupt {
565 if crate::external_agents::live::deliver_message(&child.id, &message) {
571 child.add_message(bamboo_agent_core::Message::user(message.clone()));
572 port.save_child_session(&mut child).await?;
573 return Ok(json!({
574 "child_session_id": child.id,
575 "status": "message_delivered_live",
576 "auto_run": false,
577 "message": message,
578 "message_count": child.messages.len(),
579 "note": "Message delivered to the running actor in-band; it will be admitted at the next round boundary without canceling progress.",
580 }));
581 }
582
583 let mut pending = child.pending_injected_messages().unwrap_or_default();
588 let queued = QueuedInjectedMessage {
589 content: message.clone(),
590 created_at: Some(chrono::Utc::now()),
591 };
592 pending.push(serde_json::to_value(&queued).unwrap_or(serde_json::Value::Null));
593 child.set_pending_injected_messages(pending);
594 port.save_child_session(&mut child).await?;
595
596 if !port.is_child_running(&child.id).await {
601 port.enqueue_child_run(parent, &child).await?;
602 return Ok(json!({
603 "child_session_id": child.id,
604 "status": "queued",
605 "auto_run": true,
606 "message": message,
607 "message_count": child.messages.len(),
608 "note": "Child finished while the message was being queued; a new run was scheduled to process it.",
609 }));
610 }
611
612 return Ok(json!({
613 "child_session_id": child.id,
614 "status": "message_queued",
615 "auto_run": false,
616 "message": message,
617 "message_count": child.messages.len(),
618 "note": "Message queued for the child session. It will be picked up at the next turn boundary without canceling current progress.",
619 }));
620 }
621
622 child.add_message(bamboo_agent_core::Message::user(message.clone()));
623 child.set_last_run_status("pending");
624 child.clear_last_run_error();
625 port.save_child_session(&mut child).await?;
626
627 let should_auto_run = auto_run.unwrap_or(true);
628 if should_auto_run {
629 port.enqueue_child_run(parent, &child).await?;
630 }
631
632 Ok(json!({
633 "child_session_id": child.id,
634 "status": if should_auto_run { "queued" } else { "pending" },
635 "auto_run": should_auto_run,
636 "message": message,
637 "message_count": child.messages.len(),
638 "note": if should_auto_run {
639 "Follow-up message appended and child session queued."
640 } else {
641 "Follow-up message appended. Use action=run to execute the child session."
642 },
643 }))
644}
645
646pub async fn cancel_child_action(
647 port: &dyn ChildSessionPort,
648 parent_id: &str,
649 child_session_id: String,
650) -> Result<serde_json::Value, ChildSessionError> {
651 let _ = port
653 .load_child_for_parent(parent_id, &child_session_id)
654 .await?;
655 port.cancel_child_run_and_wait(&child_session_id).await?;
656
657 let mut child = port
662 .load_child_for_parent(parent_id, &child_session_id)
663 .await?;
664 let latest_status = child.last_run_status().unwrap_or_default();
665 if matches!(latest_status.as_str(), "completed" | "error") {
666 return Ok(json!({
667 "child_session_id": child_session_id,
668 "status": latest_status,
669 "note": "Child reached a natural terminal state while the cancel was in flight; its real outcome was kept.",
670 }));
671 }
672 child.set_last_run_status("cancelled");
673 child.set_last_run_error("Cancelled by parent");
674 port.save_child_session(&mut child).await?;
675 Ok(json!({
676 "child_session_id": child_session_id,
677 "status": "cancelled",
678 }))
679}
680
681pub async fn delete_child_action(
682 port: &dyn ChildSessionPort,
683 parent_id: &str,
684 child_session_id: String,
685) -> Result<serde_json::Value, ChildSessionError> {
686 let child = port
688 .load_child_for_parent(parent_id, &child_session_id)
689 .await?;
690 let result = port.delete_child_session(parent_id, &child.id).await?;
691
692 if !result.deleted {
693 return Err(ChildSessionError::Execution(format!(
694 "child session was not deleted: {}",
695 child.id
696 )));
697 }
698
699 Ok(json!({
700 "child_session_id": child.id,
701 "deleted": true,
702 "cancelled_running_child": result.cancelled_running_child,
703 }))
704}
705
706#[cfg(test)]
707mod tree_tests {
708 use super::super::ChildSessionEntry;
709 use super::assemble_session_tree;
710 use std::collections::HashMap;
711
712 fn entry(id: &str, title: &str) -> ChildSessionEntry {
713 ChildSessionEntry {
714 child_session_id: id.to_string(),
715 title: title.to_string(),
716 pinned: false,
717 message_count: 0,
718 updated_at: String::new(),
719 last_run_status: Some("completed".to_string()),
720 last_run_error: None,
721 }
722 }
723
724 #[test]
725 fn assembles_multi_level_tree() {
726 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
727 adj.insert(
728 "root".into(),
729 vec![entry("c1", "child 1"), entry("c2", "child 2")],
730 );
731 adj.insert("c1".into(), vec![entry("g1", "grandchild")]);
732
733 let tree = assemble_session_tree("root", "Root", &adj, 8);
734 assert_eq!(tree.session_id, "root");
735 assert_eq!(tree.depth, 0);
736 assert_eq!(tree.children.len(), 2);
737 let c1 = tree.children.iter().find(|n| n.session_id == "c1").unwrap();
738 assert_eq!(c1.depth, 1);
739 assert_eq!(c1.children.len(), 1);
740 assert_eq!(c1.children[0].session_id, "g1");
741 assert_eq!(c1.children[0].depth, 2);
742 let c2 = tree.children.iter().find(|n| n.session_id == "c2").unwrap();
743 assert!(c2.children.is_empty());
744 }
745
746 #[test]
747 fn depth_cap_stops_descent() {
748 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
749 adj.insert("root".into(), vec![entry("c1", "c1")]);
750 adj.insert("c1".into(), vec![entry("g1", "g1")]);
751 let tree = assemble_session_tree("root", "Root", &adj, 1);
752 assert_eq!(tree.children.len(), 1);
753 assert!(
754 tree.children[0].children.is_empty(),
755 "depth cap stops expansion at depth 1"
756 );
757 }
758
759 #[test]
760 fn cycle_is_broken_by_first_visit_guard() {
761 let mut adj: HashMap<String, Vec<ChildSessionEntry>> = HashMap::new();
762 adj.insert("a".into(), vec![entry("b", "b")]);
763 adj.insert("b".into(), vec![entry("a", "a")]); let tree = assemble_session_tree("a", "A", &adj, 100);
765 assert_eq!(tree.children.len(), 1);
766 let b = &tree.children[0];
767 assert_eq!(b.session_id, "b");
768 assert_eq!(b.children.len(), 1);
769 let a2 = &b.children[0];
770 assert_eq!(a2.session_id, "a");
771 assert!(a2.children.is_empty(), "cycle must terminate as a leaf");
772 }
773}