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