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}
244
245// ---------------------------------------------------------------------------
246// ExecuteRequestBuilder — ergonomic construction of ExecuteRequest
247// ---------------------------------------------------------------------------
248
249/// Fluent builder for [`ExecuteRequest`].
250///
251/// `ExecuteRequest` carries three required fields plus a long tail of optional
252/// overrides; constructing it by hand forces callers to spell out ~20 `None`s.
253/// This builder requires only `initial_message` + `event_tx` + `cancel_token`,
254/// defaults every optional field to `None` (matching the runtime's spawn
255/// defaults), and exposes fluent setters for the rest.
256///
257/// It lives in `bamboo-engine` so both the in-crate server layer (e.g. the
258/// schedule manager) and the root `bamboo_agent` SDK facade (which re-exports
259/// it) construct requests through one shared builder — no forked assembly.
260pub struct ExecuteRequestBuilder {
261    initial_message: String,
262    event_tx: mpsc::Sender<AgentEvent>,
263    cancel_token: CancellationToken,
264
265    tools: Option<Arc<dyn ToolExecutor>>,
266    provider_override: Option<Arc<dyn LLMProvider>>,
267    // Individual model fields are accumulated here for backward-compatible
268    // fluent setters; `build()` assembles them into a `ModelRoster`. A
269    // `.model_roster(..)` setter seeds all of them at once.
270    model: Option<String>,
271    provider_name: Option<String>,
272    provider_type: Option<String>,
273    fast_model: Option<String>,
274    fast_model_provider: Option<Arc<dyn LLMProvider>>,
275    background_model: Option<String>,
276    background_model_provider: Option<Arc<dyn LLMProvider>>,
277    summarization_model: Option<String>,
278    summarization_model_provider: Option<Arc<dyn LLMProvider>>,
279    reasoning_effort: Option<ReasoningEffort>,
280    auxiliary_model_resolver: Option<Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>>,
281    disabled_filter_resolver:
282        Option<Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>>,
283    disabled_tools: Option<BTreeSet<String>>,
284    disabled_skill_ids: Option<BTreeSet<String>>,
285    selected_skill_ids: Option<Vec<String>>,
286    selected_skill_mode: Option<String>,
287    image_fallback: Option<ImageFallbackConfig>,
288    gold_config: Option<GoldConfig>,
289    guardian_config: Option<GuardianConfig>,
290    guardian_spawner: Option<Arc<dyn GuardianSpawner>>,
291    bash_resume_hook: Option<Arc<dyn BashResumeHook>>,
292    bash_completion_sink: Option<Arc<dyn BashCompletionSink>>,
293    app_data_dir: Option<std::path::PathBuf>,
294}
295
296impl ExecuteRequestBuilder {
297    /// Create a builder with the three required fields. All optional overrides
298    /// default to `None`.
299    pub fn new(
300        initial_message: impl Into<String>,
301        event_tx: mpsc::Sender<AgentEvent>,
302        cancel_token: CancellationToken,
303    ) -> Self {
304        Self {
305            initial_message: initial_message.into(),
306            event_tx,
307            cancel_token,
308            tools: None,
309            provider_override: None,
310            model: None,
311            provider_name: None,
312            provider_type: None,
313            fast_model: None,
314            fast_model_provider: None,
315            background_model: None,
316            background_model_provider: None,
317            summarization_model: None,
318            summarization_model_provider: None,
319            reasoning_effort: None,
320            auxiliary_model_resolver: None,
321            disabled_filter_resolver: None,
322            disabled_tools: None,
323            disabled_skill_ids: None,
324            selected_skill_ids: None,
325            selected_skill_mode: None,
326            image_fallback: None,
327            gold_config: None,
328            guardian_config: None,
329            guardian_spawner: None,
330            bash_resume_hook: None,
331            bash_completion_sink: None,
332            app_data_dir: None,
333        }
334    }
335
336    /// Override the tool executor for this execution.
337    pub fn tools(mut self, v: Arc<dyn ToolExecutor>) -> Self {
338        self.tools = Some(v);
339        self
340    }
341
342    /// Override the LLM provider for this execution.
343    pub fn provider_override(mut self, v: Arc<dyn LLMProvider>) -> Self {
344        self.provider_override = Some(v);
345        self
346    }
347
348    /// Seed the full model selection from a [`ModelRoster`].
349    ///
350    /// Decomposes the roster back into the builder's individual fields so it
351    /// composes with the existing per-field fluent setters; later individual
352    /// setters override the corresponding roster entry.
353    pub fn model_roster(mut self, roster: ModelRoster) -> Self {
354        self.fast_model = roster.fast_model();
355        self.fast_model_provider = roster.fast_model_provider();
356        self.background_model = roster.background_model();
357        self.background_model_provider = roster.background_model_provider();
358        self.summarization_model = roster.summarization_model();
359        self.summarization_model_provider = roster.summarization_model_provider();
360        self.model = roster.model;
361        self.provider_name = roster.provider_name;
362        self.provider_type = roster.provider_type;
363        self
364    }
365
366    /// Override the primary model name.
367    pub fn model(mut self, v: impl Into<String>) -> Self {
368        self.model = Some(v.into());
369        self
370    }
371
372    /// Override the provider name.
373    pub fn provider_name(mut self, v: impl Into<String>) -> Self {
374        self.provider_name = Some(v.into());
375        self
376    }
377
378    /// Override the provider type.
379    pub fn provider_type(mut self, v: impl Into<String>) -> Self {
380        self.provider_type = Some(v.into());
381        self
382    }
383
384    /// Override the fast-model name.
385    pub fn fast_model(mut self, v: impl Into<String>) -> Self {
386        self.fast_model = Some(v.into());
387        self
388    }
389
390    /// Override the provider used for fast-model calls.
391    pub fn fast_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
392        self.fast_model_provider = Some(v);
393        self
394    }
395
396    /// Override the background-model name.
397    pub fn background_model(mut self, v: impl Into<String>) -> Self {
398        self.background_model = Some(v.into());
399        self
400    }
401
402    /// Override the provider used for background/memory model calls.
403    pub fn background_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
404        self.background_model_provider = Some(v);
405        self
406    }
407
408    /// Override the summarization-model name.
409    pub fn summarization_model(mut self, v: impl Into<String>) -> Self {
410        self.summarization_model = Some(v.into());
411        self
412    }
413
414    /// Override the provider used for summarization/compression calls.
415    pub fn summarization_model_provider(mut self, v: Arc<dyn LLMProvider>) -> Self {
416        self.summarization_model_provider = Some(v);
417        self
418    }
419
420    /// Set the reasoning effort.
421    pub fn reasoning_effort(mut self, v: ReasoningEffort) -> Self {
422        self.reasoning_effort = Some(v);
423        self
424    }
425
426    /// Set the per-round auxiliary-model resolver.
427    pub fn auxiliary_model_resolver(
428        mut self,
429        v: Arc<dyn Fn() -> AuxiliaryModelConfig + Send + Sync>,
430    ) -> Self {
431        self.auxiliary_model_resolver = Some(v);
432        self
433    }
434
435    /// Set the per-round resolver for the live disabled tool/skill sets (#136).
436    pub fn disabled_filter_resolver(
437        mut self,
438        v: Arc<dyn Fn() -> (BTreeSet<String>, BTreeSet<String>) + Send + Sync>,
439    ) -> Self {
440        self.disabled_filter_resolver = Some(v);
441        self
442    }
443
444    /// Set the disabled tool names (merged with config defaults at runtime).
445    pub fn disabled_tools(mut self, v: BTreeSet<String>) -> Self {
446        self.disabled_tools = Some(v);
447        self
448    }
449
450    /// Set the disabled skill ids.
451    pub fn disabled_skill_ids(mut self, v: BTreeSet<String>) -> Self {
452        self.disabled_skill_ids = Some(v);
453        self
454    }
455
456    /// Set the explicitly selected skill ids.
457    pub fn selected_skill_ids(mut self, v: Vec<String>) -> Self {
458        self.selected_skill_ids = Some(v);
459        self
460    }
461
462    /// Set the skill-selection mode.
463    pub fn selected_skill_mode(mut self, v: impl Into<String>) -> Self {
464        self.selected_skill_mode = Some(v.into());
465        self
466    }
467
468    /// Set the image fallback configuration.
469    pub fn image_fallback(mut self, v: ImageFallbackConfig) -> Self {
470        self.image_fallback = Some(v);
471        self
472    }
473
474    /// Set the internal `gold_config` feature flag.
475    ///
476    /// `gold_config` is an internal feature flag (not part of the public SDK
477    /// surface), so this setter is crate-visible only. Public SDK callers always
478    /// leave it `None`; the in-crate spawn paths thread a resolved value through
479    /// here. Defaults to `None`.
480    pub(crate) fn gold_config(mut self, v: Option<GoldConfig>) -> Self {
481        self.gold_config = v;
482        self
483    }
484
485    /// Set the internal `guardian_config` feature flag (crate-visible, like
486    /// [`Self::gold_config`]). Public SDK callers leave it `None`.
487    pub(crate) fn guardian_config(mut self, v: Option<GuardianConfig>) -> Self {
488        self.guardian_config = v;
489        self
490    }
491
492    /// Set the late-bound guardian reviewer spawner (crate-visible; wired by the
493    /// server's spawn path so the runner can create the reviewer child).
494    pub(crate) fn guardian_spawner(mut self, v: Option<Arc<dyn GuardianSpawner>>) -> Self {
495        self.guardian_spawner = v;
496        self
497    }
498
499    /// Set the late-bound bash self-resume hook (crate-visible; wired by the
500    /// server so a session suspended on background bash is always resumed).
501    pub(crate) fn bash_resume_hook(mut self, v: Option<Arc<dyn BashResumeHook>>) -> Self {
502        self.bash_resume_hook = v;
503        self
504    }
505
506    /// Set the late-bound bash completion sink (crate-visible; wired by the
507    /// server so a completed background shell's result is pushed into the loop).
508    pub(crate) fn bash_completion_sink(
509        mut self,
510        v: Option<Arc<dyn BashCompletionSink>>,
511    ) -> Self {
512        self.bash_completion_sink = v;
513        self
514    }
515
516    /// Set the Bamboo application data directory.
517    pub fn app_data_dir(mut self, v: std::path::PathBuf) -> Self {
518        self.app_data_dir = Some(v);
519        self
520    }
521
522    /// Materialize the underlying [`ExecuteRequest`].
523    ///
524    /// `gold_config` is an internal feature flag with only a crate-visible
525    /// setter ([`Self::gold_config`]); public SDK callers leave it `None`.
526    pub fn build(self) -> ExecuteRequest {
527        let model_roster = ModelRoster {
528            model: self.model,
529            provider_name: self.provider_name,
530            provider_type: self.provider_type,
531            fast: RoleModel::from_parts(self.fast_model, self.fast_model_provider),
532            background: RoleModel::from_parts(
533                self.background_model,
534                self.background_model_provider,
535            ),
536            summarization: RoleModel::from_parts(
537                self.summarization_model,
538                self.summarization_model_provider,
539            ),
540        };
541        ExecuteRequest {
542            initial_message: self.initial_message,
543            event_tx: self.event_tx,
544            cancel_token: self.cancel_token,
545            tools: self.tools,
546            provider_override: self.provider_override,
547            model_roster,
548            reasoning_effort: self.reasoning_effort,
549            auxiliary_model_resolver: self.auxiliary_model_resolver,
550            disabled_filter_resolver: self.disabled_filter_resolver,
551            disabled_tools: self.disabled_tools,
552            disabled_skill_ids: self.disabled_skill_ids,
553            selected_skill_ids: self.selected_skill_ids,
554            selected_skill_mode: self.selected_skill_mode,
555            image_fallback: self.image_fallback,
556            gold_config: self.gold_config,
557            guardian_config: self.guardian_config,
558            guardian_spawner: self.guardian_spawner,
559            bash_resume_hook: self.bash_resume_hook,
560            bash_completion_sink: self.bash_completion_sink,
561            app_data_dir: self.app_data_dir,
562        }
563    }
564}
565
566// ---------------------------------------------------------------------------
567// Helpers
568// ---------------------------------------------------------------------------
569
570/// Extract the system prompt from session messages.
571fn extract_system_prompt(session: &Session) -> Option<String> {
572    session
573        .messages
574        .iter()
575        .find(|m| matches!(m.role, Role::System))
576        .map(|m| m.content.clone())
577}
578
579// ---------------------------------------------------------------------------
580// Execution
581// ---------------------------------------------------------------------------
582
583impl AgentRuntime {
584    /// Execute the agent loop with the given request.
585    ///
586    /// Builds an [`AgentLoopConfig`] from the request parameters and shared
587    /// runtime resources, then delegates to [`run_agent_loop_with_config`].
588    pub async fn execute(
589        &self,
590        session: &mut Session,
591        req: ExecuteRequest,
592    ) -> crate::runtime::runner::Result<()> {
593        let system_prompt = extract_system_prompt(session);
594        let config = self.config.read().await;
595        let ExecuteRequest {
596            initial_message,
597            event_tx,
598            cancel_token,
599            tools,
600            provider_override,
601            model_roster,
602            reasoning_effort,
603            auxiliary_model_resolver,
604            disabled_filter_resolver,
605            disabled_tools,
606            disabled_skill_ids,
607            selected_skill_ids,
608            selected_skill_mode,
609            image_fallback,
610            gold_config,
611            guardian_config,
612            guardian_spawner,
613            bash_resume_hook,
614            bash_completion_sink,
615            app_data_dir,
616        } = req;
617        let tools = tools.unwrap_or_else(|| self.default_tools.clone());
618        let llm = provider_override.unwrap_or_else(|| self.provider.clone());
619
620        // Decompose the roster back into the loose locals the resolution logic
621        // below expects. This preserves the byte-for-byte `None → Config`
622        // fallbacks: a `None` role yields `None` name/provider exactly as the
623        // old loose fields did.
624        let fast_model = model_roster.fast_model();
625        let fast_model_provider = model_roster.fast_model_provider();
626        let background_model = model_roster.background_model();
627        let background_model_provider = model_roster.background_model_provider();
628        let summarization_model = model_roster.summarization_model();
629        let summarization_model_provider = model_roster.summarization_model_provider();
630        let ModelRoster {
631            model,
632            provider_name,
633            provider_type,
634            ..
635        } = model_roster;
636
637        let loop_config = AgentLoopConfig {
638            max_rounds: 200,
639            system_prompt,
640            // Snapshot the legacy model_limits from the live in-memory config so
641            // resolve_token_budget never falls back to a disk-reading Config::new(). #38.
642            legacy_model_limits: config.extra.get("model_limits").cloned(),
643            disabled_skill_ids: disabled_skill_ids.unwrap_or_else(|| config.disabled_skill_ids()),
644            selected_skill_ids,
645            selected_skill_mode,
646            skill_manager: Some(self.skill_manager.clone()),
647            skip_initial_user_message: true,
648            storage: Some(self.storage.clone()),
649            persistence: Some(self.persistence.clone()),
650            attachment_reader: Some(self.attachment_reader.clone()),
651            metrics_collector: Some(self.metrics_collector.clone()),
652            model_name: model,
653            fast_model_name: fast_model.or_else(|| config.get_fast_model()),
654            fast_model_provider,
655            background_model_name: background_model
656                .or_else(|| config.get_memory_background_model()),
657            planning_model_name: config
658                .defaults
659                .as_ref()
660                .and_then(|d| d.planning.as_ref())
661                .map(|r| r.model.clone()),
662            search_model_name: config
663                .defaults
664                .as_ref()
665                .and_then(|d| d.search.as_ref().or(d.fast.as_ref()))
666                .map(|r| r.model.clone()),
667            compression_instructions: None,
668            summarization_model_name: summarization_model
669                .or_else(|| config.get_task_summary_model()),
670            background_model_provider,
671            summarization_model_provider,
672            provider_name: Some(provider_name.unwrap_or_else(|| config.provider.clone())),
673            provider_type,
674            reasoning_effort,
675            auxiliary_model_resolver,
676            disabled_filter_resolver,
677            disabled_tools: {
678                let mut merged = config.disabled_tool_names();
679                if let Some(dt) = disabled_tools {
680                    merged.extend(dt);
681                }
682                merged
683            },
684            image_fallback,
685            app_data_dir,
686            prompt_memory_flags: config
687                .memory
688                .as_ref()
689                .map(PromptMemoryFlags::from)
690                .unwrap_or_default(),
691            features_dynamic_model_routing: config.features.dynamic_model_routing,
692            permission_mode: session
693                .agent_runtime_state
694                .as_ref()
695                .and_then(|state| state.plan_mode.as_ref())
696                .map(|_| PermissionMode::Plan),
697            gold_config,
698            guardian_config,
699            guardian_spawner,
700            bash_resume_hook,
701            bash_completion_sink,
702            // Capture the tool executor's server-level guidance (connected MCP
703            // servers' `instructions`) once, so it lands in the system prompt only
704            // while those servers are loaded for this run.
705            mcp_tool_guidance: tools.tool_guidance(),
706            ..Default::default()
707        };
708
709        drop(config);
710
711        run_agent_loop_with_config(
712            session,
713            initial_message,
714            event_tx,
715            llm,
716            tools,
717            cancel_token,
718            loop_config,
719        )
720        .await
721    }
722}