Skip to main content

bamboo_engine/
resolved_defaults.rs

1//! Single, shared implementation of "resolve the server's current effective
2//! run configuration purely from live `Config`" — no per-request overrides.
3//!
4//! This is the one place that combines [`crate::model_areas`]'s
5//! auxiliary-area resolution, [`crate::model_config_helper`]'s
6//! provider-type/gold-config resolution, [`crate::prompt_defaults`]'s
7//! template read, and [`crate::context::assemble_system_prompt`] into a
8//! single resolved snapshot. Two callers need EXACTLY this: `bamboo-server`'s
9//! connect bridge (`resolve_connect_run_config`, called once per inbound chat
10//! message) and the public `GET /api/v1/execute/defaults` HTTP handler (an
11//! external connector's "what would the server currently run with" probe,
12//! issue #480). Both call [`resolve_default_run_config`] rather than each
13//! reimplementing the cascade — that reimplementation is exactly the drift
14//! #480 set out to prevent.
15//!
16//! Deliberately mirrors `schedule_app::manager::resolve_run_config_from_config`
17//! minus the per-job overrides a scheduled run supports (a chat message or a
18//! defaults probe has none).
19
20use std::sync::Arc;
21
22use bamboo_domain::reasoning::ReasoningEffort;
23use bamboo_llm::{Config, ProviderRegistry};
24
25use crate::config::GoldConfig;
26use crate::ModelRoster;
27
28/// Resolved model/prompt/workspace configuration for a run that carries no
29/// per-request overrides, derived purely from the live global config.
30#[derive(Clone)]
31pub struct ResolvedDefaultRunConfig {
32    /// Primary + auxiliary model/provider selection. The primary model is
33    /// resolved via `model_roster.model` (may be `None`/empty if the server
34    /// has no model configured at all).
35    pub model_roster: ModelRoster,
36    pub reasoning_effort: Option<ReasoningEffort>,
37    pub gold_config: Option<GoldConfig>,
38    /// Assembled system prompt (base template + workspace note).
39    pub system_prompt: String,
40    pub base_system_prompt: String,
41    pub workspace_path: Option<String>,
42}
43
44/// Resolve the server's current effective run configuration purely from live
45/// `Config` — no per-request overrides. See the module docs for why this is
46/// the sole implementation of this cascade.
47pub fn resolve_default_run_config(
48    config_snapshot: &Config,
49    provider_registry: &Arc<ProviderRegistry>,
50) -> ResolvedDefaultRunConfig {
51    let model = config_snapshot.get_model().unwrap_or_default();
52    let provider_name = Some(config_snapshot.effective_default_provider().to_string());
53    let provider_type = provider_name.as_deref().and_then(|name| {
54        crate::model_config_helper::resolve_provider_type(config_snapshot, name, provider_registry)
55    });
56    let capability_provider_name = provider_name
57        .as_deref()
58        .unwrap_or(config_snapshot.effective_default_provider());
59    // Auxiliary models are global (config-derived), never session-bound —
60    // see `crate::model_areas`'s module docs.
61    let areas = crate::model_areas::resolve_global_area_models(
62        config_snapshot,
63        capability_provider_name,
64        provider_registry,
65    );
66    let reasoning_effort = config_snapshot.get_reasoning_effort();
67    let base_system_prompt = crate::prompt_defaults::read_global_default_system_prompt_template();
68    let workspace_path = config_snapshot
69        .get_default_work_area_path()
70        .map(|path| bamboo_config::paths::path_to_display_string(&path));
71    let system_prompt = crate::context::assemble_system_prompt(
72        &base_system_prompt,
73        None,
74        workspace_path.as_deref(),
75    );
76    let model_roster = ModelRoster::from_areas(Some(model), provider_name, provider_type, areas);
77
78    ResolvedDefaultRunConfig {
79        model_roster,
80        reasoning_effort,
81        gold_config: crate::model_config_helper::resolve_gold_config(config_snapshot, None),
82        system_prompt,
83        base_system_prompt,
84        workspace_path,
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    macro_rules! test_config {
93        (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
94        (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
95        (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
96        (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
97        ($($field:ident: $value:expr),* $(,)?) => {{
98            let mut config = Config::default();
99            $(test_config!(@assign config, $field, $value);)*
100            config
101        }};
102    }
103    use bamboo_agent_core::tools::ToolSchema;
104    use bamboo_agent_core::Message;
105    use bamboo_config::{DefaultsConfig, FeatureFlags, OpenAIConfig, ProviderConfigs};
106    use bamboo_domain::ProviderModelRef;
107    use bamboo_llm::{LLMError, LLMProvider, LLMStream};
108    use std::collections::HashMap;
109
110    struct NoopProvider;
111
112    #[async_trait::async_trait]
113    impl LLMProvider for NoopProvider {
114        async fn chat_stream(
115            &self,
116            _messages: &[Message],
117            _tools: &[ToolSchema],
118            _max_output_tokens: Option<u32>,
119            _model: &str,
120        ) -> Result<LLMStream, LLMError> {
121            Err(LLMError::Api("noop".to_string()))
122        }
123    }
124
125    fn test_registry() -> Arc<ProviderRegistry> {
126        let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
127        providers.insert("openai".to_string(), Arc::new(NoopProvider));
128        Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
129    }
130
131    fn config_with_area_defaults() -> Config {
132        let defaults = DefaultsConfig {
133            chat: ProviderModelRef::new("openai", "gpt-chat"),
134            fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
135            task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
136            vision: None,
137            memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
138            planning: None,
139            search: None,
140            code_review: None,
141            sub_agent: None,
142            subagent_models: HashMap::new(),
143        };
144        test_config! {
145            provider: "openai".to_string(),
146            features: FeatureFlags {
147                provider_model_ref: true,
148                ..Default::default()
149            },
150            providers: ProviderConfigs {
151                openai: Some(OpenAIConfig {
152                    api_key: "test-secret-key".to_string(),
153                    model: Some("gpt-chat".to_string()),
154                    ..Default::default()
155                }),
156                ..Default::default()
157            },
158            defaults: Some(defaults),
159        }
160    }
161
162    /// The whole point of extraction: resolution must reflect *distinct*
163    /// area models, not just echo the raw chat model into every field.
164    #[test]
165    fn resolve_default_run_config_resolves_distinct_area_models() {
166        let config = config_with_area_defaults();
167        let registry = test_registry();
168
169        let resolved = resolve_default_run_config(&config, &registry);
170
171        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
172        assert_eq!(
173            resolved.model_roster.fast_model().as_deref(),
174            Some("gpt-fast")
175        );
176        assert_eq!(
177            resolved.model_roster.background_model().as_deref(),
178            Some("gpt-memory")
179        );
180        assert_eq!(
181            resolved.model_roster.summarization_model().as_deref(),
182            Some("gpt-summary")
183        );
184    }
185
186    #[test]
187    fn resolve_default_run_config_never_leaks_api_keys() {
188        let config = config_with_area_defaults();
189        let registry = test_registry();
190
191        let resolved = resolve_default_run_config(&config, &registry);
192
193        assert!(!resolved.system_prompt.contains("test-secret-key"));
194        assert!(!resolved.base_system_prompt.contains("test-secret-key"));
195        let gold_json =
196            serde_json::to_string(&resolved.gold_config).unwrap_or_else(|_| "null".to_string());
197        assert!(!gold_json.contains("test-secret-key"));
198    }
199}