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