bamboo-engine 2026.8.1

Execution engine and orchestration for the Bamboo agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! Child session management use cases.
//!
//! Provides the application-layer logic for managing child sessions within
//! a root session. The server layer implements `ChildSessionPort` to supply
//! the infrastructure operations (load, save, schedule, cancel).

use async_trait::async_trait;
use bamboo_domain::session::runtime_state::ChildWaitPolicy;
use bamboo_domain::Session;
use std::collections::HashMap;

mod actions;
mod helpers;

#[cfg(test)]
mod tests;

pub use actions::{
    assemble_session_tree, build_session_tree_action, cancel_child_action, create_child_action,
    delete_child_action, get_child_action, list_children_action, run_child_action,
    send_message_to_child_action, update_child_action, SessionTreeNode,
};
pub use helpers::{
    compute_status_guidance, format_child_assignment, map_child_entry, metadata_text,
    normalize_non_empty_optional, normalize_required_text, replace_or_append_last_user_message,
    truncate_after_index, truncate_after_last_user,
};

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

#[derive(Debug, thiserror::Error)]
pub enum ChildSessionError {
    #[error("session not found: {0}")]
    NotFound(String),
    #[error("session is not a root session: {0}")]
    NotRootSession(String),
    #[error("session is not a child session: {0}")]
    NotChildSession(String),
    #[error("child session {child_id} does not belong to parent {parent_id}")]
    NotChildOfParent { child_id: String, parent_id: String },
    #[error("{0}")]
    InvalidArguments(String),
    #[error("{0}")]
    Execution(String),
}

// ---------------------------------------------------------------------------
// Value types
// ---------------------------------------------------------------------------

/// Summary of a child session for listing.
#[derive(Debug, Clone)]
pub struct ChildSessionEntry {
    pub child_session_id: String,
    pub title: String,
    pub pinned: bool,
    pub message_count: usize,
    pub updated_at: String,
    pub last_run_status: Option<String>,
    pub last_run_error: Option<String>,
}

/// Result of deleting a child session.
#[derive(Debug, Clone)]
pub struct DeleteChildResult {
    pub deleted: bool,
    pub cancelled_running_child: bool,
}

/// Diagnostic snapshot of a running child session runner.
#[derive(Debug, Clone)]
pub struct ChildRunnerInfo {
    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
    pub last_tool_name: Option<String>,
    pub last_tool_phase: Option<String>,
    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
    pub round_count: u32,
}

/// Result of a logical parent→child delivery.
///
/// Both variants mean the envelope is already durable. `ActivationPending`
/// preserves the enqueue-success/wake-failure distinction so a tool caller can
/// report success and let restart recovery retry the durable activation instead
/// of generating a second message id.
#[derive(Debug)]
pub enum ChildSessionMessageDelivery {
    Activated(crate::SessionMessengerReceipt),
    ActivationPending {
        delivery: bamboo_domain::SessionInboxReceipt,
        error: String,
    },
}

/// Short delegation note appended to the full root-style base prompt for a
/// sub-agent. A sub-agent is a first-class agent (same base prompt + context
/// enhancement as a top-level session); this note only frames the delegation
/// relationship and the expected concise hand-back to the parent.
pub const DELEGATION_NOTE: &str = r#"---

You 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)."#;

