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