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 /// Optional model override resolved from subagent_type routing.
118 /// When `None`, the child inherits the parent session's model.
119 pub model_override: Option<String>,
120 /// Optional provider+model override resolved from subagent routing.
121 /// When present, this preserves cross-provider routing for child execution.
122 pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
123 /// Runtime metadata resolved from subagent routing (e.g. external agent config).
124 pub runtime_metadata: std::collections::HashMap<String, String>,
125 /// Whether to immediately enqueue the child for execution.
126 /// Defaults to `true`.
127 pub auto_run: bool,
128 /// Optional reasoning effort to apply to the child's own LLM calls.
129 /// `None` (the default) leaves `Session::reasoning_effort` at `None`,
130 /// so the provider falls back to its default. The child does NOT
131 /// inherit the parent's reasoning_effort — fan-out children that
132 /// only need a quick lookup should not pay for `xhigh` reasoning
133 /// just because the orchestrator is running at `xhigh`.
134 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
135 /// Lifecycle of this child: `Some("resident")` marks a reusable resident
136 /// agent (one stable session reused for successive tasks under the same
137 /// root); `None`/`Some("oneshot")` is the default throwaway child.
138 pub lifecycle: Option<String>,
139 /// For a resident agent, the stable reuse key (scoped to the root session).
140 pub resident_name: Option<String>,
141 /// For a resident agent, how successive tasks treat prior context:
142 /// `"reset"` (default — independent tasks) or `"accumulate"` (remember).
143 pub resident_context: Option<String>,
144 /// Tool names to disable for this child (denylist; matched by EXACT
145 /// `ToolSchema.function.name`). `None` (the default) = full toolset. A
146 /// read-only Guardian reviewer sets e.g. {"Edit","Write","SubAgent",...}.
147 /// Carried to the child's `SpawnJob.disabled_tools` via the child session
148 /// metadata (see `create_child_action`) so the worker trims its toolset.
149 pub disabled_tools: Option<std::collections::BTreeSet<String>>,
150 /// Model-controllable context fork (Phase 3): when `Some(n)` with `n > 0`,
151 /// the last `n` non-system parent messages are rendered into a "Forked
152 /// context from parent" block prepended to the child's task brief. `None`
153 /// (the default) keeps the child on a clean, freshly-seeded context.
154 pub context_fork: Option<usize>,
155}
156
157/// Result of creating a child session.
158#[derive(Debug, Clone)]
159pub struct CreateChildResult {
160 pub child_session_id: String,
161 pub model: String,
162}
163
164/// A queued follow-up message stored in session metadata for later injection.
165#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
166pub struct QueuedInjectedMessage {
167 pub content: String,
168 #[serde(default)]
169 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
170}
171
172// ---------------------------------------------------------------------------
173// Port trait
174// ---------------------------------------------------------------------------
175
176#[async_trait]
177pub trait ChildSessionPort: Send + Sync {
178 /// Validate and normalize the child's workspace before any child/session
179 /// state is created. Server adapters override this with the authoritative
180 /// Project registry ownership check; non-server embeddings still apply the
181 /// shared confinement resolver.
182 async fn validate_child_workspace(
183 &self,
184 _project_id: Option<&bamboo_domain::ProjectId>,
185 requested_workspace: &str,
186 ) -> Result<String, ChildSessionError> {
187 normalize_child_workspace(requested_workspace)
188 }
189
190 async fn load_root_session(&self, root_id: &str) -> Result<Session, ChildSessionError>;
191 async fn load_child_for_parent(
192 &self,
193 parent_id: &str,
194 child_id: &str,
195 ) -> Result<Session, ChildSessionError>;
196 async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError>;
197 /// Save a child session whose `agent_runtime_state` posture flags
198 /// (`bypass_permissions` / `no_human_approver`) the caller just set
199 /// authoritatively (the #74 resident-reuse re-seed) — persists them as-is
200 /// instead of adopting the child's stale on-disk value, unlike
201 /// [`Self::save_child_session`], which protects a concurrent `PATCH` to a
202 /// running child. Use ONLY right after deliberately writing those flags. #540.
203 async fn save_child_session_authoritative_flags(
204 &self,
205 child: &mut Session,
206 ) -> Result<(), ChildSessionError>;
207 /// Deliver one parent→child peer message through the runtime-owned logical
208 /// SessionMessenger. Implementations must not mutate the Session snapshot
209 /// to enqueue.
210 async fn send_session_message(
211 &self,
212 source_session_id: &str,
213 target_session_id: &str,
214 message: &str,
215 idempotency_key: Option<&str>,
216 ) -> Result<ChildSessionMessageDelivery, ChildSessionError> {
217 let _ = (
218 source_session_id,
219 target_session_id,
220 message,
221 idempotency_key,
222 );
223 Err(ChildSessionError::Execution(
224 "logical SessionMessenger is not configured for this runtime".to_string(),
225 ))
226 }
227 /// Commit the live parent's posture plus a validated workspace when
228 /// reusing a resident. Persistence happens before the runtime workspace is
229 /// published, so a failed save cannot move tools onto an uncommitted path.
230 async fn save_resident_reuse_state(
231 &self,
232 child: &mut Session,
233 workspace: &str,
234 ) -> Result<(), ChildSessionError> {
235 child.workspace = Some(workspace.to_string());
236 child.set_workspace_path_meta(workspace);
237 self.save_child_session_authoritative_flags(child).await?;
238 bamboo_agent_core::workspace_state::publish_resolved_workspace(
239 &child.id,
240 std::path::PathBuf::from(workspace),
241 );
242 Ok(())
243 }
244 async fn is_child_running(&self, child_id: &str) -> bool;
245 async fn list_children(&self, parent_id: &str) -> Vec<ChildSessionEntry>;
246 async fn enqueue_child_run(
247 &self,
248 parent: &Session,
249 child: &Session,
250 ) -> Result<(), ChildSessionError>;
251 async fn cancel_child_run_and_wait(&self, child_id: &str) -> Result<(), ChildSessionError>;
252 async fn delete_child_session(
253 &self,
254 parent_id: &str,
255 child_id: &str,
256 ) -> Result<DeleteChildResult, ChildSessionError>;
257 /// Return live diagnostic info for a running child session, if available.
258 async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
259
260 /// Register a durable parent wait for a single enqueued child. Idempotent
261 /// and coalesced per parent (concurrent sibling spawns merge into one write).
262 async fn register_parent_wait_for_child(
263 &self,
264 parent_session_id: &str,
265 child_session_id: &str,
266 tool_call_id: Option<&str>,
267 ) -> Result<(), ChildSessionError>;
268
269 /// Register a durable parent wait over an explicit set of children with a
270 /// chosen policy (the `SubAgent.wait` action). Returns the number of
271 /// children the wait now covers (0 = nothing to wait on).
272 async fn register_parent_wait_for_children(
273 &self,
274 parent_session_id: &str,
275 child_session_ids: &[String],
276 policy: ChildWaitPolicy,
277 ) -> Result<usize, ChildSessionError>;
278
279 /// The parent's currently-active (non-terminal) child session ids.
280 async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String>;
281
282 /// The subset of `candidates` the session index POSITIVELY reports as
283 /// terminal children of this parent, as `(child_id, status)` pairs
284 /// (issue #546). `SubAgent.wait` uses this to avoid arming a wait over a
285 /// child that fires no further completion — and, policy-permitting, to
286 /// short-circuit a wait the terminal statuses already satisfy. Unknown
287 /// ids are NOT reported (index-less backends can't distinguish "terminal"
288 /// from "not yet indexed"); the child-wait watchdog rescues those at
289 /// runtime. Default: empty (no filtering).
290 async fn terminal_child_ids(
291 &self,
292 parent_session_id: &str,
293 candidates: &[String],
294 ) -> Vec<(String, String)> {
295 let _ = (parent_session_id, candidates);
296 Vec::new()
297 }
298
299 /// Find an existing resident agent in the same root tree by its stable
300 /// `resident_name`, returning its child session id if one exists. Used to
301 /// reuse a resident agent for a new task instead of minting a new child.
302 /// Index-backed (matches `root_session_id` + `metadata["resident_name"]`).
303 async fn find_resident_child(
304 &self,
305 root_session_id: &str,
306 resident_name: &str,
307 ) -> Option<String>;
308
309 /// Best-effort: ensure the child's session-index entry is visible
310 /// immediately after creation (the index is otherwise eventually
311 /// consistent). Failures are ignored by the caller.
312 async fn ensure_child_indexed(&self, child_session_id: &str);
313}
314
315fn normalize_child_workspace(requested_workspace: &str) -> Result<String, ChildSessionError> {
316 let requested_workspace = requested_workspace.trim();
317 if requested_workspace.is_empty() {
318 return Err(ChildSessionError::InvalidArguments(
319 "child workspace must be a non-empty path".to_string(),
320 ));
321 }
322 let requested = std::path::PathBuf::from(requested_workspace);
323 if requested.exists() && !requested.is_dir() {
324 return Err(ChildSessionError::InvalidArguments(format!(
325 "child workspace is not a directory: {requested_workspace}"
326 )));
327 }
328 let canonical = requested.canonicalize().unwrap_or(requested);
329 let final_workspace = bamboo_agent_core::workspace_state::resolve_workspace_path(canonical);
330 Ok(bamboo_config::paths::path_to_display_string(
331 &final_workspace,
332 ))
333}
334
335// ---------------------------------------------------------------------------
336// Subagent resolution port
337// ---------------------------------------------------------------------------
338
339/// Resolves subagent-type–specific configuration (model, runtime metadata)
340/// for the `SubAgent` tool.
341///
342/// Kept separate from [`ChildSessionPort`] (session CRUD/lifecycle/state): this
343/// port is pure `subagent_type` → config resolution (cross-provider model
344/// routing + actor/external-agent metadata). The server layer implements it;
345/// the tool depends only on the trait, carrying no `AppState` coupling.
346#[async_trait]
347pub trait SubagentResolutionPort: Send + Sync {
348 /// Provider+model ref for a `subagent_type`, or `None` to use defaults.
349 async fn resolve_subagent_model(
350 &self,
351 subagent_type: &str,
352 ) -> Option<bamboo_domain::ProviderModelRef>;
353
354 /// Runtime metadata (e.g. external-agent routing) for a `subagent_type`.
355 async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String>;
356}
357
358/// Models available from one configured provider (best-effort listing).
359#[derive(Debug, Clone, serde::Serialize)]
360pub struct ProviderModelList {
361 pub provider: String,
362 pub models: Vec<String>,
363 /// Set when this provider's listing failed (auth missing, network, …);
364 /// the provider is still usable with an explicitly known model id.
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub error: Option<String>,
367}
368
369/// Lists the models the parent can pin a child session to
370/// (`SubAgent` tool `action=list_models` / `create.model`).
371///
372/// Separate from [`SubagentResolutionPort`] because it is backed by the live
373/// provider registry rather than per-`subagent_type` config resolution.
374#[async_trait]
375pub trait ModelCatalogPort: Send + Sync {
376 /// Best-effort model listing per configured provider. Providers whose
377 /// listing fails are still returned (with `error` set) so the caller can
378 /// see they exist.
379 async fn list_models(&self) -> Vec<ProviderModelList>;
380
381 /// The default provider name (used to resolve a bare model id without a
382 /// `provider:` prefix).
383 fn default_provider(&self) -> String;
384}