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