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 bamboo_domain::{AdmissionCommit, AdmissionGate};
11use std::collections::HashMap;
12
13mod actions;
14mod depth_policy;
15mod helpers;
16mod inspection;
17pub mod named_profile;
18pub mod owned_tree;
19pub mod planner_artifact;
20pub(crate) mod result_projection;
21mod shared_workspace;
22
23#[cfg(test)]
24mod tests;
25
26pub use actions::{
27    apply_child_session_update, assemble_session_tree, build_session_tree_action,
28    cancel_child_action, create_child_action, create_shared_child_action,
29    create_ticket_child_action, delete_child_action, get_child_action, list_children_action,
30    rollback_failed_wait_launch, run_child_action, send_message_to_child_action,
31    send_message_to_child_action_with_gate, update_child_action,
32    update_child_action_with_background, ChildSessionUpdate, SessionTreeNode,
33};
34pub use depth_policy::validate_spawn_parent;
35pub use helpers::{
36    append_subagent_delegation_contract, compute_status_guidance, format_child_assignment,
37    format_child_assignment_with_background, map_child_entry, metadata_text,
38    normalize_non_empty_optional, normalize_required_text, render_forked_parent_context,
39    replace_or_append_last_user_message, truncate_after_index, truncate_after_last_user,
40};
41pub use inspection::{inspect_child_action, inspect_child_report_action};
42pub use result_projection::{unavailable_child_result, MAX_CHILD_RESULT_BYTES};
43pub use shared_workspace::resolve_shared_child_workspace;
44
45// ---------------------------------------------------------------------------
46// Error type
47// ---------------------------------------------------------------------------
48
49#[derive(Debug, thiserror::Error)]
50pub enum ChildSessionError {
51    #[error("session not found: {0}")]
52    NotFound(String),
53    #[error("session is not a root session: {0}")]
54    NotRootSession(String),
55    #[error("session is not a child session: {0}")]
56    NotChildSession(String),
57    #[error("child session {child_id} does not belong to parent {parent_id}")]
58    NotChildOfParent { child_id: String, parent_id: String },
59    #[error("{0}")]
60    InvalidArguments(String),
61    #[error("{0}")]
62    Execution(String),
63}
64
65// ---------------------------------------------------------------------------
66// Value types
67// ---------------------------------------------------------------------------
68
69/// Summary of a child session for listing.
70#[derive(Debug, Clone)]
71pub struct ChildSessionEntry {
72    pub child_session_id: String,
73    pub title: String,
74    pub pinned: bool,
75    pub message_count: usize,
76    pub updated_at: String,
77    pub last_run_status: Option<String>,
78    pub last_run_error: Option<String>,
79}
80
81/// Result of deleting a child session.
82#[derive(Debug, Clone)]
83pub struct DeleteChildResult {
84    pub deleted: bool,
85    pub cancelled_running_child: bool,
86}
87
88/// Diagnostic snapshot of a running child session runner.
89#[derive(Debug, Clone)]
90pub struct ChildRunnerInfo {
91    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
92    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
93    pub last_tool_name: Option<String>,
94    pub last_tool_phase: Option<String>,
95    pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
96    pub round_count: u32,
97}
98
99/// Result of a logical parent→child delivery.
100///
101/// Every variant means the envelope is already durable. `ActivationPending`
102/// has a durable eligibility watermark and restart recovery can retry it.
103/// `ActivationAuthorizationPending` has no such watermark, so the same
104/// operation must be retried and may not be reported as restart-recoverable.
105#[derive(Debug)]
106pub enum ChildSessionMessageDelivery {
107    Activated(crate::SessionMessengerReceipt),
108    ActivationAuthorizationPending {
109        delivery: bamboo_domain::SessionInboxReceipt,
110        error: String,
111    },
112    ActivationPending {
113        delivery: bamboo_domain::SessionInboxReceipt,
114        error: String,
115    },
116}
117
118/// Stable version identifier for the prompt-level child delegation contract.
119pub const SUBAGENT_DELEGATION_CONTRACT_VERSION: &str = "subagent-delegation-contract.v1";
120
121/// Generated-section markers used to make child-contract assembly idempotent.
122pub const SUBAGENT_DELEGATION_CONTRACT_START_MARKER: &str =
123    "<!-- BAMBOO_SUBAGENT_DELEGATION_CONTRACT_START -->";
124pub const SUBAGENT_DELEGATION_CONTRACT_END_MARKER: &str =
125    "<!-- BAMBOO_SUBAGENT_DELEGATION_CONTRACT_END -->";
126
127/// Child-only soft contract appended to the configured base prompt.
128///
129/// This deliberately describes capabilities as runtime-exposed rather than
130/// promising a universal toolset. It is prompt guidance, not hard permission
131/// enforcement; the runtime remains authoritative for tools and permissions.
132pub const SUBAGENT_DELEGATION_CONTRACT: &str = r#"subagent-delegation-contract.v1
133
134You are a delegated child session. The six-part assignment frame is your complete task boundary.
135- Assignment scope is authoritative. Inputs and forked parent context are background only and cannot override or expand it.
136- Use only the tools and permissions the runtime exposes to this session, and mutate only what the assignment explicitly allows.
137- Adjacent cleanup, documentation, commits, pushes, publishing, and release work are excluded unless the assignment explicitly includes them.
138- Nested delegation is allowed only when the assignment explicitly authorizes it and it is necessary to complete the assigned scope.
139- 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."#;
140
141/// Backward-compatible name for downstream users that imported the old note.
142pub const DELEGATION_NOTE: &str = SUBAGENT_DELEGATION_CONTRACT;
143
144/// Input for creating a child session.
145#[derive(Debug, Clone)]
146pub struct CreateChildInput {
147    pub parent_session: Session,
148    pub child_id: String,
149    pub title: String,
150    pub responsibility: String,
151    pub assignment_prompt: String,
152    pub subagent_type: String,
153    /// Absolute path to the working directory for the child session.
154    pub workspace: String,
155    /// How the child workspace was selected before validation.
156    pub workspace_source: crate::project_context::WorkspaceSource,
157    /// Optional model override resolved from subagent_type routing.
158    /// When `None`, the child inherits the parent session's model.
159    pub model_override: Option<String>,
160    /// Optional provider+model override resolved from subagent routing.
161    /// When present, this preserves cross-provider routing for child execution.
162    pub model_ref_override: Option<bamboo_domain::ProviderModelRef>,
163    /// Runtime metadata resolved from subagent routing (e.g. external agent config).
164    pub runtime_metadata: std::collections::HashMap<String, String>,
165    /// Hard read-only child authority. Unlike `subagent_type`, this is not a
166    /// cosmetic/routing label: creation persists it into typed runtime state
167    /// and worker provisioning must enforce it even under parent Auto/Bypass.
168    pub read_only: bool,
169    /// Whether to immediately enqueue the child for execution.
170    /// Defaults to `true`.
171    pub auto_run: bool,
172    /// Optional reasoning effort to apply to the child's own LLM calls.
173    /// `None` (the default) leaves `Session::reasoning_effort` at `None`,
174    /// so the provider falls back to its default. The child does NOT
175    /// inherit the parent's reasoning_effort — fan-out children that
176    /// only need a quick lookup should not pay for `xhigh` reasoning
177    /// just because the orchestrator is running at `xhigh`.
178    pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
179    /// Lifecycle of this child: `Some("resident")` marks a reusable resident
180    /// agent (one stable session reused for successive tasks under the same
181    /// root); `None`/`Some("oneshot")` is the default throwaway child.
182    pub lifecycle: Option<String>,
183    /// For a resident agent, the stable reuse key (scoped to the root session).
184    pub resident_name: Option<String>,
185    /// For a resident agent, how successive tasks treat prior context:
186    /// `"reset"` (default — independent tasks) or `"accumulate"` (remember).
187    pub resident_context: Option<String>,
188    /// Tool names to disable for this child (denylist; matched by EXACT
189    /// `ToolSchema.function.name`). `None` (the default) adds no child-specific
190    /// denylist; the runtime still determines which tools are exposed. A
191    /// read-only Guardian reviewer sets e.g. {"Edit","Write","SubAgent",...}.
192    /// Carried to the child's `SpawnJob.disabled_tools` via the child session
193    /// metadata (see `create_child_action`) so the worker trims its toolset.
194    pub disabled_tools: Option<std::collections::BTreeSet<String>>,
195    /// Model-controllable context fork (Phase 3): when `Some(n)` with `n > 0`,
196    /// the last `n` non-system parent messages are rendered into a "Forked
197    /// context from parent" block prepended to the child's task brief. `None`
198    /// (the default) keeps the child on a clean, freshly-seeded context.
199    pub context_fork: Option<usize>,
200}
201
202/// Result of creating a child session.
203#[derive(Debug, Clone)]
204pub struct CreateChildResult {
205    pub child_session_id: String,
206    pub model: String,
207}
208
209/// A queued follow-up message stored in session metadata for later injection.
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211pub struct QueuedInjectedMessage {
212    pub content: String,
213    #[serde(default)]
214    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
215}
216
217// ---------------------------------------------------------------------------
218// Port trait
219// ---------------------------------------------------------------------------
220
221#[async_trait]
222pub trait ChildSessionPort: Send + Sync {
223    /// Read the current Host policy for a new child. Existing lineage and
224    /// activation are independent of a later configuration change.
225    async fn max_spawn_depth(&self) -> u32 {
226        bamboo_config::DEFAULT_MAX_SPAWN_DEPTH
227    }
228
229    /// Check the child's final provider/model before persistence or activation.
230    /// Hosts without configuration-managed model admission retain their own
231    /// routing policy; server adapters enforce the configured runtime list.
232    async fn validate_child_model(&self, _child: &Session) -> Result<(), ChildSessionError> {
233        Ok(())
234    }
235
236    /// Host-private catalog selection. Embeddings without a catalog keep the
237    /// legacy path; server implementations reject unavailable authority.
238    async fn resolve_named_profile(
239        &self,
240        _parent: &Session,
241        _name: &str,
242    ) -> Result<Option<named_profile::ResolvedChildProfile>, ChildSessionError> {
243        Ok(None)
244    }
245
246    /// Opt-in required packets may only cross an explicitly supported fresh
247    /// local Bamboo worker route. Embeddings must prove support before persist.
248    async fn validate_required_child_context_route(
249        &self,
250        _runtime_metadata: &HashMap<String, String>,
251        _subagent_type: &str,
252    ) -> Result<(), ChildSessionError> {
253        Err(ChildSessionError::Execution(
254            "required_child_context_unsupported: no supported built-in worker route".into(),
255        ))
256    }
257
258    /// Resolve a planner workspace from a freshly loaded durable parent, then
259    /// apply this port's existing Project/confinement validation. Selection is
260    /// explicit input, durable workspace metadata, or the current Project
261    /// default. Only unassigned legacy parents may use the old workspace field;
262    /// no process cwd, publication cache or global default supplies authority.
263    async fn resolve_child_workspace(
264        &self,
265        parent: &Session,
266        explicit_workspace: Option<&str>,
267    ) -> Result<(String, crate::project_context::WorkspaceSource), ChildSessionError> {
268        use crate::project_context::{
269            ProjectContextResolver, SessionProjectIdentity, WorkspaceSource,
270            WORKSPACE_SOURCE_METADATA_KEY,
271        };
272        let project_id = match ProjectContextResolver::session_project_identity(parent) {
273            SessionProjectIdentity::Assigned(project_id) => Some(project_id),
274            SessionProjectIdentity::Unassigned => None,
275            SessionProjectIdentity::Invalid { raw, message } => {
276                return Err(ChildSessionError::InvalidArguments(format!(
277                    "parent session carries an invalid Project identity '{raw}': {message}"
278                )));
279            }
280        };
281        let explicit_workspace = explicit_workspace
282            .map(str::trim)
283            .filter(|path| !path.is_empty());
284        let (requested, source) = if let Some(path) = explicit_workspace {
285            (path.to_owned(), WorkspaceSource::Explicit)
286        } else if parent
287            .metadata
288            .get(WORKSPACE_SOURCE_METADATA_KEY)
289            .map(String::as_str)
290            == Some(WorkspaceSource::ProjectDefault.as_str())
291        {
292            // A default-derived cached path cannot pin an older Project path.
293            (String::new(), WorkspaceSource::ProjectDefault)
294        } else if let Some(path) = parent.workspace_path_meta() {
295            (path, WorkspaceSource::Session)
296        } else if project_id.is_some() {
297            (String::new(), WorkspaceSource::ProjectDefault)
298        } else {
299            (
300                parent.workspace.clone().unwrap_or_default(),
301                WorkspaceSource::Session,
302            )
303        };
304        if requested.trim().is_empty() && project_id.is_none() {
305            return Err(ChildSessionError::InvalidArguments(
306                "child workspace must be a non-empty path".to_owned(),
307            ));
308        }
309        let path = self
310            .validate_child_workspace(project_id.as_ref(), &requested)
311            .await?;
312        Ok((path, source))
313    }
314
315    /// Validate and normalize the child's workspace before any child/session
316    /// state is created. Server adapters override this with the authoritative
317    /// Project registry ownership check; non-server embeddings still apply the
318    /// shared confinement resolver.
319    async fn validate_child_workspace(
320        &self,
321        _project_id: Option<&bamboo_domain::ProjectId>,
322        requested_workspace: &str,
323    ) -> Result<String, ChildSessionError> {
324        normalize_child_workspace(requested_workspace)
325    }
326
327    /// Publish a child workspace that already passed this port's validation.
328    ///
329    /// Server adapters override this so validation and publication use the
330    /// same AppState-scoped confinement resolver. The default preserves the
331    /// process-global behavior for non-server embeddings.
332    fn publish_child_workspace(
333        &self,
334        session_id: &str,
335        workspace: std::path::PathBuf,
336        source: &str,
337    ) -> std::path::PathBuf {
338        let _ = source;
339        bamboo_agent_core::workspace_state::publish_resolved_workspace(session_id, workspace)
340    }
341
342    async fn load_root_session(&self, root_id: &str) -> Result<Session, ChildSessionError>;
343    /// Load a direct parent for child lifecycle operations. Server-backed
344    /// implementations may admit a canonical Child as the direct parent;
345    /// legacy embeddings remain Root-only until they opt in explicitly.
346    async fn load_parent_session(&self, parent_id: &str) -> Result<Session, ChildSessionError> {
347        self.load_root_session(parent_id).await
348    }
349    /// Load the current tree caller from durable Session storage. A default
350    /// embedding supports Root callers only; child inspection requires an
351    /// implementation that can reload arbitrary logical sessions by ID.
352    async fn load_tree_caller_session(
353        &self,
354        caller_id: &str,
355    ) -> Result<Session, ChildSessionError> {
356        self.load_root_session(caller_id).await
357    }
358    async fn load_child_for_parent(
359        &self,
360        parent_id: &str,
361        child_id: &str,
362    ) -> Result<Session, ChildSessionError>;
363    /// Read-only history/diagnostics may address a verified descendant. A
364    /// runtime that cannot prove lineage retains direct-parent inspection.
365    async fn load_child_for_inspection(
366        &self,
367        caller_id: &str,
368        child_id: &str,
369    ) -> Result<Session, ChildSessionError> {
370        self.load_child_for_parent(caller_id, child_id).await
371    }
372    /// Read bounded operational evidence for an already authorized direct child.
373    /// Implementations without a durable diagnostic source must say so.
374    async fn inspect_child_diagnostics(
375        &self,
376        child: &Session,
377    ) -> Result<serde_json::Value, ChildSessionError> {
378        Ok(serde_json::json!({
379            "child_session_id": child.id,
380            "view": "diagnostics",
381            "available": false,
382            "reason": "unsupported_runtime",
383        }))
384    }
385    /// Validate a run request before resetting transcript or changing control state.
386    /// Default embeddings retain their existing behavior; this never grants an activation.
387    async fn validate_child_run_request(
388        &self,
389        _parent: &Session,
390        _child: &Session,
391        _reset: Option<bool>,
392    ) -> Result<(), ChildSessionError> {
393        Ok(())
394    }
395    async fn save_child_session(&self, child: &mut Session) -> Result<(), ChildSessionError>;
396    /// Atomically apply an update to the latest child snapshot. The adapter
397    /// must fence activation, reject execution changes to active generations,
398    /// and preserve concurrent transcript appends. Embeddings without those
399    /// boundaries fail closed.
400    async fn update_child_session(
401        &self,
402        _parent_id: &str,
403        _child_id: &str,
404        _update: ChildSessionUpdate,
405    ) -> Result<(Session, usize), ChildSessionError> {
406        Err(ChildSessionError::Execution(
407            "atomic child session update is unavailable in this runtime".into(),
408        ))
409    }
410    /// Append a draft-only parent message from the latest durable transcript.
411    /// This shares the update transaction boundary so a concurrent update
412    /// cannot overwrite the message or be overwritten by a stale draft save.
413    async fn append_draft_child_message(
414        &self,
415        _parent_id: &str,
416        _child_id: &str,
417        _message: &str,
418    ) -> Result<Session, ChildSessionError> {
419        Err(ChildSessionError::Execution(
420            "atomic draft child message append is unavailable in this runtime".into(),
421        ))
422    }
423    /// Save a child session whose `agent_runtime_state` posture
424    /// (`permission_mode` / `no_human_approver`) the caller just set
425    /// authoritatively (the #74 resident-reuse re-seed) — persists them as-is
426    /// instead of adopting the child's stale on-disk value, unlike
427    /// [`Self::save_child_session`], which protects a concurrent `PATCH` to a
428    /// running child. Use ONLY right after deliberately writing those flags. #540.
429    async fn save_child_session_authoritative_flags(
430        &self,
431        child: &mut Session,
432    ) -> Result<(), ChildSessionError>;
433    /// Deliver one parent→child peer message through the runtime-owned logical
434    /// SessionMessenger. Implementations must not mutate the Session snapshot
435    /// to enqueue.
436    async fn send_session_message(
437        &self,
438        source_session_id: &str,
439        target_session_id: &str,
440        message: &str,
441        idempotency_key: Option<&str>,
442    ) -> Result<ChildSessionMessageDelivery, ChildSessionError> {
443        let _ = (
444            source_session_id,
445            target_session_id,
446            message,
447            idempotency_key,
448        );
449        Err(ChildSessionError::Execution(
450            "logical SessionMessenger is not configured for this runtime".to_string(),
451        ))
452    }
453    /// The gate is checked at the durable SessionInbox rename, not when this
454    /// asynchronous port call begins. Implementations without that boundary
455    /// must fail closed for gated deliveries.
456    async fn send_session_message_with_gate(
457        &self,
458        source_session_id: &str,
459        target_session_id: &str,
460        message: &str,
461        idempotency_key: Option<&str>,
462        gate: Option<&AdmissionGate>,
463    ) -> Result<ChildSessionMessageDelivery, ChildSessionError> {
464        if gate.is_some() {
465            return Err(ChildSessionError::Execution(
466                "cancellation-aware SessionInbox admission is unsupported".into(),
467            ));
468        }
469        self.send_session_message(
470            source_session_id,
471            target_session_id,
472            message,
473            idempotency_key,
474        )
475        .await
476    }
477    /// Commit the live parent's posture plus a validated workspace when
478    /// reusing a resident. Persistence happens before the runtime workspace is
479    /// published, so a failed save cannot move tools onto an uncommitted path.
480    async fn save_resident_reuse_state(
481        &self,
482        child: &mut Session,
483        workspace: &str,
484        workspace_source: crate::project_context::WorkspaceSource,
485        permission_audit: bamboo_domain::PermissionAuditSeed,
486        no_human_approver: bool,
487    ) -> Result<(), ChildSessionError> {
488        let previous_mode = child
489            .agent_runtime_state
490            .as_ref()
491            .map(|state| state.effective_permission_mode())
492            .unwrap_or_default();
493        let previous_resolution =
494            bamboo_domain::PermissionAuditSnapshot::from_metadata(&child.metadata)
495                .map(|snapshot| snapshot.resolution);
496        child.workspace = Some(workspace.to_string());
497        child.set_workspace_path_meta(workspace);
498        child.metadata.insert(
499            crate::project_context::WORKSPACE_SOURCE_METADATA_KEY.to_string(),
500            workspace_source.as_str().to_string(),
501        );
502        let runtime = child
503            .agent_runtime_state
504            .get_or_insert_with(bamboo_domain::AgentRuntimeState::default);
505        runtime.set_permission_mode(permission_audit.resolution.requested);
506        runtime.no_human_approver = no_human_approver;
507        let changed = previous_mode != permission_audit.resolution.requested;
508        let posture_changed = previous_resolution != Some(permission_audit.resolution);
509        let transitioned_at = posture_changed.then(|| chrono::Utc::now().to_rfc3339());
510        bamboo_domain::record_permission_audit(
511            &mut child.metadata,
512            &permission_audit,
513            transitioned_at.as_deref(),
514        )
515        .map_err(|error| ChildSessionError::Execution(error.to_string()))?;
516        if changed {
517            child.metadata_version = child.metadata_version.saturating_add(1);
518        }
519        self.save_child_session_authoritative_flags(child).await?;
520        self.publish_child_workspace(
521            &child.id,
522            std::path::PathBuf::from(workspace),
523            workspace_source.as_str(),
524        );
525        Ok(())
526    }
527    async fn is_child_running(&self, child_id: &str) -> bool;
528    async fn list_children(&self, parent_id: &str) -> Vec<ChildSessionEntry>;
529    /// One bounded rebuildable candidate index for tree inspection. Every
530    /// returned ID is still reloaded from durable Session storage before display.
531    async fn tree_index_snapshot(
532        &self,
533    ) -> Result<Option<Vec<(String, String)>>, ChildSessionError> {
534        Ok(None)
535    }
536    async fn enqueue_child_run(
537        &self,
538        parent: &Session,
539        child: &Session,
540    ) -> Result<(), ChildSessionError>;
541    /// Admit one child job at the scheduler's synchronous queue send. Repeating
542    /// with a committed gate must not enqueue the job again.
543    async fn admit_child_run(
544        &self,
545        parent: &Session,
546        child: &Session,
547        gate: Option<&AdmissionGate>,
548    ) -> Result<AdmissionCommit<()>, ChildSessionError> {
549        if gate.is_some() {
550            return Err(ChildSessionError::Execution(
551                "cancellation-aware child job admission is unsupported".into(),
552            ));
553        }
554        self.enqueue_child_run(parent, child).await?;
555        Ok(AdmissionCommit::Committed(()))
556    }
557    async fn cancel_child_run_and_wait(&self, child_id: &str) -> Result<(), ChildSessionError>;
558    async fn delete_child_session(
559        &self,
560        parent_id: &str,
561        child_id: &str,
562    ) -> Result<DeleteChildResult, ChildSessionError>;
563    /// Return live diagnostic info for a running child session, if available.
564    async fn get_child_runner_info(&self, child_id: &str) -> Option<ChildRunnerInfo>;
565
566    /// Register a durable parent wait for a single child immediately before it
567    /// is enqueued. Idempotent and coalesced per parent (concurrent sibling
568    /// spawns merge into one write).
569    async fn register_parent_wait_for_child(
570        &self,
571        parent_session_id: &str,
572        child_session_id: &str,
573        tool_call_id: Option<&str>,
574    ) -> Result<(), ChildSessionError>;
575
576    /// Compensate a failed child launch by removing only that child from the
577    /// parent's durable wait. Implementations must preserve concurrently
578    /// registered siblings and clear the suspension marker only when the wait
579    /// becomes empty.
580    async fn rollback_parent_wait_for_child(
581        &self,
582        parent_session_id: &str,
583        child_session_id: &str,
584    ) -> Result<(), ChildSessionError> {
585        let _ = (parent_session_id, child_session_id);
586        Err(ChildSessionError::Execution(
587            "parent-wait rollback is not configured for this runtime".to_string(),
588        ))
589    }
590
591    /// Register a durable parent wait over an explicit set of children with a
592    /// chosen policy (the `SubAgent.wait` action). Returns the number of
593    /// children the wait now covers (0 = nothing to wait on).
594    async fn register_parent_wait_for_children(
595        &self,
596        parent_session_id: &str,
597        child_session_ids: &[String],
598        policy: ChildWaitPolicy,
599    ) -> Result<usize, ChildSessionError>;
600
601    /// Tool-registered explicit wait. Implementations must persist the tool
602    /// call ID with the wait so final runner saves can distinguish a completed
603    /// wait from an unpersisted runner-created safety-net wait.
604    async fn register_parent_wait_for_children_tagged(
605        &self,
606        _parent_session_id: &str,
607        _child_session_ids: &[String],
608        _policy: ChildWaitPolicy,
609        _tool_call_id: &str,
610    ) -> Result<usize, ChildSessionError> {
611        Err(ChildSessionError::Execution(
612            "tagged parent wait is not configured for this runtime".to_string(),
613        ))
614    }
615
616    /// The parent's currently-active (non-terminal) child session ids.
617    async fn active_child_ids(&self, parent_session_id: &str) -> Vec<String>;
618
619    /// The subset of `candidates` the session index POSITIVELY reports as
620    /// terminal children of this parent, as `(child_id, status)` pairs
621    /// (issue #546). `SubAgent.wait` uses this to avoid arming a wait over a
622    /// child that fires no further completion — and, policy-permitting, to
623    /// short-circuit a wait the terminal statuses already satisfy. Unknown
624    /// ids are NOT reported (index-less backends can't distinguish "terminal"
625    /// from "not yet indexed"); the child-wait watchdog rescues those at
626    /// runtime. Default: empty (no filtering).
627    async fn terminal_child_ids(
628        &self,
629        parent_session_id: &str,
630        candidates: &[String],
631    ) -> Vec<(String, String)> {
632        let _ = (parent_session_id, candidates);
633        Vec::new()
634    }
635
636    /// Find an existing resident agent in the same root tree by its stable
637    /// `resident_name`, returning its child session id if one exists. Used to
638    /// reuse a resident agent for a new task instead of minting a new child.
639    /// Index-backed (matches `root_session_id` + `metadata["resident_name"]`).
640    async fn find_resident_child(
641        &self,
642        root_session_id: &str,
643        resident_name: &str,
644    ) -> Option<String>;
645
646    /// Best-effort: ensure the child's session-index entry is visible
647    /// immediately after creation (the index is otherwise eventually
648    /// consistent). Failures are ignored by the caller.
649    async fn ensure_child_indexed(&self, child_session_id: &str);
650}
651
652fn normalize_child_workspace(requested_workspace: &str) -> Result<String, ChildSessionError> {
653    let requested_workspace = requested_workspace.trim();
654    if requested_workspace.is_empty() {
655        return Err(ChildSessionError::InvalidArguments(
656            "child workspace must be a non-empty path".to_string(),
657        ));
658    }
659    let requested = std::path::PathBuf::from(requested_workspace);
660    if requested.exists() && !requested.is_dir() {
661        return Err(ChildSessionError::InvalidArguments(format!(
662            "child workspace is not a directory: {requested_workspace}"
663        )));
664    }
665    let canonical = requested.canonicalize().unwrap_or(requested);
666    let final_workspace = bamboo_agent_core::workspace_state::resolve_workspace_path(canonical);
667    Ok(bamboo_config::paths::path_to_display_string(
668        &final_workspace,
669    ))
670}
671
672// ---------------------------------------------------------------------------
673// Subagent resolution port
674// ---------------------------------------------------------------------------
675
676/// Resolves subagent-type–specific configuration (model, runtime metadata)
677/// for the `SubAgent` tool.
678///
679/// Kept separate from [`ChildSessionPort`] (session CRUD/lifecycle/state): this
680/// port is pure `subagent_type` → config resolution (cross-provider model
681/// routing + actor/external-agent metadata). The server layer implements it;
682/// the tool depends only on the trait, carrying no `AppState` coupling.
683#[async_trait]
684pub trait SubagentResolutionPort: Send + Sync {
685    /// Provider+model ref for a `subagent_type`, or `None` to use defaults.
686    async fn resolve_subagent_model(
687        &self,
688        subagent_type: &str,
689    ) -> Option<bamboo_domain::ProviderModelRef>;
690
691    /// Runtime metadata (e.g. external-agent routing) for a `subagent_type`.
692    async fn resolve_runtime_metadata(&self, subagent_type: &str) -> HashMap<String, String>;
693}
694
695/// Models available from one configured provider (best-effort listing).
696#[derive(Debug, Clone, serde::Serialize)]
697pub struct ProviderModelList {
698    pub provider: String,
699    pub models: Vec<String>,
700    /// Set when this provider's listing failed (auth missing, network, …);
701    /// the provider is still usable with an explicitly known model id.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub error: Option<String>,
704}
705
706/// Lists the models the parent can pin a child session to
707/// (`SubAgent` tool `action=list_models` / `create.model`).
708///
709/// Separate from [`SubagentResolutionPort`] because it is backed by the live
710/// provider registry rather than per-`subagent_type` config resolution.
711#[async_trait]
712pub trait ModelCatalogPort: Send + Sync {
713    /// Best-effort model listing per configured provider. Providers whose
714    /// listing fails are still returned (with `error` set) so the caller can
715    /// see they exist.
716    async fn list_models(&self) -> Vec<ProviderModelList>;
717
718    /// The default provider name (used to resolve a bare model id without a
719    /// `provider:` prefix).
720    fn default_provider(&self) -> String;
721}