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, HashMap};
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, ProviderRegistry};
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
38pub(crate) type AuxiliaryModelResolver = Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>;
39pub(crate) type AuxiliaryModelResolverFactory =
40    Arc<dyn Fn(&str) -> AuxiliaryModelResolver + Send + Sync>;
41
42// A host factory is installed only on the Schedule Agent clone. Explicitly
43// absent roster keys are passed as empty, never substituted with a global key.
44fn select_auxiliary_model_resolver(
45    factory: Option<&AuxiliaryModelResolverFactory>,
46    roster_provider: Option<&str>,
47    fallback: Option<AuxiliaryModelResolver>,
48) -> Option<AuxiliaryModelResolver> {
49    factory
50        .map(|create| create(roster_provider.unwrap_or_default()))
51        .or(fallback)
52}
53
54#[cfg(test)]
55mod auxiliary_resolver_factory_tests {
56    use super::*;
57    use std::sync::Mutex;
58
59    #[test]
60    fn factory_receives_each_roster_key_and_sdk_fallback_is_unchanged() {
61        let keys = Arc::new(Mutex::new(Vec::new()));
62        let recorded = keys.clone();
63        let factory: AuxiliaryModelResolverFactory = Arc::new(move |key| {
64            recorded.lock().unwrap().push(key.to_string());
65            let name = key.to_string();
66            Arc::new(move || AuxiliaryModelConfig {
67                fast_model_name: Some(name.clone()),
68                ..Default::default()
69            })
70        });
71        let fallback: AuxiliaryModelResolver = Arc::new(|| AuxiliaryModelConfig {
72            fast_model_name: Some("snapshot".into()),
73            ..Default::default()
74        });
75        let a = select_auxiliary_model_resolver(
76            Some(&factory),
77            Some("account-a"),
78            Some(fallback.clone()),
79        )
80        .unwrap();
81        let b = select_auxiliary_model_resolver(
82            Some(&factory),
83            Some("account-b"),
84            Some(fallback.clone()),
85        )
86        .unwrap();
87        let missing = select_auxiliary_model_resolver(Some(&factory), None, None).unwrap();
88        assert_eq!(a().fast_model_name.as_deref(), Some("account-a"));
89        assert_eq!(b().fast_model_name.as_deref(), Some("account-b"));
90        assert_eq!(missing().fast_model_name.as_deref(), Some(""));
91        assert_eq!(*keys.lock().unwrap(), vec!["account-a", "account-b", ""]);
92        let unchanged =
93            select_auxiliary_model_resolver(None, Some("account-a"), Some(fallback.clone()))
94                .unwrap();
95        assert!(Arc::ptr_eq(&unchanged, &fallback));
96        assert!(select_auxiliary_model_resolver(None, None, None).is_none());
97    }
98}
99
100// Preserve the exact managed child-resume route through the optional Work
101// decorator. `main` is the undecorated override and `decorated_main` was built
102// from it by execute_bound. Do not trust a later resolver caller to supply the
103// matching delegate, and do not silently downgrade an invariant violation.
104pub(crate) fn bind_child_gold_delegate(
105    config: &mut AgentLoopConfig,
106    selected: Option<&Arc<dyn LLMProvider>>,
107    main: &Arc<dyn LLMProvider>,
108    decorated_main: &Arc<dyn LLMProvider>,
109) -> crate::runtime::runner::Result<()> {
110    let Some(selected) = selected else {
111        return Ok(());
112    };
113    if !Arc::ptr_eq(selected, main) {
114        return Err(bamboo_agent_core::AgentError::LLM(
115            "Managed child-resume provider differs from its selected route".into(),
116        ));
117    }
118    let resolve = config.gold_target_resolver.take().ok_or_else(|| {
119        bamboo_agent_core::AgentError::LLM(
120            "Managed child-resume Gold route is missing its guard".into(),
121        )
122    })?;
123    let delegate = decorated_main.clone();
124    config.gold_target_resolver = Some(Arc::new(move |model, _untrusted_delegate| {
125        resolve(model, delegate.clone())
126    }));
127    Ok(())
128}
129
130// ---------------------------------------------------------------------------
131// AgentRuntime — shared resources (assembled once)
132// ---------------------------------------------------------------------------
133
134/// Shared runtime resources assembled once at server startup.
135///
136/// Each field is an `Arc` or `Clone`-cheap handle so that `AgentRuntime` itself
137/// is cheaply cloneable across tasks.
138#[derive(Clone)]
139pub struct AgentRuntime {
140    pub storage: Arc<dyn Storage>,
141    pub persistence: Arc<dyn RuntimeSessionPersistence>,
142    /// Capturing no inherited wait is also immutable for this execution.
143    pub(crate) inherited_child_wait_captured: bool,
144    /// Config-derived fallback is an explicit per-run Host choice.
145    pub(crate) legacy_image_from_config: bool,
146    pub(crate) legacy_image_provider_source:
147        Option<Arc<bamboo_llm::provider_registry::LegacyVisionProviderSource>>,
148    pub(crate) sdk_skill_execution_host:
149        Option<Arc<dyn crate::runtime::config::SdkSkillExecutionHost>>,
150    pub(crate) workspace_execution_host:
151        Option<Arc<dyn crate::runtime::config::WorkspaceExecutionHost>>,
152    pub session_inbox: Option<Arc<dyn SessionInboxPort>>,
153    pub(crate) live_bootstrap_host: Option<Arc<dyn crate::runtime::config::LiveBootstrapHost>>,
154    pub(crate) gold_target_resolver: Option<crate::runtime::config::GoldTargetResolver>,
155    pub(crate) host_gold_origin_resolver: Option<crate::runtime::host_gold::HostGoldOriginResolver>,
156    // Private to managed child resume; never inferred from a registry name.
157    pub(crate) child_resume_gold_selected: Option<Arc<dyn LLMProvider>>,
158    pub(crate) background_model_resolver: Option<crate::runtime::config::BackgroundModelResolver>,
159    pub(crate) auxiliary_model_resolver_factory: Option<AuxiliaryModelResolverFactory>,
160    pub(crate) resume_auxiliary_resolver: Option<crate::runtime::config::ResumeAuxiliaryResolver>,
161    pub activation_router: Option<Arc<SessionActivationRouter>>,
162    pub session_messenger: Option<Arc<SessionMessenger>>,
163    pub attachment_reader: Arc<dyn AttachmentReader>,
164    pub skill_manager: Arc<SkillManager>,
165    pub project_context_resolver: Option<Arc<crate::project_context::ProjectContextResolver>>,
166    pub metrics_collector: MetricsCollector,
167    pub config: Arc<RwLock<Config>>,
168    pub(crate) model_limits_section:
169        Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
170    /// Live process-wide permission policy when the embedding exposes one.
171    pub permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
172    /// SDK/static fallback used when there is no live policy handle.
173    pub permission_mode: PermissionMode,
174
175    /// Reloadable LLM provider handle (delegates to the latest provider).
176    pub provider: Arc<dyn LLMProvider>,
177
178    /// Concrete Jiandu store shared by every run from this runtime.
179    pub memory_store: bamboo_memory::memory_store::MemoryStore,
180
181    /// Default tool executor (root tools with full surface).
182    /// Call sites that need a reduced tool set (child / schedule) pass their
183    /// own via `ExecuteRequest::tools`.
184    pub default_tools: Arc<dyn ToolExecutor>,
185
186    /// Immutable hook registry shared by runs. Each execute call snapshots the
187    /// `Arc` onto its sealed [`AgentLoopConfig`].
188    pub hook_runner: Arc<HookRunner>,
189}
190
191// ---------------------------------------------------------------------------
192// AgentRuntimeBuilder
193// ---------------------------------------------------------------------------
194
195/// Builder for [`AgentRuntime`].
196///
197/// ```
198/// # use bamboo_engine::AgentRuntimeBuilder;
199/// // In real code all fields are provided by the server assembly.
200/// let rt = AgentRuntimeBuilder::new()
201///     // .storage(...)
202///     // .provider(...)
203///     .build();
204/// ```
205pub struct AgentRuntimeBuilder {
206    storage: Option<Arc<dyn Storage>>,
207    persistence: Option<Arc<dyn RuntimeSessionPersistence>>,
208    session_inbox: Option<Arc<dyn SessionInboxPort>>,
209    activation_router: Option<Arc<SessionActivationRouter>>,
210    session_messenger: Option<Arc<SessionMessenger>>,
211    attachment_reader: Option<Arc<dyn AttachmentReader>>,
212    skill_manager: Option<Arc<SkillManager>>,
213    project_context_resolver: Option<Arc<crate::project_context::ProjectContextResolver>>,
214    metrics_collector: Option<MetricsCollector>,
215    config: Option<Arc<RwLock<Config>>>,
216    model_limits_section:
217        Option<Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>>,
218    permission_config: Option<Arc<bamboo_tools::permission::PermissionConfig>>,
219    permission_mode: PermissionMode,
220    provider: Option<Arc<dyn LLMProvider>>,
221    memory_store: bamboo_memory::memory_store::MemoryStore,
222    default_tools: Option<Arc<dyn ToolExecutor>>,
223    hook_runner: Arc<HookRunner>,
224}
225
226impl AgentRuntimeBuilder {
227    pub fn new() -> Self {
228        Self {
229            storage: None,
230            persistence: None,
231            session_inbox: None,
232            activation_router: None,
233            session_messenger: None,
234            attachment_reader: None,
235            skill_manager: None,
236            project_context_resolver: None,
237            metrics_collector: None,
238            config: None,
239            model_limits_section: None,
240            permission_config: None,
241            permission_mode: PermissionMode::Default,
242            provider: None,
243            memory_store: bamboo_memory::memory_store::MemoryStore::with_defaults(),
244            default_tools: None,
245            hook_runner: Arc::new(HookRunner::new()),
246        }
247    }
248
249    pub fn storage(mut self, v: Arc<dyn Storage>) -> Self {
250        self.storage = Some(v);
251        self
252    }
253
254    pub fn persistence(mut self, v: Arc<dyn RuntimeSessionPersistence>) -> Self {
255        self.persistence = Some(v);
256        self
257    }
258
259    pub fn session_inbox(mut self, v: Arc<dyn SessionInboxPort>) -> Self {
260        self.session_inbox = Some(v);
261        self
262    }
263
264    pub fn activation_router(mut self, v: Arc<SessionActivationRouter>) -> Self {
265        self.activation_router = Some(v);
266        self
267    }
268
269    pub fn session_messenger(mut self, v: Arc<SessionMessenger>) -> Self {
270        self.session_messenger = Some(v);
271        self
272    }
273
274    pub fn attachment_reader(mut self, v: Arc<dyn AttachmentReader>) -> Self {
275        self.attachment_reader = Some(v);
276        self
277    }
278
279    pub fn skill_manager(mut self, v: Arc<SkillManager>) -> Self {
280        self.skill_manager = Some(v);
281        self
282    }
283
284    pub fn project_context_resolver(
285        mut self,
286        v: Arc<crate::project_context::ProjectContextResolver>,
287    ) -> Self {
288        self.project_context_resolver = Some(v);
289        self
290    }
291
292    pub fn metrics_collector(mut self, v: MetricsCollector) -> Self {
293        self.metrics_collector = Some(v);
294        self
295    }
296
297    pub fn config(mut self, v: Arc<RwLock<Config>>) -> Self {
298        self.config = Some(v);
299        self
300    }
301
302    /// Use a host's validated model-limit snapshots for every agent round.
303    /// The host owns reloads and section health; budget resolution performs no
304    /// file I/O when this handle is installed.
305    pub fn model_limits_section(
306        mut self,
307        section: Arc<bamboo_config::LiveSection<bamboo_config::ModelLimitsSection>>,
308    ) -> Self {
309        self.model_limits_section = Some(section);
310        self
311    }
312
313    pub fn permission_config(mut self, v: Arc<bamboo_tools::permission::PermissionConfig>) -> Self {
314        self.permission_config = Some(v);
315        self
316    }
317
318    pub fn permission_mode(mut self, v: PermissionMode) -> Self {
319        self.permission_mode = v;
320        self
321    }
322
323    pub fn provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
324        self.provider = Some(v);
325        self
326    }
327
328    pub fn memory_store(mut self, v: bamboo_memory::memory_store::MemoryStore) -> Self {
329        self.memory_store = v;
330        self
331    }
332
333    pub fn default_tools(mut self, v: Arc<dyn ToolExecutor>) -> Self {
334        self.default_tools = Some(v);
335        self
336    }
337
338    /// Install the lifecycle hook registry used by all runs from this runtime.
339    pub fn hook_runner(mut self, v: Arc<HookRunner>) -> Self {
340        self.hook_runner = v;
341        self
342    }
343
344    pub fn build(self) -> Result<AgentRuntime, &'static str> {
345        if let (Some(router), Some(inbox)) = (&self.activation_router, &self.session_inbox) {
346            router.set_inbox(inbox.clone());
347        }
348        Ok(AgentRuntime {
349            inherited_child_wait_captured: false,
350            legacy_image_from_config: false,
351            legacy_image_provider_source: None,
352            sdk_skill_execution_host: None,
353            workspace_execution_host: None,
354            storage: self.storage.ok_or_else(|| format_missing("storage"))?,
355            persistence: self
356                .persistence
357                .ok_or_else(|| format_missing("persistence"))?,
358            session_inbox: self.session_inbox,
359            live_bootstrap_host: None,
360            gold_target_resolver: None,
361            host_gold_origin_resolver: None,
362            child_resume_gold_selected: None,
363            background_model_resolver: None,
364            auxiliary_model_resolver_factory: None,
365            resume_auxiliary_resolver: None,
366            activation_router: self.activation_router,
367            session_messenger: self.session_messenger,
368            attachment_reader: self
369                .attachment_reader
370                .ok_or_else(|| format_missing("attachment_reader"))?,
371            skill_manager: self
372                .skill_manager
373                .ok_or_else(|| format_missing("skill_manager"))?,
374            project_context_resolver: self.project_context_resolver,
375            metrics_collector: self
376                .metrics_collector
377                .ok_or_else(|| format_missing("metrics_collector"))?,
378            config: self.config.ok_or_else(|| format_missing("config"))?,
379            model_limits_section: self.model_limits_section,
380            permission_config: self.permission_config,
381            permission_mode: self.permission_mode,
382            provider: self.provider.ok_or_else(|| format_missing("provider"))?,
383            memory_store: self.memory_store,
384            default_tools: self
385                .default_tools
386                .ok_or_else(|| format_missing("default_tools"))?,
387            hook_runner: self.hook_runner,
388        })
389    }
390}
391
392fn format_missing(field: &str) -> &'static str {
393    // Static strings for the common fields keep the error type `&'static str`.
394    // This is good enough for a builder that only runs at startup.
395    match field {
396        "storage" => "AgentRuntimeBuilder: missing storage",
397        "persistence" => "AgentRuntimeBuilder: missing persistence",
398        "attachment_reader" => "AgentRuntimeBuilder: missing attachment_reader",
399        "skill_manager" => "AgentRuntimeBuilder: missing skill_manager",
400        "metrics_collector" => "AgentRuntimeBuilder: missing metrics_collector",
401        "config" => "AgentRuntimeBuilder: missing config",
402        "provider" => "AgentRuntimeBuilder: missing provider",
403        "default_tools" => "AgentRuntimeBuilder: missing default_tools",
404        _ => "AgentRuntimeBuilder: missing required field",
405    }
406}
407
408impl Default for AgentRuntimeBuilder {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414// ---------------------------------------------------------------------------
415// ExecuteRequest — per-request parameters
416// ---------------------------------------------------------------------------
417
418/// Per-request parameters for agent execution.
419///
420/// Required fields (`initial_message`, `event_tx`, `cancel_token`) must always
421/// be provided.  The provider is taken from [`AgentRuntime::provider`]; tools
422/// default to [`AgentRuntime::default_tools`] when `None`.
423pub struct ExecuteRequest {
424    /// Native Host attribution, never decoded from HTTP or Worker arguments.
425    pub ticket_token_budget: Option<Arc<crate::ticket_token_budget::NativeWorkTokenBudget>>,
426    /// Host-installed, per-run private plan capability. Never decoded from HTTP.
427    pub ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
428    // -- Required ----------------------------------------------------------
429    pub initial_message: String,
430    pub event_tx: mpsc::Sender<AgentEvent>,
431    pub cancel_token: CancellationToken,
432
433    // -- Tool override -----------------------------------------------------
434    /// Override runtime's `default_tools`.  When `None`, uses the runtime's
435    /// default tool executor.
436    pub tools: Option<Arc<dyn ToolExecutor>>,
437    /// Override the LLM provider for this execution. When `None`, uses the
438    /// runtime's shared provider handle.
439    pub provider_override: Option<Arc<dyn LLMProvider>>,
440
441    // -- Model selection (None roles → config defaults) -------------------
442    /// Cohesive primary + auxiliary model/provider selection. Replaces the old
443    /// `model` / `provider_name` / `provider_type` / `fast_model(+provider)` /
444    /// `background_model(+provider)` / `summarization_model(+provider)` clump.
445    /// Per-role `None` preserves the same `None → Config::get_*` fallbacks
446    /// applied during [`AgentRuntime::execute`].
447    pub model_roster: ModelRoster,
448    pub reasoning_effort: Option<ReasoningEffort>,
449    /// Optional per-round resolver for auxiliary model settings that should be
450    /// re-read from live global config between rounds.
451    pub auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
452    /// Optional per-round live resolver for the disabled tool/skill sets (#136).
453    /// When `None`, the snapshotted `disabled_tools`/`disabled_skill_ids` are used.
454    pub disabled_filter_resolver: Option<DisabledFilterResolver>,
455    /// When `None`, falls back to `Config::disabled_tool_references()`.
456    pub disabled_tools: Option<BTreeSet<String>>,
457    /// When `None`, falls back to `Config::disabled_skill_ids()`.
458    pub disabled_skill_ids: Option<BTreeSet<String>>,
459    pub selected_skill_ids: Option<Vec<String>>,
460    pub selected_skill_mode: Option<String>,
461    pub image_fallback: Option<ImageFallbackConfig>,
462    pub gold_config: Option<GoldConfig>,
463    /// Optional guardian adversarial-review gate configuration.
464    pub guardian_config: Option<GuardianConfig>,
465    /// Late-bound spawner for the guardian reviewer child (wired by the server;
466    /// the runner cannot construct a child directly).
467    pub guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
468    /// Late-bound hook that arranges a self-resume after a background-bash
469    /// suspend (issue #84 Phase 2b). Wired by the server.
470    pub bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
471    /// Late-bound sink that pushes a completed background-bash shell's result
472    /// into this loop (issue #84 Phase 2b follow-up). Wired by the server.
473    pub bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
474    /// Bamboo application data directory (typically `~/.bamboo`).
475    pub app_data_dir: Option<std::path::PathBuf>,
476    /// Per-run resource guardrail override (issue #221): token / tool-call /
477    /// subagent budget for THIS execution. TIGHTEN-ONLY: per field, the
478    /// effective limit is the minimum of this override and the config-level
479    /// `Config::run_budget` default — a request can lower the operator's
480    /// ceiling but never raise or remove it. An unset field (`None`) keeps
481    /// the config default (which may itself be unlimited). See
482    /// [`bamboo_config::RunBudgetConfig::merged_with_override`].
483    pub run_budget: Option<bamboo_config::RunBudgetConfig>,
484    /// Opt-in live counted repetition under the caller's existing execution owner.
485    /// Requires the shared activation router and an already registered run.
486    pub counted_loop: Option<super::counted_loop::CountedLoopConfig>,
487    /// Explicit finite authority for the model to start one loop in this activation.
488    pub loop_control: Option<super::loop_control::LoopControlConfig>,
489}
490
491// ---------------------------------------------------------------------------
492// ExecuteRequestBuilder — ergonomic construction of ExecuteRequest
493// ---------------------------------------------------------------------------
494
495/// Fluent builder for [`ExecuteRequest`].
496///
497/// `ExecuteRequest` carries three required fields plus a long tail of optional
498/// overrides; constructing it by hand forces callers to spell out ~20 `None`s.
499/// This builder requires only `initial_message` + `event_tx` + `cancel_token`,
500/// defaults every optional field to `None` (matching the runtime's spawn
501/// defaults), and exposes fluent setters for the rest.
502///
503/// It lives in `bamboo-engine` so both the in-crate server layer (e.g. the
504/// schedule manager) and the root `bamboo_agent` SDK facade (which re-exports
505/// it) construct requests through one shared builder — no forked assembly.
506pub struct ExecuteRequestBuilder {
507    ticket_token_budget: Option<Arc<crate::ticket_token_budget::NativeWorkTokenBudget>>,
508    ticket_worker_plan: Option<Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>>,
509    initial_message: String,
510    event_tx: mpsc::Sender<AgentEvent>,
511    cancel_token: CancellationToken,
512
513    tools: Option<Arc<dyn ToolExecutor>>,
514    provider_override: Option<Arc<dyn LLMProvider>>,
515    // Individual model fields are accumulated here for backward-compatible
516    // fluent setters; `build()` assembles them into a `ModelRoster`. A
517    // `.model_roster(..)` setter seeds all of them at once.
518    model: Option<String>,
519    provider_name: Option<String>,
520    provider_type: Option<String>,
521    fast_model: Option<String>,
522    fast_model_provider: Option<Arc<dyn LLMProvider>>,
523    background_model: Option<String>,
524    background_model_provider: Option<Arc<dyn LLMProvider>>,
525    summarization_model: Option<String>,
526    summarization_model_provider: Option<Arc<dyn LLMProvider>>,
527    reasoning_effort: Option<ReasoningEffort>,
528    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
529    disabled_filter_resolver: Option<DisabledFilterResolver>,
530    disabled_tools: Option<BTreeSet<String>>,
531    disabled_skill_ids: Option<BTreeSet<String>>,
532    selected_skill_ids: Option<Vec<String>>,
533    selected_skill_mode: Option<String>,
534    image_fallback: Option<ImageFallbackConfig>,
535    gold_config: Option<GoldConfig>,
536    guardian_config: Option<GuardianConfig>,
537    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
538    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
539    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
540    app_data_dir: Option<std::path::PathBuf>,
541    run_budget: Option<bamboo_config::RunBudgetConfig>,
542    counted_loop: Option<super::counted_loop::CountedLoopConfig>,
543    loop_control: Option<super::loop_control::LoopControlConfig>,
544}
545
546impl ExecuteRequestBuilder {
547    pub(crate) fn ticket_token_budget(
548        mut self,
549        budget: Arc<crate::ticket_token_budget::NativeWorkTokenBudget>,
550    ) -> Self {
551        self.ticket_token_budget = Some(budget);
552        self
553    }
554
555    /// Create a builder with the three required fields. All optional overrides
556    /// default to `None`.
557    pub fn new(
558        initial_message: impl Into<String>,
559        event_tx: mpsc::Sender<AgentEvent>,
560        cancel_token: CancellationToken,
561    ) -> Self {
562        Self {
563            initial_message: initial_message.into(),
564            ticket_worker_plan: None,
565            ticket_token_budget: None,
566            event_tx,
567            cancel_token,
568            tools: None,
569            provider_override: None,
570            model: None,
571            provider_name: None,
572            provider_type: None,
573            fast_model: None,
574            fast_model_provider: None,
575            background_model: None,
576            background_model_provider: None,
577            summarization_model: None,
578            summarization_model_provider: None,
579            reasoning_effort: None,
580            auxiliary_model_resolver: None,
581            disabled_filter_resolver: None,
582            disabled_tools: None,
583            disabled_skill_ids: None,
584            selected_skill_ids: None,
585            selected_skill_mode: None,
586            image_fallback: None,
587            gold_config: None,
588            guardian_config: None,
589            guardian_spawner: None,
590            bash_resume_hook: None,
591            bash_completion_sink: None,
592            app_data_dir: None,
593            run_budget: None,
594            counted_loop: None,
595            loop_control: None,
596        }
597    }
598
599    /// Install a trusted, per-run private LocalPlan port.
600    pub fn ticket_worker_plan(
601        mut self,
602        plan: Arc<dyn crate::ticket_worker_plan::WorkerLocalPlan>,
603    ) -> Self {
604        self.ticket_worker_plan = Some(plan);
605        self
606    }
607
608    /// Override the tool executor for this execution.
609    pub fn tools(mut self, v: Arc<dyn ToolExecutor>) -> Self {
610        self.tools = Some(v);
611        self
612    }
613
614    /// Override the LLM provider for this execution.
615    pub fn provider_override(mut self, v: Arc<dyn LLMProvider>) -> Self {
616        self.provider_override = Some(v);
617        self
618    }
619
620    /// Seed the full model selection from a [`ModelRoster`].
621    ///
622    /// Decomposes the roster back into the builder's individual fields so it
623    /// composes with the existing per-field fluent setters; later individual
624    /// setters override the corresponding roster entry.
625    pub fn model_roster(mut self, roster: ModelRoster) -> Self {
626        self.fast_model = roster.fast_model();
627        self.fast_model_provider = roster.fast_model_provider();
628        self.background_model = roster.background_model();
629        self.background_model_provider = roster.background_model_provider();
630        self.summarization_model = roster.summarization_model();
631        self.summarization_model_provider = roster.summarization_model_provider();
632        self.model = roster.model;
633        self.provider_name = roster.provider_name;
634        self.provider_type = roster.provider_type;
635        self
636    }
637
638    /// Override the primary model name.
639    pub fn model(mut self, v: impl Into<String>) -> Self {
640        self.model = Some(v.into());
641        self
642    }
643
644    /// Override the provider name.
645    pub fn provider_name(mut self, v: impl Into<String>) -> Self {
646        self.provider_name = Some(v.into());
647        self
648    }
649
650    /// Override the provider type.
651    pub fn provider_type(mut self, v: impl Into<String>) -> Self {
652        self.provider_type = Some(v.into());
653        self
654    }
655
656    /// Override the fast-model name.
657    pub fn fast_model(mut self, v: impl Into<String>) -> Self {
658        self.fast_model = Some(v.into());
659        self
660    }
661
662    /// Override the provider used for fast-model calls.
663    pub fn fast_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
664        self.fast_model_provider = Some(v);
665        self
666    }
667
668    /// Override the background-model name.
669    pub fn background_model(mut self, v: impl Into<String>) -> Self {
670        self.background_model = Some(v.into());
671        self
672    }
673
674    /// Override the provider used for background/memory model calls.
675    pub fn background_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
676        self.background_model_provider = Some(v);
677        self
678    }
679
680    /// Override the summarization-model name.
681    pub fn summarization_model(mut self, v: impl Into<String>) -> Self {
682        self.summarization_model = Some(v.into());
683        self
684    }
685
686    /// Override the provider used for summarization/compression calls.
687    pub fn summarization_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
688        self.summarization_model_provider = Some(v);
689        self
690    }
691
692    /// Set the reasoning effort.
693    pub fn reasoning_effort(mut self, v: ReasoningEffort) -> Self {
694        self.reasoning_effort = Some(v);
695        self
696    }
697
698    /// Set the per-round auxiliary-model resolver.
699    pub fn auxiliary_model_resolver(
700        mut self,
701        v: Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>,
702    ) -> Self {
703        self.auxiliary_model_resolver = Some(v);
704        self
705    }
706
707    /// Set the per-round resolver for the live disabled tool/skill sets (#136).
708    pub fn disabled_filter_resolver(mut self, v: DisabledFilterResolver) -> Self {
709        self.disabled_filter_resolver = Some(v);
710        self
711    }
712
713    /// Set the disabled tool names (merged with config defaults at runtime).
714    pub fn disabled_tools(mut self, v: BTreeSet<String>) -> Self {
715        self.disabled_tools = Some(v);
716        self
717    }
718
719    /// Set the disabled skill ids.
720    pub fn disabled_skill_ids(mut self, v: BTreeSet<String>) -> Self {
721        self.disabled_skill_ids = Some(v);
722        self
723    }
724
725    /// Set the explicitly selected skill ids.
726    pub fn selected_skill_ids(mut self, v: Vec<String>) -> Self {
727        self.selected_skill_ids = Some(v);
728        self
729    }
730
731    /// Set the skill-selection mode.
732    pub fn selected_skill_mode(mut self, v: impl Into<String>) -> Self {
733        self.selected_skill_mode = Some(v.into());
734        self
735    }
736
737    /// Set the image fallback configuration.
738    pub fn image_fallback(mut self, v: ImageFallbackConfig) -> Self {
739        self.image_fallback = Some(v);
740        self
741    }
742
743    /// Set the internal `gold_config` feature flag.
744    ///
745    /// `gold_config` is an internal feature flag (not part of the public SDK
746    /// surface), so this setter is crate-visible only. Public SDK callers always
747    /// leave it `None`; the in-crate spawn paths thread a resolved value through
748    /// here. Defaults to `None`.
749    pub(crate) fn gold_config(mut self, v: Option<GoldConfig>) -> Self {
750        self.gold_config = v;
751        self
752    }
753
754    /// Set the internal `guardian_config` feature flag (crate-visible, like
755    /// [`Self::gold_config`]). Public SDK callers leave it `None`.
756    pub(crate) fn guardian_config(mut self, v: Option<GuardianConfig>) -> Self {
757        self.guardian_config = v;
758        self
759    }
760
761    /// Set the late-bound guardian reviewer spawner (crate-visible; wired by the
762    /// server's spawn path so the runner can create the reviewer child).
763    pub(crate) fn guardian_spawner(mut self, v: Option<Arc<dyn GuardianSpawner>>) -> Self {
764        self.guardian_spawner = v;
765        self
766    }
767
768    /// Set the late-bound bash self-resume hook (crate-visible; wired by the
769    /// server so a session suspended on background bash is always resumed).
770    pub(crate) fn bash_resume_hook(mut self, v: Option<Arc<dyn BashResumeHook>>) -> Self {
771        self.bash_resume_hook = v;
772        self
773    }
774
775    /// Set the late-bound bash completion sink (crate-visible; wired by the
776    /// server so a completed background shell's result is pushed into the loop).
777    pub(crate) fn bash_completion_sink(mut self, v: Option<Arc<dyn BashCompletionSink>>) -> Self {
778        self.bash_completion_sink = v;
779        self
780    }
781
782    /// Set the Bamboo application data directory.
783    pub fn app_data_dir(mut self, v: std::path::PathBuf) -> Self {
784        self.app_data_dir = Some(v);
785        self
786    }
787
788    /// Set the per-run resource guardrail override (issue #221). TIGHTEN-ONLY:
789    /// per field, the effective limit is the minimum of `v` and the
790    /// config-level default — this can never loosen the operator's ceiling;
791    /// see [`bamboo_config::RunBudgetConfig::merged_with_override`].
792    pub fn run_budget(mut self, v: bamboo_config::RunBudgetConfig) -> Self {
793        self.run_budget = Some(v);
794        self
795    }
796
797    /// Repeat the submitted prompt with explicit iteration and total-round bounds.
798    /// This is live-only; lifecycle/model management is a separate application seam.
799    pub fn counted_loop(mut self, config: super::counted_loop::CountedLoopConfig) -> Self {
800        self.counted_loop = Some(config);
801        self
802    }
803
804    /// Authorize live self start/status/update/stop within explicit host ceilings.
805    /// This starts no loop and grants no Session ownership.
806    pub fn loop_control(mut self, config: super::loop_control::LoopControlConfig) -> Self {
807        self.loop_control = Some(config);
808        self
809    }
810
811    /// Materialize the underlying [`ExecuteRequest`].
812    ///
813    /// `gold_config` is an internal feature flag with only a crate-visible
814    /// setter ([`Self::gold_config`]); public SDK callers leave it `None`.
815    pub fn build(self) -> ExecuteRequest {
816        let model_roster = ModelRoster {
817            model: self.model,
818            provider_name: self.provider_name,
819            provider_type: self.provider_type,
820            fast: RoleModel::from_parts(self.fast_model, self.fast_model_provider),
821            background: RoleModel::from_parts(
822                self.background_model,
823                self.background_model_provider,
824            ),
825            summarization: RoleModel::from_parts(
826                self.summarization_model,
827                self.summarization_model_provider,
828            ),
829        };
830        ExecuteRequest {
831            ticket_worker_plan: self.ticket_worker_plan,
832            ticket_token_budget: self.ticket_token_budget,
833            initial_message: self.initial_message,
834            event_tx: self.event_tx,
835            cancel_token: self.cancel_token,
836            tools: self.tools,
837            provider_override: self.provider_override,
838            model_roster,
839            reasoning_effort: self.reasoning_effort,
840            auxiliary_model_resolver: self.auxiliary_model_resolver,
841            disabled_filter_resolver: self.disabled_filter_resolver,
842            disabled_tools: self.disabled_tools,
843            disabled_skill_ids: self.disabled_skill_ids,
844            selected_skill_ids: self.selected_skill_ids,
845            selected_skill_mode: self.selected_skill_mode,
846            image_fallback: self.image_fallback,
847            gold_config: self.gold_config,
848            guardian_config: self.guardian_config,
849            guardian_spawner: self.guardian_spawner,
850            bash_resume_hook: self.bash_resume_hook,
851            bash_completion_sink: self.bash_completion_sink,
852            app_data_dir: self.app_data_dir,
853            run_budget: self.run_budget,
854            counted_loop: self.counted_loop,
855            loop_control: self.loop_control,
856        }
857    }
858}
859
860// ---------------------------------------------------------------------------
861// Helpers
862// ---------------------------------------------------------------------------
863
864/// Extract the system prompt from session messages.
865fn extract_system_prompt(session: &Session) -> Option<String> {
866    session
867        .messages
868        .iter()
869        .find(|m| matches!(m.role, Role::System))
870        .map(|m| m.content.clone())
871}
872
873pub(super) fn should_observe_completed_tool_trace(
874    result: &crate::runtime::runner::Result<()>,
875    final_checkpoint_succeeded: bool,
876    session: &Session,
877) -> bool {
878    final_checkpoint_succeeded
879        && result.is_ok()
880        && session
881            .agent_runtime_state
882            .as_ref()
883            .is_some_and(|state| matches!(state.status, bamboo_domain::AgentStatusState::Completed))
884        && !session.metadata.contains_key("runtime.suspend_reason")
885        && !session.metadata.contains_key("runtime.completion_reason")
886}
887
888// ---------------------------------------------------------------------------
889// Execution
890// ---------------------------------------------------------------------------
891
892impl AgentRuntime {
893    /// Execute the agent loop with the given request.
894    ///
895    /// Builds an [`AgentLoopConfig`] from the request parameters and shared
896    /// runtime resources, then delegates to [`run_agent_loop_with_config`].
897    pub async fn execute(
898        &self,
899        session: &mut Session,
900        req: ExecuteRequest,
901    ) -> crate::runtime::runner::Result<()> {
902        self.execute_with_inputs(session, req, None).await
903    }
904
905    /// Select an initial-request retry policy for this execution only.
906    pub async fn execute_with_upstream_retry(
907        &self,
908        session: &mut Session,
909        req: ExecuteRequest,
910        upstream_retry: bamboo_llm::retry::UpstreamRetry,
911    ) -> crate::runtime::runner::Result<()> {
912        self.execute_with_inputs_and_upstream_retry(session, req, None, upstream_retry)
913            .await
914    }
915
916    pub(crate) async fn execute_with_inputs(
917        &self,
918        session: &mut Session,
919        req: ExecuteRequest,
920        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
921    ) -> crate::runtime::runner::Result<()> {
922        self.execute_with_inputs_and_upstream_retry(
923            session,
924            req,
925            inputs,
926            bamboo_llm::retry::UpstreamRetry::Bounded,
927        )
928        .await
929    }
930
931    pub(crate) async fn execute_with_inputs_and_upstream_retry(
932        &self,
933        session: &mut Session,
934        req: ExecuteRequest,
935        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
936        upstream_retry: bamboo_llm::retry::UpstreamRetry,
937    ) -> crate::runtime::runner::Result<()> {
938        struct WorkspaceFinish(Option<Arc<dyn crate::runtime::config::WorkspaceExecutionHost>>);
939        impl Drop for WorkspaceFinish {
940            fn drop(&mut self) {
941                if let Some(host) = &self.0 {
942                    host.finish();
943                }
944            }
945        }
946        // Even an admission refusal closes only this captured bridge. Do not
947        // retain an apparently live Weak entry across router finalization.
948        let _workspace_finish = WorkspaceFinish(self.workspace_execution_host.clone());
949        session
950            .check_execution_compatibility()
951            .map_err(|error| bamboo_agent_core::AgentError::Tool(error.to_string()))?;
952        // Refusals are not executions: never reach SessionEnd or the generic
953        // final checkpoint, and never mutate another counted owner's clone.
954        super::counted_loop::reject_ambiguous_reentry(session, self.persistence.as_ref()).await?;
955        if let Some(control) = req.loop_control.as_ref() {
956            control
957                .validate_admission(session, self.activation_router.as_ref())
958                .await?;
959        }
960        struct SdkFinish {
961            host: Option<Arc<dyn crate::runtime::config::SdkSkillExecutionHost>>,
962            session_id: String,
963            execution_id: String,
964        }
965        impl Drop for SdkFinish {
966            fn drop(&mut self) {
967                if let Some(host) = self.host.as_ref() {
968                    host.finish(&self.session_id, &self.execution_id);
969                }
970            }
971        }
972        let _sdk_finish = SdkFinish {
973            host: self.sdk_skill_execution_host.clone(),
974            session_id: session.id.clone(),
975            execution_id: inputs
976                .as_ref()
977                .and_then(|inputs| inputs.sdk_execution_id())
978                .unwrap_or("")
979                .to_owned(),
980        };
981        let existing = self.persistence.inherited_child_wait();
982        if let Some(inherited) = existing.as_ref() {
983            inherited
984                .validate_session(session)
985                .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
986        } else if !self.inherited_child_wait_captured {
987            let mut runtime = self.clone();
988            if let Some(inherited) = bamboo_domain::InheritedChildWait::capture(session) {
989                runtime.persistence = self
990                    .persistence
991                    .bind_inherited_child_wait(inherited)
992                    .map_err(|error| bamboo_agent_core::AgentError::LLM(error.to_string()))?;
993            }
994            runtime.inherited_child_wait_captured = true;
995            return runtime
996                .execute_bound(session, req, inputs, upstream_retry)
997                .await;
998        }
999        self.execute_bound(session, req, inputs, upstream_retry)
1000            .await
1001    }
1002
1003    async fn execute_bound(
1004        &self,
1005        session: &mut Session,
1006        req: ExecuteRequest,
1007        inputs: Option<crate::runtime::config::UntrustedExecutionInputs>,
1008        upstream_retry: bamboo_llm::retry::UpstreamRetry,
1009    ) -> crate::runtime::runner::Result<()> {
1010        if self.persistence.root_actor_execution_required(session) {
1011            return Err(bamboo_agent_core::AgentError::Tool(
1012                "Root execution route requires a bound Actor writer and event handoff".into(),
1013            ));
1014        }
1015        let session_activation_notifications = match self.activation_router.as_ref() {
1016            Some(router) => Some(Arc::new(parking_lot::Mutex::new(
1017                router.subscribe(&session.id).await,
1018            ))),
1019            None => None,
1020        };
1021        let guidance_active_run_id = match self.activation_router.as_ref() {
1022            Some(router) => router.current_run_id(&session.id).await,
1023            None => None,
1024        };
1025        if let Some(counted) = req.counted_loop.as_ref() {
1026            if !counted
1027                .owns_session(self.activation_router.as_ref(), session)
1028                .await
1029            {
1030                return Err(bamboo_agent_core::AgentError::Budget(
1031                    "counted loop requires the caller's exact Session activation owner".into(),
1032                ));
1033            }
1034        }
1035        let system_prompt = extract_system_prompt(session);
1036        let config = self.config.read().await;
1037        let ExecuteRequest {
1038            ticket_worker_plan,
1039            ticket_token_budget,
1040            initial_message,
1041            event_tx,
1042            cancel_token,
1043            tools,
1044            provider_override,
1045            model_roster,
1046            reasoning_effort,
1047            auxiliary_model_resolver,
1048            disabled_filter_resolver,
1049            disabled_tools,
1050            disabled_skill_ids,
1051            selected_skill_ids,
1052            selected_skill_mode,
1053            image_fallback,
1054            gold_config,
1055            guardian_config,
1056            guardian_spawner,
1057            bash_resume_hook,
1058            bash_completion_sink,
1059            app_data_dir,
1060            run_budget,
1061            counted_loop,
1062            loop_control,
1063        } = req;
1064        let tools = tools.unwrap_or_else(|| self.default_tools.clone());
1065        let uses_default_provider = provider_override.is_none();
1066        let llm = provider_override.unwrap_or_else(|| self.provider.clone());
1067        let configured_permission_mode = self
1068            .permission_config
1069            .as_ref()
1070            .map(|config| config.mode())
1071            .unwrap_or(self.permission_mode);
1072        let active_plan_gate = session
1073            .agent_runtime_state
1074            .as_ref()
1075            .is_some_and(|state| state.plan_mode.is_some());
1076
1077        // Explicit SDK role overrides retain their supplied client/name seam.
1078        // Unset roles use the same strict live defaults as Host consumers.
1079        let explicit_roles = [
1080            model_roster.fast.is_some(),
1081            model_roster.background.is_some(),
1082            model_roster.summarization.is_some(),
1083        ];
1084        let fast_model = model_roster.fast_model();
1085        let fast_model_provider = model_roster.fast_model_provider();
1086        let background_model = model_roster.background_model();
1087        let background_model_provider = model_roster.background_model_provider();
1088        let summarization_model = model_roster.summarization_model();
1089        let summarization_model_provider = model_roster.summarization_model_provider();
1090        let ModelRoster {
1091            model,
1092            provider_name,
1093            provider_type,
1094            ..
1095        } = model_roster;
1096        let auxiliary_model_resolver = select_auxiliary_model_resolver(
1097            self.auxiliary_model_resolver_factory.as_ref(),
1098            provider_name.as_deref(),
1099            auxiliary_model_resolver,
1100        );
1101        let active_provider =
1102            provider_name.unwrap_or_else(|| config.effective_default_provider().to_string());
1103        let auxiliary_model_resolver = auxiliary_model_resolver.or_else(|| {
1104            // A direct SDK client authorizes this declared account only. A
1105            // different configured role needs an explicit Host resolver/client;
1106            // never borrow the main client for another account's model name.
1107            let registry = Arc::new(ProviderRegistry::new(
1108                HashMap::from([(active_provider.clone(), llm.clone())]),
1109                active_provider.clone(),
1110            ));
1111            let resolve = crate::model_areas::live_auxiliary_model_resolver(
1112                self.config.clone(),
1113                registry,
1114                active_provider.clone(),
1115            );
1116            let overrides = AuxiliaryModelConfig {
1117                fast_model_name: fast_model.clone().or_else(|| config.get_fast_model()),
1118                fast_model_provider: fast_model_provider.clone(),
1119                background_model_name: background_model
1120                    .clone()
1121                    .or_else(|| config.get_memory_background_model()),
1122                background_model_provider: background_model_provider.clone(),
1123                summarization_model_name: summarization_model
1124                    .clone()
1125                    .or_else(|| config.get_task_summary_model()),
1126                summarization_model_provider: summarization_model_provider.clone(),
1127                ..Default::default()
1128            };
1129            let resolver: Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync> =
1130                Arc::new(move || {
1131                    let mut current = resolve();
1132                    if explicit_roles[0] {
1133                        current.fast_model_name = overrides.fast_model_name.clone();
1134                        current.fast_model_provider = overrides.fast_model_provider.clone();
1135                    }
1136                    if explicit_roles[1] {
1137                        current.background_model_name = overrides.background_model_name.clone();
1138                        current.background_model_provider =
1139                            overrides.background_model_provider.clone();
1140                    }
1141                    if explicit_roles[2] {
1142                        current.summarization_model_name =
1143                            overrides.summarization_model_name.clone();
1144                        current.summarization_model_provider =
1145                            overrides.summarization_model_provider.clone();
1146                        current.summarization_model_error = None;
1147                    }
1148                    current
1149                });
1150            Some(resolver)
1151        });
1152        let hook_runner = inputs
1153            .as_ref()
1154            .and_then(|inputs| inputs.sdk_hook_runner())
1155            .unwrap_or_else(|| {
1156                Arc::new(
1157                    self.hook_runner
1158                        .with_lifecycle_config(&config.lifecycle_hooks, app_data_dir.clone()),
1159                )
1160            });
1161
1162        let mut effective_run_budget = config.run_budget.merged_with_override(run_budget.as_ref());
1163        let legacy_vision_model_resolver = image_fallback
1164            .as_ref()
1165            .filter(|fallback| fallback.mode == crate::ImageFallbackMode::Vision)
1166            .map(|fallback| {
1167                crate::runtime::runner::image_fallback::legacy_vision_resolver(
1168                    config.clone(),
1169                    active_provider.clone(),
1170                    llm.clone(),
1171                    fallback.vision_model.clone(),
1172                    app_data_dir.clone(),
1173                    self.legacy_image_from_config,
1174                    self.legacy_image_provider_source.clone(),
1175                )
1176            });
1177        if let Some(control) = loop_control.as_ref() {
1178            effective_run_budget.max_rounds = Some(
1179                effective_run_budget
1180                    .max_rounds
1181                    .map_or(control.max_total_rounds, |cap| {
1182                        cap.min(control.max_total_rounds)
1183                    }),
1184            );
1185        }
1186        let mut loop_config = AgentLoopConfig {
1187            ticket_worker_plan: ticket_worker_plan.clone(),
1188            guidance_active_run_id,
1189            system_prompt,
1190            // Snapshot the legacy model_limits from the live in-memory config so
1191            // resolve_token_budget never falls back to a disk-reading Config::new(). #38.
1192            legacy_model_limits: config.extra.get("model_limits").cloned(),
1193            model_limits_section: self.model_limits_section.clone(),
1194            disabled_skill_ids: disabled_skill_ids.unwrap_or_else(|| config.disabled_skill_ids()),
1195            selected_skill_ids,
1196            selected_skill_mode,
1197            initial_untrusted_inputs: inputs,
1198            sdk_skill_execution_host: self.sdk_skill_execution_host.clone(),
1199            workspace_execution_host: self.workspace_execution_host.clone(),
1200            skill_manager: Some(self.skill_manager.clone()),
1201            project_context_resolver: self.project_context_resolver.clone(),
1202            skip_initial_user_message: true,
1203            storage: Some(self.storage.clone()),
1204            persistence: Some(self.persistence.clone()),
1205            session_inbox: self.session_inbox.clone(),
1206            live_bootstrap_host: self.live_bootstrap_host.clone(),
1207            gold_target_resolver: self.gold_target_resolver.clone(),
1208            session_activation_notifications,
1209            attachment_reader: Some(self.attachment_reader.clone()),
1210            metrics_collector: Some(self.metrics_collector.clone()),
1211            model_name: model,
1212            fast_model_name: fast_model.or_else(|| config.get_fast_model()),
1213            fast_model_provider,
1214            auxiliary_evaluation_max_concurrency:
1215                crate::runtime::config::normalize_auxiliary_evaluation_max_concurrency(
1216                    config
1217                        .extra
1218                        .get("auxiliary_evaluation_max_concurrency")
1219                        .and_then(serde_json::Value::as_u64),
1220                ),
1221            background_model_name: background_model
1222                .or_else(|| config.get_memory_background_model()),
1223            planning_model_name: config
1224                .defaults
1225                .as_ref()
1226                .and_then(|d| d.planning.as_ref())
1227                .map(|r| r.model.clone()),
1228            search_model_name: config
1229                .defaults
1230                .as_ref()
1231                .and_then(|d| d.search.as_ref().or(d.fast.as_ref()))
1232                .map(|r| r.model.clone()),
1233            compression_instructions: None,
1234            summary_target_ratio: config
1235                .memory()
1236                .as_ref()
1237                .map(|memory| memory.summary_target_ratio)
1238                .unwrap_or(0.20),
1239            context_management: config.context_management.clone(),
1240            summary_safe_window_percent: config
1241                .memory()
1242                .as_ref()
1243                .map(|memory| memory.summary_safe_window_percent)
1244                .unwrap_or(80),
1245            summarization_model_name: summarization_model
1246                .or_else(|| config.get_task_summary_model()),
1247            background_model_provider,
1248            summarization_model_provider,
1249            provider_name: Some(active_provider.clone()),
1250            provider_type,
1251            reasoning_effort,
1252            auxiliary_model_resolver,
1253            background_model_resolver: self.background_model_resolver.clone(),
1254            resume_auxiliary_resolver: self.resume_auxiliary_resolver.clone(),
1255            disabled_filter_resolver,
1256            disabled_tools: {
1257                let mut merged = config.disabled_tool_references();
1258                if let Some(dt) = disabled_tools {
1259                    merged.extend(dt);
1260                }
1261                merged
1262            },
1263            image_fallback,
1264            legacy_vision_model_resolver,
1265            vision_model_resolver: Some(
1266                crate::runtime::runner::image_fallback::configured_vision_resolver(
1267                    self.config.clone(),
1268                    app_data_dir.clone(),
1269                    active_provider,
1270                ),
1271            ),
1272            app_data_dir,
1273            memory_store: self.memory_store.clone(),
1274            prompt_memory_flags: config
1275                .memory()
1276                .as_ref()
1277                .map(PromptMemoryFlags::from)
1278                .unwrap_or_default(),
1279            features_dynamic_model_routing: config.features.dynamic_model_routing,
1280            features_final_evidence_check: config.features.final_evidence_check,
1281            permission_mode: Some(if active_plan_gate {
1282                PermissionMode::Plan
1283            } else {
1284                configured_permission_mode
1285            }),
1286            gold_config,
1287            guardian_config,
1288            guardian_spawner,
1289            bash_resume_hook,
1290            bash_completion_sink,
1291            hook_runner,
1292            // Capture the tool executor's server-level guidance (connected MCP
1293            // servers' `instructions`) once, so it lands in the system prompt only
1294            // while those servers are loaded for this run.
1295            mcp_tool_guidance: tools.tool_guidance(),
1296            // Config-level default, tighten-only-merged with the request's
1297            // override (issue #221): per field the minimum wins, so no caller
1298            // can loosen the operator's ceiling. Merging here (rather than in
1299            // the HTTP layer) means every caller — HTTP, schedules, connect,
1300            // the in-proc SDK — gets the same clamped fallback for free.
1301            run_budget: effective_run_budget,
1302            counted_loop,
1303            loop_control,
1304            stream_timeout: config.stream_timeout,
1305            upstream_retry,
1306            ..Default::default()
1307        };
1308
1309        drop(config);
1310        let undecorated_main = llm.clone();
1311        let llm = match ticket_token_budget.as_ref() {
1312            Some(budget) => budget.decorate(&mut loop_config, llm),
1313            None => llm,
1314        };
1315        // A Host default provider can change between main and Gold. Capture
1316        // each accepted main dispatch rather than asking the mutable handle
1317        // again when a dedicated Gold request starts.
1318        if uses_default_provider
1319            && loop_config
1320                .gold_config
1321                .as_ref()
1322                .is_some_and(|gold| gold.enabled && gold.model_name.is_some())
1323        {
1324            if let Some(origin_resolver) = self.host_gold_origin_resolver.clone() {
1325                let tracker = crate::runtime::host_gold::HostGoldRun::new(origin_resolver);
1326                let source_tracker = tracker.clone();
1327                let live_config = self.config.clone();
1328                let original_main = undecorated_main.clone();
1329                let budget = ticket_token_budget.clone();
1330                let limits = Arc::new(AgentLoopConfig {
1331                    app_data_dir: loop_config.app_data_dir.clone(),
1332                    model_limits_section: loop_config.model_limits_section.clone(),
1333                    legacy_model_limits: loop_config.legacy_model_limits.clone(),
1334                    ..Default::default()
1335                });
1336                let inherited = loop_config.gold_target_resolver.take();
1337                loop_config.gold_target_resolver = Some(Arc::new(move |model, _mutable_main| {
1338                    let receipt = match source_tracker.accepted() {
1339                        Ok(receipt) => receipt,
1340                        Err(reason) => {
1341                            return Arc::new(crate::runtime::host_gold::UnprovenGold {
1342                                reason,
1343                                refused: std::sync::atomic::AtomicBool::new(false),
1344                            }) as Arc<dyn LLMProvider>
1345                        }
1346                    };
1347                    let fixed = match &receipt.origin {
1348                        crate::runtime::host_gold::HostGoldOrigin::Injected => {
1349                            bamboo_llm::provider::fixed_injected_provider_with_identity(
1350                                receipt.selected.clone(),
1351                                original_main.clone(),
1352                            )
1353                        }
1354                        crate::runtime::host_gold::HostGoldOrigin::Managed { .. } => {
1355                            bamboo_llm::provider::fixed_provider_with_identity(
1356                                receipt.selected.clone(),
1357                                original_main.clone(),
1358                            )
1359                        }
1360                    };
1361                    let delegate = match budget.as_ref() {
1362                        Some(budget) => budget.wrap(fixed, &limits),
1363                        None => fixed,
1364                    };
1365                    match receipt.origin {
1366                        crate::runtime::host_gold::HostGoldOrigin::Injected => inherited
1367                            .as_ref()
1368                            .map(|policy| policy(model, delegate.clone()))
1369                            .unwrap_or(delegate),
1370                        crate::runtime::host_gold::HostGoldOrigin::Managed {
1371                            provider_name,
1372                            origin,
1373                        } => {
1374                            let selected = receipt.selected;
1375                            let config = live_config.clone();
1376                            let managed: crate::runtime::config::GoldTargetResolver =
1377                                Arc::new(move |model, delegate| {
1378                                    bamboo_llm::gold_admission::guarded_gold_target_with_origin(
1379                                        config.clone(),
1380                                        origin.clone(),
1381                                        bamboo_domain::ProviderModelRef::new(
1382                                            provider_name.as_str(),
1383                                            model,
1384                                        ),
1385                                        selected.clone(),
1386                                        delegate,
1387                                    )
1388                                });
1389                            crate::runtime::agent::compose_child_gold_policy(
1390                                inherited.clone(),
1391                                managed,
1392                            )(model, delegate)
1393                        }
1394                    }
1395                }));
1396                loop_config.host_gold_run = Some(tracker);
1397            }
1398        }
1399        bind_child_gold_delegate(
1400            &mut loop_config,
1401            self.child_resume_gold_selected.as_ref(),
1402            &undecorated_main,
1403            &llm,
1404        )?;
1405        #[cfg(test)]
1406        crate::runtime::tests::observe_untrusted_inputs(
1407            &session.id,
1408            loop_config.initial_untrusted_inputs(),
1409        );
1410
1411        if let Some(plan) = &ticket_worker_plan {
1412            session
1413                .agent_runtime_state
1414                .get_or_insert_with(Default::default)
1415                .run_id = plan.run_id().to_owned();
1416            plan.bind_session(session)
1417                .map_err(|e| bamboo_agent_core::AgentError::LLM(e.to_string()))?;
1418        }
1419
1420        let trace_message_start = session.messages.len();
1421        let session_end_runner = loop_config.hook_runner.clone();
1422        let session_end_event_tx = event_tx.clone();
1423        let sdk_mode = loop_config.sdk_skill_execution_host.is_some();
1424        let loop_future = Box::pin(run_agent_loop_with_config(
1425            session,
1426            initial_message,
1427            event_tx,
1428            llm,
1429            tools,
1430            cancel_token,
1431            loop_config,
1432        ));
1433        let result = if sdk_mode {
1434            crate::runtime::runner::session_setup::legacy_instruction::scope_sdk_runtime(
1435                loop_future,
1436            )
1437            .await
1438        } else {
1439            loop_future.await
1440        };
1441
1442        crate::runtime::hooks::run_session_end_hooks(
1443            &session_end_runner,
1444            &result,
1445            session,
1446            &session_end_event_tx,
1447        )
1448        .await;
1449
1450        // Capture only the typed terminal watchdog error from this execution.
1451        // The existing checkpoint and exact run fingerprint own durable state;
1452        // the existing bounded live projection attaches the same receipt to the
1453        // error event without changing terminal ordering or retry decisions.
1454        if let Err(bamboo_agent_core::AgentError::StreamTimeout(timeout)) = &result {
1455            if let Some(diagnostic) = timeout.diagnostic(session) {
1456                if session.record_llm_request_diagnostic(diagnostic.clone()) {
1457                    bamboo_llm::retry::record_stream_timeout_diagnostic(diagnostic);
1458                }
1459            }
1460        }
1461
1462        // The runtime is the one shared execute boundary for HTTP, child-agent
1463        // and direct SDK callers.  Checkpoint here on every outcome so durable
1464        // transcript correctness never depends on a caller-specific finalize
1465        // wrapper (and normal completions without a TaskLoopContext are saved
1466        // too).  The checkpoint is append-safe: a stale live snapshot cannot
1467        // shrink/rewrite messages appended concurrently on disk.
1468        //
1469        // Persistence failure must not replace the execution outcome.  In
1470        // particular, callers need the original LLM/cancellation error for
1471        // retry and terminal-status mapping; the failed durability attempt is
1472        // recorded separately.
1473        let final_checkpoint_succeeded =
1474            match self.persistence.checkpoint_runtime_session(session).await {
1475                Ok(()) => true,
1476                Err(checkpoint_error) => {
1477                    match &result {
1478                        Ok(()) => tracing::warn!(
1479                            session_id = %session.id,
1480                            error = %checkpoint_error,
1481                            "failed to checkpoint session transcript after successful execution"
1482                        ),
1483                        Err(execution_error) => tracing::warn!(
1484                            session_id = %session.id,
1485                            error = %checkpoint_error,
1486                            execution_error = %execution_error,
1487                            "failed to checkpoint session transcript after execution error"
1488                        ),
1489                    }
1490                    false
1491                }
1492            };
1493
1494        if should_observe_completed_tool_trace(&result, final_checkpoint_succeeded, session) {
1495            if let Err(error) = self
1496                .skill_manager
1497                .observe_completed_tool_trace(session, trace_message_start)
1498                .await
1499            {
1500                tracing::warn!(
1501                    session_id = %session.id,
1502                    %error,
1503                    "failed to observe completed tool trace for reuse draft discovery"
1504                );
1505            }
1506        }
1507
1508        result
1509    }
1510}