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