/// Input for creating a child session.
#[derive(Debug, Clone)]
pub struct CreateChildInput {
    pub parent_session: Session,
    pub child_id: String,
    pub title: String,
    pub responsibility: String,
    pub assignment_prompt: String,
    pub subagent_type: String,
    /// Absolute path to the working directory for the child session.
    pub workspace: String,
    /// How the child workspace was selected before validation.
    pub workspace_source: crate::project_context::WorkspaceSource,
    /// Optional model override resolved from subagent_type routing.
    /// When `None`, the child inherits the parent session's model.
    pub model_override: Option<String>,
    /// Optional provider+model override resolved from subagent routing.
    /// When present, this preserves cross-provider routing for child execution.
    pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
    /// Runtime metadata resolved from subagent routing (e.g. external agent config).
    pub runtime_metadata: std::collections::HashMap<String, String>,
    /// Whether to immediately enqueue the child for execution.
    /// Defaults to `true`.
    pub auto_run: bool,
    /// Optional reasoning effort to apply to the child's own LLM calls.
    /// `None` (the default) leaves `Session::reasoning_effort` at `None`,
    /// so the provider falls back to its default. The child does NOT
    /// inherit the parent's reasoning_effort — fan-out children that
    /// only need a quick lookup should not pay for `xhigh` reasoning
    /// just because the orchestrator is running at `xhigh`.
    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
    /// Lifecycle of this child: `Some("resident")` marks a reusable resident
    /// agent (one stable session reused for successive tasks under the same
    /// root); `None`/`Some("oneshot")` is the default throwaway child.
    pub lifecycle: Option<String>,
    /// For a resident agent, the stable reuse key (scoped to the root session).
    pub resident_name: Option<String>,
    /// For a resident agent, how successive tasks treat prior context:
    /// `"reset"` (default — independent tasks) or `"accumulate"` (remember).
    pub resident_context: Option<String>,
    /// Tool names to disable for this child (denylist; matched by EXACT
    /// `ToolSchema.function.name`). `None` (the default) = full toolset. A
    /// read-only Guardian reviewer sets e.g. {"Edit","Write","SubAgent",...}.
    /// Carried to the child's `SpawnJob.disabled_tools` via the child session
    /// metadata (see `create_child_action`) so the worker trims its toolset.
    pub disabled_tools: Option<std::collections::BTreeSet<String>>,
    /// Model-controllable context fork (Phase 3): when `Some(n)` with `n > 0`,
    /// the last `n` non-system parent messages are rendered into a "Forked
    /// context from parent" block prepended to the child's task brief. `None`
    /// (the default) keeps the child on a clean, freshly-seeded context.
    pub context_fork: Option<usize>,
}

/// Result of creating a child session.
#[derive(Debug, Clone)]
pub struct CreateChildResult {
    pub child_session_id: String,
    pub model: String,
}

/// A queued follow-up message stored in session metadata for later injection.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QueuedInjectedMessage {
    pub content: String,
    #[serde(default)]
    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}

// ---------------------------------------------------------------------------
// Port trait
// ---------------------------------------------------------------------------

#[async_trait]
pub trait ChildSessionPort: Send + Sync {
    /// Validate and normalize the child's workspace before any child/session
    /// state is created. Server adapters override this with the authoritative
    /// Project registry ownership check; non-server embeddings still apply the
    /// shared confinement resolver.
    async fn validate_child_workspace(
        &self,
        _project_id: Option<&bamboo_domain::ProjectId>,
        requested_workspace: &str,
    ) -> Result<String, ChildSessionError> {
        normalize_child_workspace(requested_workspace)
    }

    /// Publish a child workspace that already passed this port's validation.
    ///
    /// Server adapters override this so validation and publication use the
    /// same AppState-scoped confinement resolver. The default preserves the
    /// process-global behavior for non-server embeddings.
    fn publish_child_workspace(
        &self,
        session_id: &str,
        workspace: std::path::PathBuf,
        source: &str,
    ) -> std::path::PathBuf {
        let _ = source;
        bamboo_agent_core::workspace_state::publish_resolved_workspace(session_id, workspace)
    }

