Skip to main content

bamboo_engine/runtime/
runtime.rs

1//! Unified agent execution runtime.
2//!
3//! [`AgentRuntime`] holds shared resources assembled once at server startup,
4//! including the LLM provider and default tool executor.
5//! [`ExecuteRequest`] captures per-request parameters.  Together they replace
6//! the three duplicated `AgentLoopConfig` construction sites in the server
7//! layer (HTTP handler, spawn scheduler, schedule manager).
8
9use std::collections::BTreeSet;
10use std::sync::Arc;
11
12use tokio::sync::{mpsc, RwLock};
13use tokio_util::sync::CancellationToken;
14
15use bamboo_agent_core::storage::{AttachmentReader, Storage};
16use bamboo_agent_core::tools::ToolExecutor;
17use bamboo_agent_core::{AgentEvent, Role, Session};
18use bamboo_config::PermissionMode;
19use bamboo_domain::ReasoningEffort;
20use bamboo_llm::Config;
21use bamboo_llm::LLMProvider;
22use bamboo_metrics::MetricsCollector;
23use bamboo_skills::SkillManager;
24
25use crate::runtime::config::{
26    AgentLoopConfig, AuxiliaryModelConfig, BashCompletionSink, BashResumeHook,
27    DisabledFilterResolver, GoldConfig, GuardianConfig, GuardianSpawner, ImageFallbackConfig,
28    PromptMemoryFlags,
29};
30use crate::runtime::hooks::HookRunner;
31use crate::runtime::model_roster::{ModelRoster, RoleModel};
32use crate::runtime::runner::run_agent_loop_with_config;
33use bamboo_domain::{RuntimeSessionPersistence, SessionInboxPort};
34
35use crate::session_activation::SessionActivationRouter;
36use crate::session_messaging::SessionMessenger;
37
38// ---------------------------------------------------------------------------
39// AgentRuntime — shared resources (assembled once)
40// ---------------------------------------------------------------------------
41
42/// Shared runtime resources assembled once at server startup.
43///
44/// Each field is an `Arc` or `Clone`-cheap handle so that `AgentRuntime` itself
45/// is cheaply cloneable across tasks.
46#[derive(Clone)]
47pub struct AgentRuntime {
48    pub storage: Arc<dyn Storage>,
49    pub persistence: Arc<dyn RuntimeSessionPersistence>,
50    /// Capturing no inherited wait is also immutable for this execution.
51    pub(crate) inherited_child_wait_captured: bool,
52    pub session_inbox: Option<Arc<dyn SessionInboxPort>>,
53    pub activation_router: Option<Arc<SessionActivationRouter>>,
54    pub session_messenger: Option<Arc<SessionMessenger>>,
55    pub attachment_reader: Arc<dyn AttachmentReader>,
56    pub skill_manager: Arc<SkillManager>,
57    pub project_context_resolver: Option<Arc<crate::project_context::ProjectContextResolver>>,
58    pub metrics_collector: MetricsCollector,
59    pub config: Arc<RwLock<Config>>,
60    /// Live process-wide permission policy when the embedding exposes one.
61    pub permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
62    /// SDK/static fallback used when there is no live policy handle.
63    pub permission_mode: PermissionMode,
64
65    /// Reloadable LLM provider handle (delegates to the latest provider).
66    pub provider: Arc<dyn LLMProvider>,
67
68    /// Concrete Jiandu store shared by every run from this runtime.
69    pub memory_store: bamboo_memory::memory_store::MemoryStore,
70
71    /// Default tool executor (root tools with full surface).
72    /// Call sites that need a reduced tool set (child / schedule) pass their
73    /// own via `ExecuteRequest::tools`.
74    pub default_tools: Arc<dyn ToolExecutor>,
75
76    /// Immutable hook registry shared by runs. Each execute call snapshots the
77    /// `Arc` onto its sealed [`AgentLoopConfig`].
78    pub hook_runner: Arc<HookRunner>,
79}
80
81// ---------------------------------------------------------------------------
82// AgentRuntimeBuilder
83// ---------------------------------------------------------------------------
84
85/// Builder for [`AgentRuntime`].
86///
87/// ```
88/// # use bamboo_engine::AgentRuntimeBuilder;
89/// // In real code all fields are provided by the server assembly.
90/// let rt = AgentRuntimeBuilder::new()
91///     // .storage(...)
92///     // .provider(...)
93///     .build();
94/// ```
95pub struct AgentRuntimeBuilder {
96    storage: Option<Arc<dyn Storage>>,
97    persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
98    session_inbox: Option<Arc<dyn SessionInboxPort>>,
99    activation_router: Option<Arc<SessionActivationRouter>>,
100    session_messenger: Option<Arc<SessionMessenger>>,
101    attachment_reader: Option<Arc<dyn AttachmentReader>>,
102    skill_manager: Option<Arc<SkillManager>>,
103    project_context_resolver: Option<Arc<crate::project_context::ProjectContextResolver>>,
104    metrics_collector: Option<MetricsCollector>,
105    config: Option<Arc<RwLock<Config>>>,
106    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
107    permission_mode: PermissionMode,
108    provider: Option<Arc<dyn LLMProvider>>,
109    memory_store: bamboo_memory::memory_store::MemoryStore,
110    default_tools: Option<Arc<dyn ToolExecutor>>,
111    hook_runner: Arc<HookRunner>,
112}
113
114impl AgentRuntimeBuilder {
115    pub fn new() -> Self {
116        Self {
117            storage: None,
118            persistence: None,
119            session_inbox: None,
120            activation_router: None,
121            session_messenger: None,
122            attachment_reader: None,
123            skill_manager: None,
124            project_context_resolver: None,
125            metrics_collector: None,
126            config: None,
127            permission_config: None,
128            permission_mode: PermissionMode::Default,
129            provider: None,
130            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
131            default_tools: None,
132            hook_runner: Arc::new(HookRunner::new()),
133        }
134    }
135
136    pub fn storage(mut self, v: Arc<dyn Storage>) -> Self {
137        self.storage = Some(v);
138        self
139    }
140
141    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
142        self.persistence = Some(v);
143        self
144    }
145
146    pub fn session_inbox(mut self, v: Arc<dyn SessionInboxPort>) -> Self {
147        self.session_inbox = Some(v);
148        self
149    }
150
151    pub fn activation_router(mut self, v: Arc<SessionActivationRouter>) -> Self {
152        self.activation_router = Some(v);
153        self
154    }
155
156    pub fn session_messenger(mut self, v: Arc<SessionMessenger>) -> Self {
157        self.session_messenger = Some(v);
158        self
159    }
160
161    pub fn attachment_reader(mut self, v: Arc<dyn AttachmentReader>) -> Self {
162        self.attachment_reader = Some(v);
163        self
164    }
165
166    pub fn skill_manager(mut self, v: Arc<SkillManager>) -> Self {
167        self.skill_manager = Some(v);
168        self
169    }
170
171    pub fn project_context_resolver(
172        mut self,
173        v: Arc<crate::project_context::ProjectContextResolver>,
174    ) -> Self {
175        self.project_context_resolver = Some(v);
176        self
177    }
178
179    pub fn metrics_collector(mut self, v: MetricsCollector) -> Self {
180        self.metrics_collector = Some(v);
181        self
182    }
183
184    pub fn config(mut self, v: Arc<RwLock<Config>>) -> Self {
185        self.config = Some(v);
186        self
187    }
188
189    pub fn permission_config(mut self, v: Arc<bamboo_tools::permission::PermissionConfig>) -> Self {
190        self.permission_config = Some(v);
191        self
192    }
193
194    pub fn permission_mode(mut self, v: PermissionMode) -> Self {
195        self.permission_mode = v;
196        self
197    }
198
199    pub fn provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
200        self.provider = Some(v);
201        self
202    }
203
204    pub fn memory_store(mut self, v: bamboo_memory::memory_store::MemoryStore) -> Self {
205        self.memory_store = v;
206        self
207    }
208
209    pub fn default_tools(mut self, v: Arc<dyn ToolExecutor>) -> Self {
210        self.default_tools = Some(v);
211        self
212    }
213
214    /// Install the lifecycle hook registry used by all runs from this runtime.
215    pub fn hook_runner(mut self, v: Arc<HookRunner>) -> Self {
216        self.hook_runner = v;
217        self
218    }
219
220    pub fn build(self) -> Result<AgentRuntime, &'static str> {
221        if let (Some(router), Some(inbox)) = (&self.activation_router, &self.session_inbox) {
222            router.set_inbox(inbox.clone());
223        }
224        Ok(AgentRuntime {
225            inherited_child_wait_captured: false,
226            storage: self.storage.ok_or_else(|| format_missing("storage"))?,
227            persistence: self
228                .persistence
229                .ok_or_else(|| format_missing("persistence"))?,
230            session_inbox: self.session_inbox,
231            activation_router: self.activation_router,
232            session_messenger: self.session_messenger,
233            attachment_reader: self
234                .attachment_reader
235                .ok_or_else(|| format_missing("attachment_reader"))?,
236            skill_manager: self
237                .skill_manager
238                .ok_or_else(|| format_missing("skill_manager"))?,
239            project_context_resolver: self.project_context_resolver,
240            metrics_collector: self
241                .metrics_collector
242                .ok_or_else(|| format_missing("metrics_collector"))?,
243            config: self.config.ok_or_else(|| format_missing("config"))?,
244            permission_config: self.permission_config,
245            permission_mode: self.permission_mode,
246            provider: self.provider.ok_or_else(|| format_missing("provider"))?,
247            memory_store: self.memory_store,
248            default_tools: self
249                .default_tools
250                .ok_or_else(|| format_missing("default_tools"))?,
251            hook_runner: self.hook_runner,
252        })
253    }
254}
255
256fn format_missing(field: &str) -> &'static str {
257    // Static strings for the common fields keep the error type `&'static str`.
258    // This is good enough for a builder that only runs at startup.
259    match field {
260        "storage" => "AgentRuntimeBuilder: missing storage",
261        "persistence" => "AgentRuntimeBuilder: missing persistence",
262        "attachment_reader" => "AgentRuntimeBuilder: missing attachment_reader",
263        "skill_manager" => "AgentRuntimeBuilder: missing skill_manager",
264        "metrics_collector" => "AgentRuntimeBuilder: missing metrics_collector",
265        "config" => "AgentRuntimeBuilder: missing config",
266        "provider" => "AgentRuntimeBuilder: missing provider",
267        "default_tools" => "AgentRuntimeBuilder: missing default_tools",
268        _ => "AgentRuntimeBuilder: missing required field",
269    }
270}
271
272impl Default for AgentRuntimeBuilder {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278// ---------------------------------------------------------------------------
279// ExecuteRequest — per-request parameters
280// ---------------------------------------------------------------------------
281
282/// Per-request parameters for agent execution.
283///
284/// Required fields (`initial_message`, `event_tx`, `cancel_token`) must always
285/// be provided.  The provider is taken from [`AgentRuntime::provider`]; tools
286/// default to [`AgentRuntime::default_tools`] when `None`.
287pub struct ExecuteRequest {
288    /// Host-installed, per-run private plan capability. Never decoded from HTTP.
289    pub ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
290    // -- Required ----------------------------------------------------------
291    pub initial_message: String,
292    pub event_tx: mpsc::Sender<AgentEvent>,
293    pub cancel_token: CancellationToken,
294
295    // -- Tool override -----------------------------------------------------
296    /// Override runtime's `default_tools`.  When `None`, uses the runtime's
297    /// default tool executor.
298    pub tools: Option<Arc<dyn ToolExecutor>>,
299    /// Override the LLM provider for this execution. When `None`, uses the
300    /// runtime's shared provider handle.
301    pub provider_override: Option<Arc<dyn LLMProvider>>,
302
303    // -- Model selection (None roles → config defaults) -------------------
304    /// Cohesive primary + auxiliary model/provider selection. Replaces the old
305    /// `model` / `provider_name` / `provider_type` / `fast_model(+provider)` /
306    /// `background_model(+provider)` / `summarization_model(+provider)` clump.
307    /// Per-role `None` preserves the same `None → Config::get_*` fallbacks
308    /// applied during [`AgentRuntime::execute`].
309    pub model_roster: ModelRoster,
310    pub reasoning_effort: Option<ReasoningEffort>,
311    /// Optional per-round resolver for auxiliary model settings that should be
312    /// re-read from live global config between rounds.
313    pub auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
314    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
315    /// When `None`, the snapshotted `disabled_tools`/`disabled_skill_ids` are used.
316    pub disabled_filter_resolver: Option<DisabledFilterResolver>,
317    /// When `None`, falls back to `Config::disabled_tool_references()`.
318    pub disabled_tools: Option<BTreeSet<String>>,
319    /// When `None`, falls back to `Config::disabled_skill_ids()`.
320    pub disabled_skill_ids: Option<BTreeSet<String>>,
321    pub selected_skill_ids: Option<Vec<String>>,
322    pub selected_skill_mode: Option<String>,
323    pub image_fallback: Option<ImageFallbackConfig>,
324    pub gold_config: Option<GoldConfig>,
325    /// Optional guardian adversarial-review gate configuration.
326    pub guardian_config: Option<GuardianConfig>,
327    /// Late-bound spawner for the guardian reviewer child (wired by the server;
328    /// the runner cannot construct a child directly).
329    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
330    /// Late-bound hook that arranges a self-resume after a background-bash
331    /// suspend (issue #84 Phase 2b). Wired by the server.
332    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
333    /// Late-bound sink that pushes a completed background-bash shell's result
334    /// into this loop (issue #84 Phase 2b follow-up). Wired by the server.
335    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
336    /// Bamboo application data directory (typically `~/.bamboo`).
337    pub app_data_dir: Option<std::path::PathBuf>,
338    /// Per-run resource guardrail override (issue #221): token / tool-call /
339    /// subagent budget for THIS execution. TIGHTEN-ONLY: per field, the
340    /// effective limit is the minimum of this override and the config-level
341    /// `Config::run_budget` default — a request can lower the operator's
342    /// ceiling but never raise or remove it. An unset field (`None`) keeps
343    /// the config default (which may itself be unlimited). See
344    /// [`bamboo_config::RunBudgetConfig::merged_with_override`].
345    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
346}
347
348// ---------------------------------------------------------------------------
349// ExecuteRequestBuilder — ergonomic construction of ExecuteRequest
350// ---------------------------------------------------------------------------
351
352/// Fluent builder for [`ExecuteRequest`].
353///
354/// `ExecuteRequest` carries three required fields plus a long tail of optional
355/// overrides; constructing it by hand forces callers to spell out ~20 `None`s.
356/// This builder requires only `initial_message` + `event_tx` + `cancel_token`,
357/// defaults every optional field to `None` (matching the runtime's spawn
358/// defaults), and exposes fluent setters for the rest.
359///
360/// It lives in `bamboo-engine` so both the in-crate server layer (e.g. the
361/// schedule manager) and the root `bamboo_agent` SDK facade (which re-exports
362/// it) construct requests through one shared builder — no forked assembly.
363pub struct ExecuteRequestBuilder {
364    ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
365    initial_message: String,
366    event_tx: mpsc::Sender<AgentEvent>,
367    cancel_token: CancellationToken,
368
369    tools: Option<Arc<dyn ToolExecutor>>,
370    provider_override: Option<Arc<dyn LLMProvider>>,
371    // Individual model fields are accumulated here for backward-compatible
372    // fluent setters; `build()` assembles them into a `ModelRoster`. A
373    // `.model_roster(..)` setter seeds all of them at once.
374    model: Option<String>,
375    provider_name: Option<String>,
376    provider_type: Option<String>,
377    fast_model: Option<String>,
378    fast_model_provider: Option<Arc<dyn LLMProvider>>,
379    background_model: Option<String>,
380    background_model_provider: Option<Arc<dyn LLMProvider>>,
381    summarization_model: Option<String>,
382    summarization_model_provider: Option<Arc<dyn LLMProvider>>,
383    reasoning_effort: Option<ReasoningEffort>,
384    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
385    disabled_filter_resolver: Option<DisabledFilterResolver>,
386    disabled_tools: Option<BTreeSet<String>>,
387    disabled_skill_ids: Option<BTreeSet<String>>,
388    selected_skill_ids: Option<Vec<String>>,
389    selected_skill_mode: Option<String>,
390    image_fallback: Option<ImageFallbackConfig>,
391    gold_config: Option<GoldConfig>,
392    guardian_config: Option<GuardianConfig>,
393    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
394    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
395    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
396    app_data_dir: Option<std::path::PathBuf>,
397    run_budget: Option<bamboo_config::RunBudgetConfig>,
398}
399
400impl ExecuteRequestBuilder {
401    /// Create a builder with the three required fields. All optional overrides
402    /// default to `None`.
403    pub fn new(
404        initial_message: impl Into<String>,
405        event_tx: mpsc::Sender<AgentEvent>,
406        cancel_token: CancellationToken,
407    ) -> Self {
408        Self {
409            initial_message: initial_message.into(),
410            ticket_worker_plan: None,
411            event_tx,
412            cancel_token,
413            tools: None,
414            provider_override: None,
415            model: None,
416            provider_name: None,
417            provider_type: None,
418            fast_model: None,
419            fast_model_provider: None,
420            background_model: None,
421            background_model_provider: None,
422            summarization_model: None,
423            summarization_model_provider: None,
424            reasoning_effort: None,
425            auxiliary_model_resolver: None,
426            disabled_filter_resolver: None,
427            disabled_tools: None,
428            disabled_skill_ids: None,
429            selected_skill_ids: None,
430            selected_skill_mode: None,
431            image_fallback: None,
432            gold_config: None,
433            guardian_config: None,
434            guardian_spawner: None,
435            bash_resume_hook: None,
436            bash_completion_sink: None,
437            app_data_dir: None,
438            run_budget: None,
439        }
440    }
441
442    /// Install a trusted, per-run private LocalPlan port.
443    pub fn ticket_worker_plan(
444        mut self,
445        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
446    ) -> Self {
447        self.ticket_worker_plan = Some(plan);
448        self
449    }
450
451    /// Override the tool executor for this execution.
452    pub fn tools(mut self, v: Arc<dyn ToolExecutor>) -> Self {
453        self.tools = Some(v);
454        self
455    }
456
457    /// Override the LLM provider for this execution.
458    pub fn provider_override(mut self, v: Arc<dyn LLMProvider>) -> Self {
459        self.provider_override = Some(v);
460        self
461    }
462
463    /// Seed the full model selection from a [`ModelRoster`].
464    ///
465    /// Decomposes the roster back into the builder's individual fields so it
466    /// composes with the existing per-field fluent setters; later individual
467    /// setters override the corresponding roster entry.
468    pub fn model_roster(mut self, roster: ModelRoster) -> Self {
469        self.fast_model = roster.fast_model();
470        self.fast_model_provider = roster.fast_model_provider();
471        self.background_model = roster.background_model();
472        self.background_model_provider = roster.background_model_provider();
473        self.summarization_model = roster.summarization_model();
474        self.summarization_model_provider = roster.summarization_model_provider();
475        self.model = roster.model;
476        self.provider_name = roster.provider_name;
477        self.provider_type = roster.provider_type;
478        self
479    }
480
481    /// Override the primary model name.
482    pub fn model(mut self, v: impl Into<String>) -> Self {
483        self.model = Some(v.into());
484        self
485    }
486
487    /// Override the provider name.
488    pub fn provider_name(mut self, v: impl Into<String>) -> Self {
489        self.provider_name = Some(v.into());
490        self
491    }
492
493    /// Override the provider type.
494    pub fn provider_type(mut self, v: impl Into<String>) -> Self {
495        self.provider_type = Some(v.into());
496        self
497    }
498
499    /// Override the fast-model name.
500    pub fn fast_model(mut self, v: impl Into<String>) -> Self {
501        self.fast_model = Some(v.into());
502        self
503    }
504
505    /// Override the provider used for fast-model calls.
506    pub fn fast_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
507        self.fast_model_provider = Some(v);
508        self
509    }
510
511    /// Override the background-model name.
512    pub fn background_model(mut self, v: impl Into<String>) -> Self {
513        self.background_model = Some(v.into());
514        self
515    }
516
517    /// Override the provider used for background/memory model calls.
518    pub fn background_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
519        self.background_model_provider = Some(v);
520        self
521    }
522
523    /// Override the summarization-model name.
524    pub fn summarization_model(mut self, v: impl Into<String>) -> Self {
525        self.summarization_model = Some(v.into());
526        self
527    }
528
529    /// Override the provider used for summarization/compression calls.
530    pub fn summarization_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
531        self.summarization_model_provider = Some(v);
532        self
533    }
534
535    /// Set the reasoning effort.
536    pub fn reasoning_effort(mut self, v: ReasoningEffort) -> Self {
537        self.reasoning_effort = Some(v);
538        self
539    }
540
541    /// Set the per-round auxiliary-model resolver.
542    pub fn auxiliary_model_resolver(
543        mut self,
544        v: Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>,
545    ) -> Self {
546        self.auxiliary_model_resolver = Some(v);
547        self
548    }
549
550    /// Set the per-round resolver for the live disabled tool/skill sets (#136).
551    pub fn disabled_filter_resolver(mut self, v: DisabledFilterResolver) -> Self {
552        self.disabled_filter_resolver = Some(v);
553        self
554    }
555
556    /// Set the disabled tool names (merged with config defaults at runtime).
557    pub fn disabled_tools(mut self, v: BTreeSet<String>) -> Self {
558        self.disabled_tools = Some(v);
559        self
560    }
561
562    /// Set the disabled skill ids.
563    pub fn disabled_skill_ids(mut self, v: BTreeSet<String>) -> Self {
564        self.disabled_skill_ids = Some(v);
565        self
566    }
567
568    /// Set the explicitly selected skill ids.
569    pub fn selected_skill_ids(mut self, v: Vec<String>) -> Self {
570        self.selected_skill_ids = Some(v);
571        self
572    }
573
574    /// Set the skill-selection mode.
575    pub fn selected_skill_mode(mut self, v: impl Into<String>) -> Self {
576        self.selected_skill_mode = Some(v.into());
577        self
578    }
579
580    /// Set the image fallback configuration.
581    pub fn image_fallback(mut self, v: ImageFallbackConfig) -> Self {
582        self.image_fallback = Some(v);
583        self
584    }
585
586    /// Set the internal `gold_config` feature flag.
587    ///
588    /// `gold_config` is an internal feature flag (not part of the public SDK
589    /// surface), so this setter is crate-visible only. Public SDK callers always
590    /// leave it `None`; the in-crate spawn paths thread a resolved value through
591    /// here. Defaults to `None`.
592    pub(crate) fn gold_config(mut self, v: Option<GoldConfig>) -> Self {
593        self.gold_config = v;
594        self
595    }
596
597    /// Set the internal `guardian_config` feature flag (crate-visible, like
598    /// [`Self::gold_config`]). Public SDK callers leave it `None`.
599    pub(crate) fn guardian_config(mut self, v: Option<GuardianConfig>) -> Self {
600        self.guardian_config = v;
601        self
602    }
603
604    /// Set the late-bound guardian reviewer spawner (crate-visible; wired by the
605    /// server's spawn path so the runner can create the reviewer child).
606    pub(crate) fn guardian_spawner(mut self, v: Option<Arc<dyn GuardianSpawner>>) -> Self {
607        self.guardian_spawner = v;
608        self
609    }
610
611    /// Set the late-bound bash self-resume hook (crate-visible; wired by the
612    /// server so a session suspended on background bash is always resumed).
613    pub(crate) fn bash_resume_hook(mut self, v: Option<Arc<dyn BashResumeHook>>) -> Self {
614        self.bash_resume_hook = v;
615        self
616    }
617
618    /// Set the late-bound bash completion sink (crate-visible; wired by the
619    /// server so a completed background shell's result is pushed into the loop).
620    pub(crate) fn bash_completion_sink(mut self, v: Option<Arc<dyn BashCompletionSink>>) -> Self {
621        self.bash_completion_sink = v;
622        self
623    }
624
625    /// Set the Bamboo application data directory.
626    pub fn app_data_dir(mut self, v: std::path::PathBuf) -> Self {
627        self.app_data_dir = Some(v);
628        self
629    }
630
631    /// Set the per-run resource guardrail override (issue #221). TIGHTEN-ONLY:
632    /// per field, the effective limit is the minimum of `v` and the
633    /// config-level default — this can never loosen the operator's ceiling;
634    /// see [`bamboo_config::RunBudgetConfig::merged_with_override`].
635    pub fn run_budget(mut self, v: bamboo_config::RunBudgetConfig) -> Self {
636        self.run_budget = Some(v);
637        self
638    }
639
640    /// Materialize the underlying [`ExecuteRequest`].
641    ///
642    /// `gold_config` is an internal feature flag with only a crate-visible
643    /// setter ([`Self::gold_config`]); public SDK callers leave it `None`.
644    pub fn build(self) -> ExecuteRequest {
645        let model_roster = ModelRoster {
646            model: self.model,
647            provider_name: self.provider_name,
648            provider_type: self.provider_type,
649            fast: RoleModel::from_parts(self.fast_model, self.fast_model_provider),
650            background: RoleModel::from_parts(
651                self.background_model,
652                self.background_model_provider,
653            ),
654            summarization: RoleModel::from_parts(
655                self.summarization_model,
656                self.summarization_model_provider,
657            ),
658        };
659        ExecuteRequest {
660            ticket_worker_plan: self.ticket_worker_plan,
661            initial_message: self.initial_message,
662            event_tx: self.event_tx,
663            cancel_token: self.cancel_token,
664            tools: self.tools,
665            provider_override: self.provider_override,
666            model_roster,
667            reasoning_effort: self.reasoning_effort,
668            auxiliary_model_resolver: self.auxiliary_model_resolver,
669            disabled_filter_resolver: self.disabled_filter_resolver,
670            disabled_tools: self.disabled_tools,
671            disabled_skill_ids: self.disabled_skill_ids,
672            selected_skill_ids: self.selected_skill_ids,
673            selected_skill_mode: self.selected_skill_mode,
674            image_fallback: self.image_fallback,
675            gold_config: self.gold_config,
676            guardian_config: self.guardian_config,
677            guardian_spawner: self.guardian_spawner,
678            bash_resume_hook: self.bash_resume_hook,
679            bash_completion_sink: self.bash_completion_sink,
680            app_data_dir: self.app_data_dir,
681            run_budget: self.run_budget,
682        }
683    }
684}
685
686// ---------------------------------------------------------------------------
687// Helpers
688// ---------------------------------------------------------------------------
689
690/// Extract the system prompt from session messages.
691fn extract_system_prompt(session: &Session) -> Option<String> {
692    session
693        .messages
694        .iter()
695        .find(|m| matches!(m.role, Role::System))
696        .map(|m| m.content.clone())
697}
698
699pub(super) fn should_observe_completed_tool_trace(
700    result: &crate::runtime::runner::Result<()>,
701    final_checkpoint_succeeded: bool,
702    session: &Session,
703) -> bool {
704    final_checkpoint_succeeded
705        && result.is_ok()
706        && session
707            .agent_runtime_state
708            .as_ref()
709            .is_some_and(|state| matches!(state.status, bamboo_domain::AgentStatusState::Completed))
710        && !session.metadata.contains_key("runtime.suspend_reason")
711        && !session.metadata.contains_key("runtime.completion_reason")
712}
713
714// ---------------------------------------------------------------------------
715// Execution
716// ---------------------------------------------------------------------------
717
718impl AgentRuntime {
719    /// Execute the agent loop with the given request.
720    ///
721    /// Builds an [`AgentLoopConfig`] from the request parameters and shared
722    /// runtime resources, then delegates to [`run_agent_loop_with_config`].
723    pub async fn execute(
724        &self,
725        session: &mut Session,
726        req: ExecuteRequest,
727    ) -> crate::runtime::runner::Result<()> {
728        let existing = self.persistence.inherited_child_wait();
729        if let Some(inherited) = existing.as_ref() {
730            inherited
731                .validate_session(session)
732                .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
733        } else if !self.inherited_child_wait_captured {
734            let mut runtime = self.clone();
735            if let Some(inherited) = bamboo_domain::InheritedChildWait::capture(session) {
736                runtime.persistence = self
737                    .persistence
738                    .bind_inherited_child_wait(inherited)
739                    .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
740            }
741            runtime.inherited_child_wait_captured = true;
742            return runtime.execute_bound(session, req).await;
743        }
744        self.execute_bound(session, req).await
745    }
746
747    async fn execute_bound(
748        &self,
749        session: &mut Session,
750        req: ExecuteRequest,
751    ) -> crate::runtime::runner::Result<()> {
752        if self.persistence.root_actor_execution_required(session) {
753            return Err(bamboo_agent_core::AgentError::Tool(
754                "Root execution route requires a bound Actor writer and event handoff".into(),
755            ));
756        }
757        let session_activation_notifications = match self.activation_router.as_ref() {
758            Some(router) => Some(Arc::new(parking_lot::Mutex::new(
759                router.subscribe(&session.id).await,
760            ))),
761            None => None,
762        };
763        let guidance_active_run_id = match self.activation_router.as_ref() {
764            Some(router) => router.current_run_id(&session.id).await,
765            None => None,
766        };
767        let system_prompt = extract_system_prompt(session);
768        let config = self.config.read().await;
769        let ExecuteRequest {
770            ticket_worker_plan,
771            initial_message,
772            event_tx,
773            cancel_token,
774            tools,
775            provider_override,
776            model_roster,
777            reasoning_effort,
778            auxiliary_model_resolver,
779            disabled_filter_resolver,
780            disabled_tools,
781            disabled_skill_ids,
782            selected_skill_ids,
783            selected_skill_mode,
784            image_fallback,
785            gold_config,
786            guardian_config,
787            guardian_spawner,
788            bash_resume_hook,
789            bash_completion_sink,
790            app_data_dir,
791            run_budget,
792        } = req;
793        let tools = tools.unwrap_or_else(|| self.default_tools.clone());
794        let llm = provider_override.unwrap_or_else(|| self.provider.clone());
795        let configured_permission_mode = self
796            .permission_config
797            .as_ref()
798            .map(|config| config.mode())
799            .unwrap_or(self.permission_mode);
800        let active_plan_gate = session
801            .agent_runtime_state
802            .as_ref()
803            .is_some_and(|state| state.plan_mode.is_some());
804
805        // Decompose the roster back into the loose locals the resolution logic
806        // below expects. This preserves the byte-for-byte `None → Config`
807        // fallbacks: a `None` role yields `None` name/provider exactly as the
808        // old loose fields did.
809        let fast_model = model_roster.fast_model();
810        let fast_model_provider = model_roster.fast_model_provider();
811        let background_model = model_roster.background_model();
812        let background_model_provider = model_roster.background_model_provider();
813        let summarization_model = model_roster.summarization_model();
814        let summarization_model_provider = model_roster.summarization_model_provider();
815        let ModelRoster {
816            model,
817            provider_name,
818            provider_type,
819            ..
820        } = model_roster;
821        let hook_runner = Arc::new(
822            self.hook_runner
823                .with_lifecycle_config(&config.lifecycle_hooks, app_data_dir.clone()),
824        );
825
826        let loop_config = AgentLoopConfig {
827            ticket_worker_plan: ticket_worker_plan.clone(),
828            guidance_active_run_id,
829            system_prompt,
830            // Snapshot the legacy model_limits from the live in-memory config so
831            // resolve_token_budget never falls back to a disk-reading Config::new(). #38.
832            legacy_model_limits: config.extra.get("model_limits").cloned(),
833            disabled_skill_ids: disabled_skill_ids.unwrap_or_else(|| config.disabled_skill_ids()),
834            selected_skill_ids,
835            selected_skill_mode,
836            skill_manager: Some(self.skill_manager.clone()),
837            project_context_resolver: self.project_context_resolver.clone(),
838            skip_initial_user_message: true,
839            storage: Some(self.storage.clone()),
840            persistence: Some(self.persistence.clone()),
841            session_inbox: self.session_inbox.clone(),
842            session_activation_notifications,
843            attachment_reader: Some(self.attachment_reader.clone()),
844            metrics_collector: Some(self.metrics_collector.clone()),
845            model_name: model,
846            fast_model_name: fast_model.or_else(|| config.get_fast_model()),
847            fast_model_provider,
848            auxiliary_evaluation_max_concurrency:
849                crate::runtime::config::normalize_auxiliary_evaluation_max_concurrency(
850                    config
851                        .extra
852                        .get("auxiliary_evaluation_max_concurrency")
853                        .and_then(serde_json::Value::as_u64),
854                ),
855            background_model_name: background_model
856                .or_else(|| config.get_memory_background_model()),
857            planning_model_name: config
858                .defaults
859                .as_ref()
860                .and_then(|d| d.planning.as_ref())
861                .map(|r| r.model.clone()),
862            search_model_name: config
863                .defaults
864                .as_ref()
865                .and_then(|d| d.search.as_ref().or(d.fast.as_ref()))
866                .map(|r| r.model.clone()),
867            compression_instructions: None,
868            summary_target_ratio: config
869                .memory()
870                .as_ref()
871                .map(|memory| memory.summary_target_ratio)
872                .unwrap_or(0.20),
873            context_management: config.context_management.clone(),
874            summary_safe_window_percent: config
875                .memory()
876                .as_ref()
877                .map(|memory| memory.summary_safe_window_percent)
878                .unwrap_or(80),
879            summarization_model_name: summarization_model
880                .or_else(|| config.get_task_summary_model()),
881            background_model_provider,
882            summarization_model_provider,
883            provider_name: Some(
884                provider_name.unwrap_or_else(|| config.effective_default_provider().to_string()),
885            ),
886            provider_type,
887            reasoning_effort,
888            auxiliary_model_resolver,
889            disabled_filter_resolver,
890            disabled_tools: {
891                let mut merged = config.disabled_tool_references();
892                if let Some(dt) = disabled_tools {
893                    merged.extend(dt);
894                }
895                merged
896            },
897            image_fallback,
898            app_data_dir,
899            memory_store: self.memory_store.clone(),
900            prompt_memory_flags: config
901                .memory()
902                .as_ref()
903                .map(PromptMemoryFlags::from)
904                .unwrap_or_default(),
905            features_dynamic_model_routing: config.features.dynamic_model_routing,
906            permission_mode: Some(if active_plan_gate {
907                PermissionMode::Plan
908            } else {
909                configured_permission_mode
910            }),
911            gold_config,
912            guardian_config,
913            guardian_spawner,
914            bash_resume_hook,
915            bash_completion_sink,
916            hook_runner,
917            // Capture the tool executor's server-level guidance (connected MCP
918            // servers' `instructions`) once, so it lands in the system prompt only
919            // while those servers are loaded for this run.
920            mcp_tool_guidance: tools.tool_guidance(),
921            // Config-level default, tighten-only-merged with the request's
922            // override (issue #221): per field the minimum wins, so no caller
923            // can loosen the operator's ceiling. Merging here (rather than in
924            // the HTTP layer) means every caller — HTTP, schedules, connect,
925            // the in-proc SDK — gets the same clamped fallback for free.
926            run_budget: config.run_budget.merged_with_override(run_budget.as_ref()),
927            stream_timeout: config.stream_timeout,
928            ..Default::default()
929        };
930
931        drop(config);
932
933        if let Some(plan) = &ticket_worker_plan {
934            session
935                .agent_runtime_state
936                .get_or_insert_with(Default::default)
937                .run_id = plan.run_id().to_owned();
938            plan.bind_session(session)
939                .map_err(|e| bamboo_agent_core::AgentError::LLM(e.to_string()))?;
940        }
941
942        let trace_message_start = session.messages.len();
943        let session_end_runner = loop_config.hook_runner.clone();
944        let session_end_event_tx = event_tx.clone();
945        let result = Box::pin(run_agent_loop_with_config(
946            session,
947            initial_message,
948            event_tx,
949            llm,
950            tools,
951            cancel_token,
952            loop_config,
953        ))
954        .await;
955
956        crate::runtime::hooks::run_session_end_hooks(
957            &session_end_runner,
958            &result,
959            session,
960            &session_end_event_tx,
961        )
962        .await;
963
964        // The runtime is the one shared execute boundary for HTTP, child-agent
965        // and direct SDK callers.  Checkpoint here on every outcome so durable
966        // transcript correctness never depends on a caller-specific finalize
967        // wrapper (and normal completions without a TaskLoopContext are saved
968        // too).  The checkpoint is append-safe: a stale live snapshot cannot
969        // shrink/rewrite messages appended concurrently on disk.
970        //
971        // Persistence failure must not replace the execution outcome.  In
972        // particular, callers need the original LLM/cancellation error for
973        // retry and terminal-status mapping; the failed durability attempt is
974        // recorded separately.
975        let final_checkpoint_succeeded =
976            match self.persistence.checkpoint_runtime_session(session).await {
977                Ok(()) => true,
978                Err(checkpoint_error) => {
979                    match &result {
980                        Ok(()) => tracing::warn!(
981                            session_id = %session.id,
982                            error = %checkpoint_error,
983                            "failed to checkpoint session transcript after successful execution"
984                        ),
985                        Err(execution_error) => tracing::warn!(
986                            session_id = %session.id,
987                            error = %checkpoint_error,
988                            execution_error = %execution_error,
989                            "failed to checkpoint session transcript after execution error"
990                        ),
991                    }
992                    false
993                }
994            };
995
996        if should_observe_completed_tool_trace(&result, final_checkpoint_succeeded, session) {
997            if let Err(error) = self
998                .skill_manager
999                .observe_completed_tool_trace(session, trace_message_start)
1000                .await
1001            {
1002                tracing::warn!(
1003                    session_id = %session.id,
1004                    %error,
1005                    "failed to observe completed tool trace for reuse draft discovery"
1006                );
1007            }
1008        }
1009
1010        result
1011    }
1012}