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