    async fn load_root_session(&self, root_id: &str) -> Result<Session, ChildSessionError>;
    async fn load_child_for_parent(
        &self,
        parent_id: &str,
        child_id: &str,
    ) -> Result<Session, ChildSessionError>;
    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError>;
    /// Save a child session whose `agent_runtime_state` posture
    /// (`permission_mode` / `no_human_approver`) the caller just set
    /// authoritatively (the #74 resident-reuse re-seed) — persists them as-is
    /// instead of adopting the child's stale on-disk value, unlike
    /// [`Self::save_child_session`], which protects a concurrent `PATCH` to a
    /// running child. Use ONLY right after deliberately writing those flags. #540.
    async fn save_child_session_authoritative_flags(
        &self,
        child: &mut Session,
    ) -> Result<(), ChildSessionError>;
    /// Deliver one parent→child peer message through the runtime-owned logical
    /// SessionMessenger. Implementations must not mutate the Session snapshot
    /// to enqueue.
    async fn send_session_message(
        &self,
        source_session_id: &str,
        target_session_id: &str,
        message: &str,
        idempotency_key: Option<&str>,
    ) -> Result<ChildSessionMessageDelivery, ChildSessionError> {
        let _ = (
            source_session_id,
            target_session_id,
            message,
            idempotency_key,
        );
        Err(ChildSessionError::Execution(
            "logical SessionMessenger is not configured for this runtime".to_string(),
        ))
    }
    /// Commit the live parent's posture plus a validated workspace when
    /// reusing a resident. Persistence happens before the runtime workspace is
    /// published, so a failed save cannot move tools onto an uncommitted path.
    async fn save_resident_reuse_state(
        &self,
        child: &mut Session,
        workspace: &str,
        workspace_source: crate::project_context::WorkspaceSource,
        permission_audit: bamboo_domain::PermissionAuditSeed,
        no_human_approver: bool,
    ) -> Result<(), ChildSessionError> {
        let previous_mode = child
            .agent_runtime_state
            .as_ref()
            .map(|state| state.effective_permission_mode())
            .unwrap_or_default();
        let previous_resolution =
            bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
                .map(|snapshot| snapshot.resolution);
        child.workspace = Some(workspace.to_string());
        child.set_workspace_path_meta(workspace);
        child.metadata.insert(
            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
            workspace_source.as_str().to_string(),
        );
        let runtime = child
            .agent_runtime_state
            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
        runtime.set_permission_mode(permission_audit.resolution.requested);
        runtime.no_human_approver = no_human_approver;
        let changed = previous_mode != permission_audit.resolution.requested;
        let posture_changed = previous_resolution != Some(permission_audit.resolution);
        let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
        bamboo_domain::record_permission_audit(
            &mut child.metadata,
            &permission_audit,
            transitioned_at.as_deref(),
        )
        .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
        if changed {
            child.metadata_version = child.metadata_version.saturating_add(1);
        }
        self.save_child_session_authoritative_flags(child).await?;
        self.publish_child_workspace(
            &child.id,
            std::path::PathBuf::from(workspace),
            workspace_source.as_str(),
        );
        Ok(())
    }
    async fn is_child_running(&self, child_id: &str) -> bool;
    async fn list_children(&self, parent_id: &str) -> Vec<ChildSessionEntry>;
    async fn enqueue_child_run(
        &self,
        parent: &Session,
        child: &Session,
    ) -> Result<(), ChildSessionError>;
    async fn cancel_child_run_and_wait(&self, child_id: &str) -> Result<(), ChildSessionError>;
    async fn delete_child_session(
        &self,
        parent_id: &str,
        child_id: &str,
    ) -> Result<DeleteChildResult, ChildSessionError>;
    /// Return live diagnostic info for a running child session, if available.
    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;

    /// Register a durable parent wait for a single enqueued child. Idempotent
    /// and coalesced per parent (concurrent sibling spawns merge into one write).
    async fn register_parent_wait_for_child(
        &self,
        parent_session_id: &str,
        child_session_id: &str,
        tool_call_id: Option<&str>,
    ) -> Result<(), ChildSessionError>;

    /// Register a durable parent wait over an explicit set of children with a
    /// chosen policy (the `SubAgent.wait` action). Returns the number of
    /// children the wait now covers (0 = nothing to wait on).
    async fn register_parent_wait_for_children(
        &self,
        parent_session_id: &str,
        child_session_ids: &[String],
        policy: ChildWaitPolicy,
    ) -> Result<usize, ChildSessionError>;

    /// The parent's currently-active (non-terminal) child session ids.
    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String>;

