Skip to main content

bamboo_engine/session_app/child_session/
mod.rs

1//! Child session management use cases.
2//!
3//! Provides the application-layer logic for managing child sessions within
4//! a root session. The server layer implements `ChildSessionPort` to supply
5//! the infrastructure operations (load, save, schedule, cancel).
6
7use async_trait::async_trait;
8use bamboo_domain::session::runtime_state::ChildWaitPolicy;
9use bamboo_domain::Session;
10use std::collections::HashMap;
11
12mod actions;
13mod helpers;
14
15#[cfg(test)]
16mod tests;
17
18pub use actions::{
19    assemble_session_tree, build_session_tree_action, cancel_child_action, create_child_action,
20    delete_child_action, get_child_action, list_children_action, run_child_action,
21    send_message_to_child_action, update_child_action, SessionTreeNode,
22};
23pub use helpers::{
24    compute_status_guidance, format_child_assignment, map_child_entry, metadata_text,
25    normalize_non_empty_optional, normalize_required_text, replace_or_append_last_user_message,
26    truncate_after_index, truncate_after_last_user,
27};
28
29// ---------------------------------------------------------------------------
30// Error type
31// ---------------------------------------------------------------------------
32
33#[derive(Debug, thiserror::Error)]
34pub enum ChildSessionError {
35    #[error("session not found: {0}")]
36    NotFound(String),
37    #[error("session is not a root session: {0}")]
38    NotRootSession(String),
39    #[error("session is not a child session: {0}")]
40    NotChildSession(String),
41    #[error("child session {child_id} does not belong to parent {parent_id}")]
42    NotChildOfParent { child_id: String, parent_id: String },
43    #[error("{0}")]
44    InvalidArguments(String),
45    #[error("{0}")]
46    Execution(String),
47}
48
49// ---------------------------------------------------------------------------
50// Value types
51// ---------------------------------------------------------------------------
52
53/// Summary of a child session for listing.
54#[derive(Debug, Clone)]
55pub struct ChildSessionEntry {
56    pub child_session_id: String,
57    pub title: String,
58    pub pinned: bool,
59    pub message_count: usize,
60    pub updated_at: String,
61    pub last_run_status: Option<String>,
62    pub last_run_error: Option<String>,
63}
64
65/// Result of deleting a child session.
66#[derive(Debug, Clone)]
67pub struct DeleteChildResult {
68    pub deleted: bool,
69    pub cancelled_running_child: bool,
70}
71
72/// Diagnostic snapshot of a running child session runner.
73#[derive(Debug, Clone)]
74pub struct ChildRunnerInfo {
75    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
76    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
77    pub last_tool_name: Option<String>,
78    pub last_tool_phase: Option<String>,
79    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
80    pub round_count: u32,
81}
82
83/// Result of a logical parent→child delivery.
84///
85/// Both variants mean the envelope is already durable. `ActivationPending`
86/// preserves the enqueue-success/wake-failure distinction so a tool caller can
87/// report success and let restart recovery retry the durable activation instead
88/// of generating a second message id.
89#[derive(Debug)]
90pub enum ChildSessionMessageDelivery {
91    Activated(crate::SessionMessengerReceipt),
92    ActivationPending {
93        delivery: bamboo_domain::SessionInboxReceipt,
94        error: String,
95    },
96}
97
98/// Short delegation note appended to the full root-style base prompt for a
99/// sub-agent. A sub-agent is a first-class agent (same base prompt + context
100/// enhancement as a top-level session); this note only frames the delegation
101/// relationship and the expected concise hand-back to the parent.
102pub const DELEGATION_NOTE: &str = r#"---
103
104You are running as a delegated sub-agent, spawned by a parent agent to handle a focused task. You have the full capabilities of a top-level agent, including the ability to spawn your own sub-agents when that helps. Stay focused on the assigned task, and when you finish, report a concise conclusion first (the parent receives your final message as the task result)."#;
105
106/// Input for creating a child session.
107#[derive(Debug, Clone)]
108pub struct CreateChildInput {
109    pub parent_session: Session,
110    pub child_id: String,
111    pub title: String,
112    pub responsibility: String,
113    pub assignment_prompt: String,
114    pub subagent_type: String,
115    /// Absolute path to the working directory for the child session.
116    pub workspace: String,
117    /// How the child workspace was selected before validation.
118    pub workspace_source: crate::project_context::WorkspaceSource,
119    /// Optional model override resolved from subagent_type routing.
120    /// When `None`, the child inherits the parent session's model.
121    pub model_override: Option<String>,
122    /// Optional provider+model override resolved from subagent routing.
123    /// When present, this preserves cross-provider routing for child execution.
124    pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
125    /// Runtime metadata resolved from subagent routing (e.g. external agent config).
126    pub runtime_metadata: std::collections::HashMap<String, String>,
127    /// Whether to immediately enqueue the child for execution.
128    /// Defaults to `true`.
129    pub auto_run: bool,
130    /// Optional reasoning effort to apply to the child's own LLM calls.
131    /// `None` (the default) leaves `Session::reasoning_effort` at `None`,
132    /// so the provider falls back to its default. The child does NOT
133    /// inherit the parent's reasoning_effort — fan-out children that
134    /// only need a quick lookup should not pay for `xhigh` reasoning
135    /// just because the orchestrator is running at `xhigh`.
136    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
137    /// Lifecycle of this child: `Some("resident")` marks a reusable resident
138    /// agent (one stable session reused for successive tasks under the same
139    /// root); `None`/`Some("oneshot")` is the default throwaway child.
140    pub lifecycle: Option<String>,
141    /// For a resident agent, the stable reuse key (scoped to the root session).
142    pub resident_name: Option<String>,
143    /// For a resident agent, how successive tasks treat prior context:
144    /// `"reset"` (default — independent tasks) or `"accumulate"` (remember).
145    pub resident_context: Option<String>,
146    /// Tool names to disable for this child (denylist; matched by EXACT
147    /// `ToolSchema.function.name`). `None` (the default) = full toolset. A
148    /// read-only Guardian reviewer sets e.g. {"Edit","Write","SubAgent",...}.
149    /// Carried to the child's `SpawnJob.disabled_tools` via the child session
150    /// metadata (see `create_child_action`) so the worker trims its toolset.
151    pub disabled_tools: Option<std::collections::BTreeSet<String>>,
152    /// Model-controllable context fork (Phase 3): when `Some(n)` with `n > 0`,
153    /// the last `n` non-system parent messages are rendered into a "Forked
154    /// context from parent" block prepended to the child's task brief. `None`
155    /// (the default) keeps the child on a clean, freshly-seeded context.
156    pub context_fork: Option<usize>,
157}
158
159/// Result of creating a child session.
160#[derive(Debug, Clone)]
161pub struct CreateChildResult {
162    pub child_session_id: String,
163    pub model: String,
164}
165
166/// A queued follow-up message stored in session metadata for later injection.
167#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168pub struct QueuedInjectedMessage {
169    pub content: String,
170    #[serde(default)]
171    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
172}
173
174// ---------------------------------------------------------------------------
175// Port trait
176// ---------------------------------------------------------------------------
177
178#[async_trait]
179pub trait ChildSessionPort: Send + Sync {
180    /// Validate and normalize the child's workspace before any child/session
181    /// state is created. Server adapters override this with the authoritative
182    /// Project registry ownership check; non-server embeddings still apply the
183    /// shared confinement resolver.
184    async fn validate_child_workspace(
185        &self,
186        _project_id: Option<&bamboo_domain::ProjectId>,
187        requested_workspace: &str,
188    ) -> Result<String, ChildSessionError> {
189        normalize_child_workspace(requested_workspace)
190    }
191
192    /// Publish a child workspace that already passed this port's validation.
193    ///
194    /// Server adapters override this so validation and publication use the
195    /// same AppState-scoped confinement resolver. The default preserves the
196    /// process-global behavior for non-server embeddings.
197    fn publish_child_workspace(
198        &self,
199        session_id: &str,
200        workspace: std::path::PathBuf,
201        source: &str,
202    ) -> std::path::PathBuf {
203        let _ = source;
204        bamboo_agent_core::workspace_state::publish_resolved_workspace(session_id, workspace)
205    }
206
207    async fn load_root_session(&self, root_id: &str) -> Result<Session, ChildSessionError>;
208    async fn load_child_for_parent(
209        &self,
210        parent_id: &str,
211        child_id: &str,
212    ) -> Result<Session, ChildSessionError>;
213    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError>;
214    /// Save a child session whose `agent_runtime_state` posture
215    /// (`permission_mode` / `no_human_approver`) the caller just set
216    /// authoritatively (the #74 resident-reuse re-seed) — persists them as-is
217    /// instead of adopting the child's stale on-disk value, unlike
218    /// [`Self::save_child_session`], which protects a concurrent `PATCH` to a
219    /// running child. Use ONLY right after deliberately writing those flags. #540.
220    async fn save_child_session_authoritative_flags(
221        &self,
222        child: &mut Session,
223    ) -> Result<(), ChildSessionError>;
224    /// Deliver one parent→child peer message through the runtime-owned logical
225    /// SessionMessenger. Implementations must not mutate the Session snapshot
226    /// to enqueue.
227    async fn send_session_message(
228        &self,
229        source_session_id: &str,
230        target_session_id: &str,
231        message: &str,
232        idempotency_key: Option<&str>,
233    ) -> Result<ChildSessionMessageDelivery, ChildSessionError> {
234        let _ = (
235            source_session_id,
236            target_session_id,
237            message,
238            idempotency_key,
239        );
240        Err(ChildSessionError::Execution(
241            "logical SessionMessenger is not configured for this runtime".to_string(),
242        ))
243    }
244    /// Commit the live parent's posture plus a validated workspace when
245    /// reusing a resident. Persistence happens before the runtime workspace is
246    /// published, so a failed save cannot move tools onto an uncommitted path.
247    async fn save_resident_reuse_state(
248        &self,
249        child: &mut Session,
250        workspace: &str,
251        workspace_source: crate::project_context::WorkspaceSource,
252        permission_audit: bamboo_domain::PermissionAuditSeed,
253        no_human_approver: bool,
254    ) -> Result<(), ChildSessionError> {
255        let previous_mode = child
256            .agent_runtime_state
257            .as_ref()
258            .map(|state| state.effective_permission_mode())
259            .unwrap_or_default();
260        let previous_resolution =
261            bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
262                .map(|snapshot| snapshot.resolution);
263        child.workspace = Some(workspace.to_string());
264        child.set_workspace_path_meta(workspace);
265        child.metadata.insert(
266            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
267            workspace_source.as_str().to_string(),
268        );
269        let runtime = child
270            .agent_runtime_state
271            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
272        runtime.set_permission_mode(permission_audit.resolution.requested);
273        runtime.no_human_approver = no_human_approver;
274        let changed = previous_mode != permission_audit.resolution.requested;
275        let posture_changed = previous_resolution != Some(permission_audit.resolution);
276        let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
277        bamboo_domain::record_permission_audit(
278            &mut child.metadata,
279            &permission_audit,
280            transitioned_at.as_deref(),
281        )
282        .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
283        if changed {
284            child.metadata_version = child.metadata_version.saturating_add(1);
285        }
286        self.save_child_session_authoritative_flags(child).await?;
287        self.publish_child_workspace(
288            &child.id,
289            std::path::PathBuf::from(workspace),
290            workspace_source.as_str(),
291        );
292        Ok(())
293    }
294    async fn is_child_running(&self, child_id: &str) -> bool;
295    async fn list_children(&self, parent_id: &str) -> Vec<ChildSessionEntry>;
296    async fn enqueue_child_run(
297        &self,
298        parent: &Session,
299        child: &Session,
300    ) -> Result<(), ChildSessionError>;
301    async fn cancel_child_run_and_wait(&self, child_id: &str) -> Result<(), ChildSessionError>;
302    async fn delete_child_session(
303        &self,
304        parent_id: &str,
305        child_id: &str,
306    ) -> Result<DeleteChildResult, ChildSessionError>;
307    /// Return live diagnostic info for a running child session, if available.
308    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
309
310    /// Register a durable parent wait for a single enqueued child. Idempotent
311    /// and coalesced per parent (concurrent sibling spawns merge into one write).
312    async fn register_parent_wait_for_child(
313        &self,
314        parent_session_id: &str,
315        child_session_id: &str,
316        tool_call_id: Option<&str>,
317    ) -> Result<(), ChildSessionError>;
318
319    /// Register a durable parent wait over an explicit set of children with a
320    /// chosen policy (the `SubAgent.wait` action). Returns the number of
321    /// children the wait now covers (0 = nothing to wait on).
322    async fn register_parent_wait_for_children(
323        &self,
324        parent_session_id: &str,
325        child_session_ids: &[String],
326        policy: ChildWaitPolicy,
327    ) -> Result<usize, ChildSessionError>;
328
329    /// The parent's currently-active (non-terminal) child session ids.
330    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String>;
331
332    /// The subset of `candidates` the session index POSITIVELY reports as
333    /// terminal children of this parent, as `(child_id, status)` pairs
334    /// (issue #546). `SubAgent.wait` uses this to avoid arming a wait over a
335    /// child that fires no further completion — and, policy-permitting, to
336    /// short-circuit a wait the terminal statuses already satisfy. Unknown
337    /// ids are NOT reported (index-less backends can't distinguish "terminal"
338    /// from "not yet indexed"); the child-wait watchdog rescues those at
339    /// runtime. Default: empty (no filtering).
340    async fn terminal_child_ids(
341        &self,
342        parent_session_id: &str,
343        candidates: &[String],
344    ) -> Vec<(String, String)> {
345        let _ = (parent_session_id, candidates);
346        Vec::new()
347    }
348
349    /// Find an existing resident agent in the same root tree by its stable
350    /// `resident_name`, returning its child session id if one exists. Used to
351    /// reuse a resident agent for a new task instead of minting a new child.
352    /// Index-backed (matches `root_session_id` + `metadata["resident_name"]`).
353    async fn find_resident_child(
354        &self,
355        root_session_id: &str,
356        resident_name: &str,
357    ) -> Option<String>;
358
359    /// Best-effort: ensure the child's session-index entry is visible
360    /// immediately after creation (the index is otherwise eventually
361    /// consistent). Failures are ignored by the caller.
362    async fn ensure_child_indexed(&self, child_session_id: &str);
363}
364
365fn normalize_child_workspace(requested_workspace: &str) -> Result<String, ChildSessionError> {
366    let requested_workspace = requested_workspace.trim();
367    if requested_workspace.is_empty() {
368        return Err(ChildSessionError::InvalidArguments(
369            "child workspace must be a non-empty path".to_string(),
370        ));
371    }
372    let requested = std::path::PathBuf::from(requested_workspace);
373    if requested.exists() && !requested.is_dir() {
374        return Err(ChildSessionError::InvalidArguments(format!(
375            "child workspace is not a directory: {requested_workspace}"
376        )));
377    }
378    let canonical = requested.canonicalize().unwrap_or(requested);
379    let final_workspace = bamboo_agent_core::workspace_state::resolve_workspace_path(canonical);
380    Ok(bamboo_config::paths::path_to_display_string(
381        &final_workspace,
382    ))
383}
384
385// ---------------------------------------------------------------------------
386// Subagent resolution port
387// ---------------------------------------------------------------------------
388
389/// Resolves subagent-type–specific configuration (model, runtime metadata)
390/// for the `SubAgent` tool.
391///
392/// Kept separate from [`ChildSessionPort`] (session CRUD/lifecycle/state): this
393/// port is pure `subagent_type` → config resolution (cross-provider model
394/// routing + actor/external-agent metadata). The server layer implements it;
395/// the tool depends only on the trait, carrying no `AppState` coupling.
396#[async_trait]
397pub trait SubagentResolutionPort: Send + Sync {
398    /// Provider+model ref for a `subagent_type`, or `None` to use defaults.
399    async fn resolve_subagent_model(
400        &self,
401        subagent_type: &str,
402    ) -> Option<bamboo_domain::ProviderModelRef>;
403
404    /// Runtime metadata (e.g. external-agent routing) for a `subagent_type`.
405    async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String>;
406}
407
408/// Models available from one configured provider (best-effort listing).
409#[derive(Debug, Clone, serde::Serialize)]
410pub struct ProviderModelList {
411    pub provider: String,
412    pub models: Vec<String>,
413    /// Set when this provider's listing failed (auth missing, network, …);
414    /// the provider is still usable with an explicitly known model id.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub error: Option<String>,
417}
418
419/// Lists the models the parent can pin a child session to
420/// (`SubAgent` tool `action=list_models` / `create.model`).
421///
422/// Separate from [`SubagentResolutionPort`] because it is backed by the live
423/// provider registry rather than per-`subagent_type` config resolution.
424#[async_trait]
425pub trait ModelCatalogPort: Send + Sync {
426    /// Best-effort model listing per configured provider. Providers whose
427    /// listing fails are still returned (with `error` set) so the caller can
428    /// see they exist.
429    async fn list_models(&self) -> Vec<ProviderModelList>;
430
431    /// The default provider name (used to resolve a bare model id without a
432    /// `provider:` prefix).
433    fn default_provider(&self) -> String;
434}