Skip to main content

bamboo_server/tools/
child_session_adapter.rs

1//! Shared adapter implementing `ChildSessionPort` for server-side child session tools.
2//!
3//! The unified `SubAgentTool` delegates to this adapter instead of
4//! duplicating `ChildSessionPort` implementations.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::{Duration as ChronoDuration, Utc};
11use tokio::sync::{broadcast, RwLock};
12use tokio::time::{sleep, Duration, Instant};
13
14use crate::app_state::session_events::get_or_create_event_sender;
15use crate::app_state::{AgentRunner, AgentStatus};
16use bamboo_agent_core::storage::Storage;
17use bamboo_agent_core::{AgentEvent, Session, SessionKind};
18use bamboo_domain::session::runtime_state::{
19    AgentRuntimeState, ChildWaitPolicy, WaitingForChildrenState,
20};
21use bamboo_engine::execution::spawn::{SpawnJob, SpawnScheduler};
22use bamboo_engine::session_app::child_session::{
23    ChildRunnerInfo, ChildSessionEntry, ChildSessionError, ChildSessionPort, DeleteChildResult,
24    SubagentResolutionPort,
25};
26use bamboo_llm::Config;
27use bamboo_storage::{LockedSessionStore, SessionIndexEntry, SessionStoreV2};
28
29/// Server-side adapter that bridges domain `ChildSessionPort` to infrastructure.
30///
31/// Holds all shared state needed by `SubAgentTool`.
32/// Implements the full `ChildSessionPort` trait with real methods (no stubs).
33pub struct ChildSessionAdapter {
34    pub(crate) session_store: Arc<SessionStoreV2>,
35    pub(crate) storage: Arc<dyn Storage>,
36    pub(crate) persistence: Arc<LockedSessionStore>,
37    pub(crate) scheduler: Arc<SpawnScheduler>,
38    pub(crate) sessions_cache: bamboo_engine::SessionCache,
39    pub(crate) agent_runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
40    pub(crate) session_event_senders: Arc<RwLock<HashMap<String, broadcast::Sender<AgentEvent>>>>,
41    /// Optional subagent model resolver: maps subagent_type → provider+model ref.
42    pub(crate) subagent_model_resolver: crate::tools::OptionalSubagentModelResolver,
43    /// Application config for resolving subagent routing and external agent profiles.
44    pub(crate) config: Arc<RwLock<Config>>,
45    /// Subagent profile registry. Used to resolve `subagent_type` →
46    /// `system_prompt` and tool surface filter.
47    pub(crate) subagent_profiles: Arc<bamboo_domain::subagent::SubagentProfileRegistry>,
48    /// Cached list of all available tool names from the base executor.
49    /// Used to compute the complement set for Allowlist policies.
50    pub(crate) tool_names: Vec<String>,
51    /// Coalesces concurrent parent-wait registrations for the same parent that
52    /// arrive in one spawn round (the LLM emitting several `SubAgent.create`
53    /// calls at once → `join_all`) into a single parent persist. See
54    /// [`ChildSessionAdapter::register_parent_wait_for_child`].
55    pub(crate) parent_wait_slots: Arc<dashmap::DashMap<String, Arc<ParentWaitSlot>>>,
56}
57
58/// Per-parent coalescing slot for batched wait registration.
59///
60/// `flush_lock` is a barrier distinct from the persistence per-session lock
61/// (using the latter here would deadlock, since the flush itself takes it). The
62/// first registration to win the barrier drains `pending` and persists the whole
63/// batch once; concurrent registrations that find `pending` already drained were
64/// persisted by that holder before it released the barrier, so they return
65/// without an extra write.
66#[derive(Default)]
67pub(crate) struct ParentWaitSlot {
68    flush_lock: tokio::sync::Mutex<()>,
69    pending: parking_lot::Mutex<Vec<(String, Option<String>)>>,
70}
71
72const AGENT_RUNTIME_STATE_METADATA_KEY: &str = "agent.runtime.state";
73
74/// Terminal child run statuses, as mirrored into the session index. A child not
75/// in one of these states is considered active (still pending/running).
76fn is_terminal_child_status(status: &str) -> bool {
77    matches!(
78        status,
79        "completed" | "error" | "timeout" | "cancelled" | "skipped"
80    )
81}
82
83fn read_runtime_state(session: &Session) -> AgentRuntimeState {
84    session
85        .agent_runtime_state
86        .clone()
87        .or_else(|| {
88            session
89                .metadata
90                .get(AGENT_RUNTIME_STATE_METADATA_KEY)
91                .and_then(|raw| serde_json::from_str::<AgentRuntimeState>(raw).ok())
92        })
93        .unwrap_or_else(|| AgentRuntimeState::new(format!("{}-wait", session.id)))
94}
95
96fn write_runtime_state(session: &mut Session, runtime_state: &AgentRuntimeState) {
97    session.agent_runtime_state = Some(runtime_state.clone());
98    if let Ok(serialized) = serde_json::to_string(runtime_state) {
99        session
100            .metadata
101            .insert(AGENT_RUNTIME_STATE_METADATA_KEY.to_string(), serialized);
102    }
103}
104
105impl ChildSessionAdapter {
106    /// Resolve the provider+model ref for a given subagent_type using the configured resolver.
107    pub async fn resolve_subagent_model(
108        &self,
109        subagent_type: &str,
110    ) -> Option<bamboo_domain::ProviderModelRef> {
111        match &self.subagent_model_resolver {
112            Some(resolver) => resolver(subagent_type.to_string()).await,
113            None => None,
114        }
115    }
116
117    /// Resolve runtime metadata (e.g. external agent routing) for a subagent_type.
118    pub async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String> {
119        let config = self.config.read().await;
120        bamboo_engine::external_agents::config::resolve_runtime_metadata(&config, subagent_type)
121    }
122
123    /// Resolve the canonical system prompt for the given `subagent_type`.
124    ///
125    /// Always returns a prompt: unknown / empty `subagent_type` values fall
126    /// back to the `general-purpose` profile (whose prompt is byte-equal to
127    /// the legacy `CHILD_SYSTEM_PROMPT`).
128    pub fn resolve_subagent_prompt(&self, subagent_type: &str) -> String {
129        self.subagent_profiles
130            .resolve(subagent_type)
131            .system_prompt
132            .clone()
133    }
134
135    /// Register a durable parent wait for an enqueued child session.
136    ///
137    /// This is intentionally idempotent: repeated registrations for the same
138    /// child merge into the existing wait set. The child runner owns timeout
139    /// and liveness; the parent wait timeout is a long lease for observability.
140    ///
141    /// Registrations are **coalesced** per parent: when several children are
142    /// spawned in one round (the LLM issuing multiple `SubAgent.create` calls
143    /// that `join_all` runs concurrently), the first call to win the per-parent
144    /// barrier drains all currently-pending registrations and persists the parent
145    /// once, instead of each child triggering its own load+write. Callers whose
146    /// child was drained-and-persisted by that holder return without an extra
147    /// write — and only after the holder's write committed, so durability holds.
148    pub async fn register_parent_wait_for_child(
149        &self,
150        parent_session_id: &str,
151        child_session_id: &str,
152        tool_call_id: Option<&str>,
153    ) -> Result<(), ChildSessionError> {
154        let slot = self
155            .parent_wait_slots
156            .entry(parent_session_id.to_string())
157            .or_default()
158            .clone();
159
160        // 1. Enqueue this registration.
161        slot.pending.lock().push((
162            child_session_id.to_string(),
163            tool_call_id.map(str::to_string),
164        ));
165
166        // 2. Barrier: serialize flushers for this parent.
167        let _flush_guard = slot.flush_lock.lock().await;
168
169        // 3. Drain everything pending for this parent (siblings that enqueued
170        //    while we waited for the barrier are picked up here too).
171        let batch: Vec<(String, Option<String>)> = {
172            let mut pending = slot.pending.lock();
173            pending.drain(..).collect()
174        };
175        if batch.is_empty() {
176            // A prior barrier holder already persisted our child before releasing
177            // the barrier we just acquired — nothing left to write.
178            return Ok(());
179        }
180
181        // 4. Persist the whole batch in a single parent write.
182        if let Err(error) = self
183            .flush_parent_waits(parent_session_id, &batch, ChildWaitPolicy::All)
184            .await
185        {
186            // Re-queue so nothing is silently lost; a retry or sibling picks it up.
187            let mut pending = slot.pending.lock();
188            for item in batch {
189                pending.push(item);
190            }
191            return Err(error);
192        }
193        Ok(())
194    }
195
196    /// Explicitly register a parent wait for an arbitrary set of children with a
197    /// chosen policy. Used by the `SubAgent.wait` action (wait on all active
198    /// children) and the end-of-turn safety net. A single parent write.
199    ///
200    /// Returns the number of children the wait now covers (0 means there was
201    /// nothing to wait on and no wait was registered).
202    pub async fn register_parent_wait_for_children(
203        &self,
204        parent_session_id: &str,
205        child_session_ids: &[String],
206        policy: ChildWaitPolicy,
207    ) -> Result<usize, ChildSessionError> {
208        if child_session_ids.is_empty() {
209            return Ok(0);
210        }
211        let batch: Vec<(String, Option<String>)> = child_session_ids
212            .iter()
213            .map(|id| (id.clone(), None))
214            .collect();
215        self.flush_parent_waits(parent_session_id, &batch, policy)
216            .await?;
217        Ok(batch.len())
218    }
219
220    /// The parent's currently-active (non-terminal) children, derived from the
221    /// session index (single source of truth).
222    pub async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
223        self.storage
224            .list_child_run_statuses(parent_session_id)
225            .await
226            .unwrap_or_default()
227            .into_iter()
228            .filter(|(_, status)| !status.as_deref().is_some_and(is_terminal_child_status))
229            .map(|(id, _)| id)
230            .collect()
231    }
232
233    /// Persist a batch of parent-wait registrations in one runtime-only save.
234    async fn flush_parent_waits(
235        &self,
236        parent_session_id: &str,
237        batch: &[(String, Option<String>)],
238        policy: ChildWaitPolicy,
239    ) -> Result<(), ChildSessionError> {
240        let Some(mut parent) =
241            self.storage
242                .load_session(parent_session_id)
243                .await
244                .map_err(|error| {
245                    ChildSessionError::Execution(format!(
246                        "failed to load parent session {parent_session_id}: {error}"
247                    ))
248                })?
249        else {
250            return Err(ChildSessionError::NotFound(parent_session_id.to_string()));
251        };
252
253        // The active/completed child sets are derived from the session index
254        // (single source of truth), so we no longer maintain a denormalized copy
255        // here. Only the durable wait state below is parent-owned.
256        let mut runtime_state = read_runtime_state(&parent);
257
258        let now = Utc::now();
259        let mut wait = runtime_state
260            .waiting_for_children
261            .take()
262            .unwrap_or_else(|| WaitingForChildrenState {
263                child_session_ids: Vec::new(),
264                wait_for: policy,
265                registered_at: now,
266                timeout_at: Some(now + ChronoDuration::hours(6)),
267                registered_by_tool_call_id: None,
268            });
269        // An explicit wait re-asserts the policy on any pre-existing wait state.
270        wait.wait_for = policy;
271        for (child_session_id, tool_call_id) in batch {
272            if !wait
273                .child_session_ids
274                .iter()
275                .any(|id| id == child_session_id)
276            {
277                wait.child_session_ids.push(child_session_id.clone());
278            }
279            if wait.registered_by_tool_call_id.is_none() {
280                wait.registered_by_tool_call_id = tool_call_id.clone();
281            }
282        }
283        wait.child_session_ids.sort();
284        wait.child_session_ids.dedup();
285        runtime_state.waiting_for_children = Some(wait);
286
287        write_runtime_state(&mut parent, &runtime_state);
288        parent.metadata.insert(
289            "runtime.suspend_reason".to_string(),
290            "waiting_for_children".to_string(),
291        );
292        parent.updated_at = Utc::now();
293
294        // Runtime-only save: registering a parent's wait mutates the
295        // control-plane (runtime_state + suspend metadata) but NEVER the message
296        // history. Writing just the sidecar keeps spawn O(1) in conversation
297        // length instead of rewriting the parent's full session.json per child.
298        self.persistence
299            .save_runtime_only(&mut parent)
300            .await
301            .map_err(|error| {
302                ChildSessionError::Execution(format!("failed to save parent wait state: {error}"))
303            })?;
304        self.sessions_cache.insert(
305            parent.id.clone(),
306            Arc::new(parking_lot::RwLock::new(parent)),
307        );
308
309        Ok(())
310    }
311}
312
313fn map_index_entry_to_child_entry(entry: &SessionIndexEntry) -> ChildSessionEntry {
314    ChildSessionEntry {
315        child_session_id: entry.id.clone(),
316        title: entry.title.clone(),
317        pinned: entry.pinned,
318        message_count: entry.message_count,
319        updated_at: entry.updated_at.to_rfc3339(),
320        last_run_status: entry.last_run_status.clone(),
321        last_run_error: entry.last_run_error.clone(),
322    }
323}
324
325#[async_trait]
326impl SubagentResolutionPort for ChildSessionAdapter {
327    async fn resolve_subagent_model(
328        &self,
329        subagent_type: &str,
330    ) -> Option<bamboo_domain::ProviderModelRef> {
331        ChildSessionAdapter::resolve_subagent_model(self, subagent_type).await
332    }
333
334    async fn resolve_runtime_metadata(
335        &self,
336        subagent_type: &str,
337    ) -> std::collections::HashMap<String, String> {
338        ChildSessionAdapter::resolve_runtime_metadata(self, subagent_type).await
339    }
340
341    fn resolve_subagent_prompt(&self, subagent_type: &str) -> String {
342        ChildSessionAdapter::resolve_subagent_prompt(self, subagent_type)
343    }
344}
345
346#[async_trait]
347impl ChildSessionPort for ChildSessionAdapter {
348    async fn load_root_session(&self, root_session_id: &str) -> Result<Session, ChildSessionError> {
349        let Some(session) = self
350            .storage
351            .load_session(root_session_id)
352            .await
353            .map_err(|error| {
354                ChildSessionError::Execution(format!(
355                    "failed to load session {root_session_id}: {error}"
356                ))
357            })?
358        else {
359            return Err(ChildSessionError::NotFound(root_session_id.to_string()));
360        };
361
362        if session.kind != SessionKind::Root {
363            return Err(ChildSessionError::NotRootSession(
364                root_session_id.to_string(),
365            ));
366        }
367
368        Ok(session)
369    }
370
371    async fn load_child_for_parent(
372        &self,
373        parent_session_id: &str,
374        child_session_id: &str,
375    ) -> Result<Session, ChildSessionError> {
376        let Some(child) = self
377            .storage
378            .load_session(child_session_id)
379            .await
380            .map_err(|error| {
381                ChildSessionError::Execution(format!(
382                    "failed to load child session {child_session_id}: {error}"
383                ))
384            })?
385        else {
386            return Err(ChildSessionError::NotFound(child_session_id.to_string()));
387        };
388
389        if child.kind != SessionKind::Child {
390            return Err(ChildSessionError::NotChildSession(
391                child_session_id.to_string(),
392            ));
393        }
394
395        if child.parent_session_id.as_deref() != Some(parent_session_id) {
396            return Err(ChildSessionError::NotChildOfParent {
397                child_id: child_session_id.to_string(),
398                parent_id: parent_session_id.to_string(),
399            });
400        }
401
402        Ok(child)
403    }
404
405    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError> {
406        self.persistence
407            .merge_save_runtime(child)
408            .await
409            .map_err(|error| {
410                ChildSessionError::Execution(format!("failed to save child session: {error}"))
411            })?;
412
413        self.sessions_cache.insert(
414            child.id.clone(),
415            Arc::new(parking_lot::RwLock::new(child.clone())),
416        );
417
418        Ok(())
419    }
420
421    async fn is_child_running(&self, child_session_id: &str) -> bool {
422        let runners = self.agent_runners.read().await;
423        runners
424            .get(child_session_id)
425            .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
426    }
427
428    async fn list_children(&self, parent_session_id: &str) -> Vec<ChildSessionEntry> {
429        self.session_store
430            .list_index_entries()
431            .await
432            .into_iter()
433            .filter(|entry| {
434                entry.kind == SessionKind::Child
435                    && entry.parent_session_id.as_deref() == Some(parent_session_id)
436            })
437            .map(|entry| map_index_entry_to_child_entry(&entry))
438            .collect()
439    }
440
441    async fn find_resident_child(
442        &self,
443        root_session_id: &str,
444        resident_name: &str,
445    ) -> Option<String> {
446        let name = resident_name.trim();
447        if name.is_empty() {
448            return None;
449        }
450        // Scan the index for a child in this root tree tagged with the resident
451        // name. Prefer the most recently updated if (defensively) more than one
452        // exists. Index-backed: no session.json loads.
453        let mut best: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
454        for entry in self.session_store.list_index_entries().await {
455            if entry.kind == SessionKind::Child
456                && entry.root_session_id == root_session_id
457                && entry.resident_name.as_deref() == Some(name)
458            {
459                match &best {
460                    Some((_, ts)) if *ts >= entry.updated_at => {}
461                    _ => best = Some((entry.id.clone(), entry.updated_at)),
462                }
463            }
464        }
465        best.map(|(id, _)| id)
466    }
467
468    async fn enqueue_child_run(
469        &self,
470        parent: &Session,
471        child: &Session,
472    ) -> Result<(), ChildSessionError> {
473        let model = if child.model.trim().is_empty() {
474            parent.model.clone()
475        } else {
476            child.model.clone()
477        };
478        if model.trim().is_empty() {
479            return Err(ChildSessionError::Execution(
480                "child model is empty and parent model is unavailable".to_string(),
481            ));
482        }
483
484        // Resolve profile policy into schema-level disabled_tools.
485        let disabled_tools = child
486            .subagent_type()
487            .map(|s| s.trim().to_string())
488            .filter(|s| !s.is_empty())
489            .and_then(|subagent_type| {
490                let profile = self.subagent_profiles.resolve(&subagent_type);
491                match &profile.tools {
492                    bamboo_domain::subagent::ToolPolicy::Inherit => None,
493                    policy => {
494                        let names = bamboo_domain::subagent::disabled_tools_for_profile(
495                            policy,
496                            &self.tool_names,
497                        );
498                        if names.is_empty() {
499                            None
500                        } else {
501                            Some(names)
502                        }
503                    }
504                }
505            });
506
507        // NOTE: enqueue only *runs* the child in the background. Registering the
508        // parent's wait (which suspends the parent) is now an explicit, separate
509        // step so the model can spawn several children without each one
510        // suspending it — see `register_parent_wait_for_child` /
511        // `register_parent_wait_for_children` and the `SubAgent.wait` action.
512        self.scheduler
513            .enqueue(SpawnJob {
514                parent_session_id: parent.id.clone(),
515                child_session_id: child.id.clone(),
516                model,
517                disabled_tools,
518            })
519            .await
520            .map_err(ChildSessionError::Execution)?;
521
522        let parent_tx = get_or_create_event_sender(&self.session_event_senders, &parent.id).await;
523        let _ = parent_tx.send(AgentEvent::SubAgentStarted {
524            parent_session_id: parent.id.clone(),
525            child_session_id: child.id.clone(),
526            title: Some(child.title.clone()),
527        });
528
529        Ok(())
530    }
531
532    async fn cancel_child_run_and_wait(
533        &self,
534        child_session_id: &str,
535    ) -> Result<(), ChildSessionError> {
536        let cancelled = {
537            let mut runners = self.agent_runners.write().await;
538            if let Some(runner) = runners.get_mut(child_session_id) {
539                if matches!(runner.status, AgentStatus::Running) {
540                    runner.cancel_token.cancel();
541                    true
542                } else {
543                    false
544                }
545            } else {
546                false
547            }
548        };
549
550        if !cancelled {
551            return Ok(());
552        }
553
554        let deadline = Instant::now() + Duration::from_secs(10);
555        loop {
556            let still_running = {
557                let runners = self.agent_runners.read().await;
558                runners
559                    .get(child_session_id)
560                    .is_some_and(|runner| matches!(runner.status, AgentStatus::Running))
561            };
562            if !still_running {
563                return Ok(());
564            }
565            if Instant::now() >= deadline {
566                return Err(ChildSessionError::Execution(format!(
567                    "timed out waiting for child session {child_session_id} to stop after cancellation"
568                )));
569            }
570            sleep(Duration::from_millis(50)).await;
571        }
572    }
573
574    async fn delete_child_session(
575        &self,
576        parent_session_id: &str,
577        child_id: &str,
578    ) -> Result<DeleteChildResult, ChildSessionError> {
579        let cancelled_running_child = {
580            let mut runners = self.agent_runners.write().await;
581            if let Some(runner) = runners.remove(child_id) {
582                runner.cancel_token.cancel();
583                true
584            } else {
585                false
586            }
587        };
588
589        let deleted = self
590            .storage
591            .delete_session(child_id)
592            .await
593            .map_err(|error| {
594                ChildSessionError::Execution(format!("failed to delete child session: {error}"))
595            })?;
596
597        self.sessions_cache.remove(child_id);
598        {
599            let mut senders = self.session_event_senders.write().await;
600            senders.remove(child_id);
601            if cancelled_running_child {
602                if let Some(parent_tx) = senders.get(parent_session_id) {
603                    let _ = parent_tx.send(AgentEvent::SubAgentCompleted {
604                        parent_session_id: parent_session_id.to_string(),
605                        child_session_id: child_id.to_string(),
606                        status: "cancelled".to_string(),
607                        error: Some("Child session deleted while running".to_string()),
608                    });
609                }
610            }
611        }
612
613        Ok(DeleteChildResult {
614            deleted,
615            cancelled_running_child,
616        })
617    }
618
619    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo> {
620        let runners = self.agent_runners.read().await;
621        runners.get(child_id).map(|runner| ChildRunnerInfo {
622            started_at: Some(runner.started_at),
623            completed_at: runner.completed_at,
624            last_tool_name: runner.last_tool_name.clone(),
625            last_tool_phase: runner.last_tool_phase.clone(),
626            last_event_at: runner.last_event_at,
627            round_count: runner.round_count,
628        })
629    }
630
631    async fn register_parent_wait_for_child(
632        &self,
633        parent_session_id: &str,
634        child_session_id: &str,
635        tool_call_id: Option<&str>,
636    ) -> Result<(), ChildSessionError> {
637        ChildSessionAdapter::register_parent_wait_for_child(
638            self,
639            parent_session_id,
640            child_session_id,
641            tool_call_id,
642        )
643        .await
644    }
645
646    async fn register_parent_wait_for_children(
647        &self,
648        parent_session_id: &str,
649        child_session_ids: &[String],
650        policy: ChildWaitPolicy,
651    ) -> Result<usize, ChildSessionError> {
652        ChildSessionAdapter::register_parent_wait_for_children(
653            self,
654            parent_session_id,
655            child_session_ids,
656            policy,
657        )
658        .await
659    }
660
661    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String> {
662        ChildSessionAdapter::active_child_ids(self, parent_session_id).await
663    }
664
665    async fn ensure_child_indexed(&self, child_session_id: &str) {
666        let _ = self.session_store.get_index_entry(child_session_id).await;
667    }
668}