    /// The subset of `candidates` the session index POSITIVELY reports as
    /// terminal children of this parent, as `(child_id, status)` pairs
    /// (issue #546). `SubAgent.wait` uses this to avoid arming a wait over a
    /// child that fires no further completion — and, policy-permitting, to
    /// short-circuit a wait the terminal statuses already satisfy. Unknown
    /// ids are NOT reported (index-less backends can't distinguish "terminal"
    /// from "not yet indexed"); the child-wait watchdog rescues those at
    /// runtime. Default: empty (no filtering).
    async fn terminal_child_ids(
        &self,
        parent_session_id: &str,
        candidates: &[String],
    ) -> Vec<(String, String)> {
        let _ = (parent_session_id, candidates);
        Vec::new()
    }

    /// Find an existing resident agent in the same root tree by its stable
    /// `resident_name`, returning its child session id if one exists. Used to
    /// reuse a resident agent for a new task instead of minting a new child.
    /// Index-backed (matches `root_session_id` + `metadata["resident_name"]`).
    async fn find_resident_child(
        &self,
        root_session_id: &str,
        resident_name: &str,
    ) -> Option<String>;

    /// Best-effort: ensure the child's session-index entry is visible
    /// immediately after creation (the index is otherwise eventually
    /// consistent). Failures are ignored by the caller.
    async fn ensure_child_indexed(&self, child_session_id: &str);
}

fn normalize_child_workspace(requested_workspace: &str) -> Result<String, ChildSessionError> {
    let requested_workspace = requested_workspace.trim();
    if requested_workspace.is_empty() {
        return Err(ChildSessionError::InvalidArguments(
            "child workspace must be a non-empty path".to_string(),
        ));
    }
    let requested = std::path::PathBuf::from(requested_workspace);
    if requested.exists() && !requested.is_dir() {
        return Err(ChildSessionError::InvalidArguments(format!(
            "child workspace is not a directory: {requested_workspace}"
        )));
    }
    let canonical = requested.canonicalize().unwrap_or(requested);
    let final_workspace = bamboo_agent_core::workspace_state::resolve_workspace_path(canonical);
    Ok(bamboo_config::paths::path_to_display_string(
        &final_workspace,
    ))
}

// ---------------------------------------------------------------------------
// Subagent resolution port
// ---------------------------------------------------------------------------

/// Resolves subagent-type–specific configuration (model, runtime metadata)
/// for the `SubAgent` tool.
///
/// Kept separate from [`ChildSessionPort`] (session CRUD/lifecycle/state): this
/// port is pure `subagent_type` → config resolution (cross-provider model
/// routing + actor/external-agent metadata). The server layer implements it;
/// the tool depends only on the trait, carrying no `AppState` coupling.
#[async_trait]
pub trait SubagentResolutionPort: Send + Sync {
    /// Provider+model ref for a `subagent_type`, or `None` to use defaults.
    async fn resolve_subagent_model(
        &self,
        subagent_type: &str,
    ) -> Option<bamboo_domain::ProviderModelRef>;

    /// Runtime metadata (e.g. external-agent routing) for a `subagent_type`.
    async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String>;
}

/// Models available from one configured provider (best-effort listing).
#[derive(Debug, Clone, serde::Serialize)]
pub struct ProviderModelList {
    pub provider: String,
    pub models: Vec<String>,
    /// Set when this provider's listing failed (auth missing, network, …);
    /// the provider is still usable with an explicitly known model id.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Lists the models the parent can pin a child session to
/// (`SubAgent` tool `action=list_models` / `create.model`).
///
/// Separate from [`SubagentResolutionPort`] because it is backed by the live
/// provider registry rather than per-`subagent_type` config resolution.
#[async_trait]
pub trait ModelCatalogPort: Send + Sync {
    /// Best-effort model listing per configured provider. Providers whose
    /// listing fails are still returned (with `error` set) so the caller can
    /// see they exist.
    async fn list_models(&self) -> Vec<ProviderModelList>;

    /// The default provider name (used to resolve a bare model id without a
    /// `provider:` prefix).
    fn default_provider(&self) -> String;
}