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