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_domain::ProviderModelRef;
24use bamboo_llm::{Config, LLMProvider, ProviderRegistry};
25
26use crate::config::GoldConfig;
27use crate::ModelRoster;
28
29/// Resolved model/prompt/workspace configuration for a run that carries no
30/// per-request overrides, derived purely from the live global config.
31#[derive(Clone)]
32pub struct ResolvedDefaultRunConfig {
33    /// Primary + auxiliary model/provider selection. The primary model is
34    /// resolved via `model_roster.model` (may be `None`/empty if the server
35    /// has no model configured at all).
36    pub model_roster: ModelRoster,
37    /// The admitted client for the selected primary model. Host consumers must
38    /// dispatch this pair together rather than use a mutable default client.
39    pub model_provider: Option<Arc<dyn LLMProvider>>,
40    pub model_origin: Option<Arc<bamboo_llm::provider_registry::ManagedProviderOrigin>>,
41    pub model_error: Option<String>,
42    pub reasoning_effort: Option<ReasoningEffort>,
43    pub gold_config: Option<GoldConfig>,
44    /// Assembled system prompt (base template + workspace note).
45    pub system_prompt: String,
46    pub base_system_prompt: String,
47    pub workspace_path: Option<String>,
48}
49
50/// Resolve the server's current effective run configuration purely from live
51/// `Config` — no per-request overrides. See the module docs for why this is
52/// the sole implementation of this cascade.
53pub fn resolve_default_run_config(
54    config_snapshot: &Config,
55    provider_registry: &Arc<ProviderRegistry>,
56) -> ResolvedDefaultRunConfig {
57    let mut target = default_chat_model_ref(config_snapshot);
58    let (model_provider, model_origin, model_error) = match target.as_ref() {
59        Some(requested) => match crate::model_config_helper::try_resolve_primary_model(
60            config_snapshot,
61            provider_registry,
62            requested,
63        ) {
64            Ok(selected) => {
65                target = Some(selected.target);
66                (Some(selected.provider), selected.managed_origin, None)
67            }
68            Err(error) => (None, None, Some(error.to_string())),
69        },
70        None => (None, None, None),
71    };
72    let model = target.as_ref().map(|target| target.model.clone());
73    let provider_name = Some(
74        target
75            .as_ref()
76            .map(|target| target.provider.clone())
77            .unwrap_or_else(|| config_snapshot.effective_default_provider().to_string()),
78    );
79    let provider_type = provider_name.as_deref().and_then(|name| {
80        crate::model_config_helper::resolve_provider_type(config_snapshot, name, provider_registry)
81    });
82    let capability_provider_name = provider_name
83        .as_deref()
84        .unwrap_or(config_snapshot.effective_default_provider());
85    // Auxiliary models are global (config-derived), never session-bound —
86    // see `crate::model_areas`'s module docs.
87    let areas = crate::model_areas::resolve_global_area_models(
88        config_snapshot,
89        capability_provider_name,
90        provider_registry,
91    );
92    let reasoning_effort = match target.as_ref() {
93        Some(target) => target
94            .reasoning_effort
95            .or_else(|| config_snapshot.reasoning_effort_for_key(&target.provider)),
96        None => config_snapshot.get_reasoning_effort(),
97    };
98    let base_system_prompt = crate::prompt_defaults::read_global_default_system_prompt_template();
99    let workspace_path = config_snapshot
100        .get_default_work_area_path()
101        .map(|path| bamboo_config::paths::path_to_display_string(&path));
102    let system_prompt = crate::context::assemble_system_prompt(
103        &base_system_prompt,
104        None,
105        workspace_path.as_deref(),
106    );
107    let model_roster = ModelRoster::from_areas(model, provider_name, provider_type, areas);
108
109    ResolvedDefaultRunConfig {
110        model_roster,
111        model_provider,
112        model_origin,
113        model_error,
114        reasoning_effort,
115        gold_config: crate::model_config_helper::resolve_gold_config(config_snapshot, None),
116        system_prompt,
117        base_system_prompt,
118        workspace_path,
119    }
120}
121
122/// Keep configured intent separate from admission so a bad explicit target is
123/// reported, never replaced by an unrelated default account.
124pub fn default_chat_model_ref(config: &Config) -> Option<ProviderModelRef> {
125    if config.features.provider_model_ref {
126        if let Some(defaults) = config.defaults.as_ref() {
127            return Some(defaults.chat.clone());
128        }
129    }
130    let provider = config.effective_default_provider();
131    let model = if let Some(instance) = config.provider_instances.get(provider) {
132        // A disabled configured target is still intent; admission reports it.
133        instance
134            .model
135            .clone()
136            .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()))
137    } else {
138        config.get_model()
139    }?;
140    (!model.trim().is_empty()).then(|| ProviderModelRef::new(provider, model.trim()))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    macro_rules! test_config {
148        (@assign $config:ident, providers, $value:expr) => { *$config.providers_mut() = $value; };
149        (@assign $config:ident, memory, $value:expr) => { *$config.memory_mut() = $value; };
150        (@assign $config:ident, subagents, $value:expr) => { *$config.subagents_mut() = $value; };
151        (@assign $config:ident, $field:ident, $value:expr) => { $config.$field = $value; };
152        ($($field:ident: $value:expr),* $(,)?) => {{
153            let mut config = Config::default();
154            $(test_config!(@assign config, $field, $value);)*
155            config
156        }};
157    }
158    use bamboo_agent_core::tools::ToolSchema;
159    use bamboo_agent_core::Message;
160    use bamboo_config::{DefaultsConfig, FeatureFlags, OpenAIConfig, ProviderConfigs};
161    use bamboo_domain::ProviderModelRef;
162    use bamboo_llm::{LLMError, LLMProvider, LLMStream};
163    use std::collections::HashMap;
164
165    struct NoopProvider;
166
167    #[async_trait::async_trait]
168    impl LLMProvider for NoopProvider {
169        async fn chat_stream(
170            &self,
171            _messages: &[Message],
172            _tools: &[ToolSchema],
173            _max_output_tokens: Option<u32>,
174            _model: &str,
175        ) -> Result<LLMStream, LLMError> {
176            Err(LLMError::Api("noop".to_string()))
177        }
178    }
179
180    fn test_registry() -> Arc<ProviderRegistry> {
181        let mut providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::new();
182        providers.insert("openai".to_string(), Arc::new(NoopProvider));
183        Arc::new(ProviderRegistry::new(providers, "openai".to_string()))
184    }
185
186    fn config_with_area_defaults() -> Config {
187        let defaults = DefaultsConfig {
188            chat: ProviderModelRef::new("openai", "gpt-chat"),
189            fast: Some(ProviderModelRef::new("openai", "gpt-fast")),
190            task_summary: Some(ProviderModelRef::new("openai", "gpt-summary")),
191            vision: None,
192            memory_background: Some(ProviderModelRef::new("openai", "gpt-memory")),
193            planning: None,
194            search: None,
195            code_review: None,
196            sub_agent: None,
197            subagent_models: HashMap::new(),
198        };
199        test_config! {
200            provider: "openai".to_string(),
201            features: FeatureFlags {
202                provider_model_ref: true,
203                ..Default::default()
204            },
205            providers: ProviderConfigs {
206                openai: Some(OpenAIConfig {
207                    api_key: "test-secret-key".to_string(),
208                    model: Some("gpt-chat".to_string()),
209                    ..Default::default()
210                }),
211                ..Default::default()
212            },
213            defaults: Some(defaults),
214        }
215    }
216
217    /// The whole point of extraction: resolution must reflect *distinct*
218    /// area models, not just echo the raw chat model into every field.
219    #[test]
220    fn resolve_default_run_config_resolves_distinct_area_models() {
221        let config = config_with_area_defaults();
222        let registry = test_registry();
223
224        let resolved = resolve_default_run_config(&config, &registry);
225
226        assert_eq!(resolved.model_roster.model.as_deref(), Some("gpt-chat"));
227        assert_eq!(
228            resolved.model_roster.fast_model().as_deref(),
229            Some("gpt-fast")
230        );
231        assert_eq!(
232            resolved.model_roster.background_model().as_deref(),
233            Some("gpt-memory")
234        );
235        assert_eq!(
236            resolved.model_roster.summarization_model().as_deref(),
237            Some("gpt-summary")
238        );
239    }
240
241    #[test]
242    fn alias_routing_uses_selected_instance_reasoning_without_legacy_fallback() {
243        let mut config = config_with_area_defaults();
244        config
245            .providers_mut()
246            .openai
247            .as_mut()
248            .unwrap()
249            .reasoning_effort = Some(ReasoningEffort::High);
250        config.default_provider_instance = Some("work-openai".into());
251        config.provider_instances.insert(
252            "work-openai".into(),
253            serde_json::from_value(serde_json::json!({
254                "provider_type": "openai", "enabled": true, "model": "gpt-chat",
255                "runtime_models": ["gpt-chat"]
256            }))
257            .unwrap(),
258        );
259        let providers: HashMap<String, Arc<dyn LLMProvider>> = HashMap::from([(
260            "work-openai".into(),
261            Arc::new(NoopProvider) as Arc<dyn LLMProvider>,
262        )]);
263        let registry = Arc::new(ProviderRegistry::new(providers, "work-openai".into()));
264
265        let selected = resolve_default_run_config(&config, &registry);
266        assert!(selected.model_error.is_none(), "{:?}", selected.model_error);
267        assert_eq!(
268            selected.model_roster.provider_name.as_deref(),
269            Some("work-openai")
270        );
271        assert_eq!(selected.reasoning_effort, None);
272
273        config
274            .provider_instances
275            .get_mut("work-openai")
276            .unwrap()
277            .reasoning_effort = Some(ReasoningEffort::Low);
278        let selected = resolve_default_run_config(&config, &registry);
279        assert_eq!(selected.reasoning_effort, Some(ReasoningEffort::Low));
280        config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Medium);
281        let selected = resolve_default_run_config(&config, &registry);
282        assert_eq!(selected.reasoning_effort, Some(ReasoningEffort::Medium));
283    }
284
285    #[test]
286    fn missing_chat_target_retains_legacy_reasoning_default() {
287        let mut config = Config::default();
288        config.provider = "openai".into();
289        config.providers_mut().openai = Some(OpenAIConfig {
290            reasoning_effort: Some(ReasoningEffort::High),
291            ..Default::default()
292        });
293        let resolved = resolve_default_run_config(&config, &test_registry());
294        assert!(resolved.model_roster.model.is_none());
295        assert_eq!(resolved.reasoning_effort, Some(ReasoningEffort::High));
296    }
297
298    #[test]
299    fn resolve_default_run_config_never_leaks_api_keys() {
300        let config = config_with_area_defaults();
301        let registry = test_registry();
302
303        let resolved = resolve_default_run_config(&config, &registry);
304
305        assert!(!resolved.system_prompt.contains("test-secret-key"));
306        assert!(!resolved.base_system_prompt.contains("test-secret-key"));
307        let gold_json =
308            serde_json::to_string(&resolved.gold_config).unwrap_or_else(|_| "null".to_string());
309        assert!(!gold_json.contains("test-secret-key"));
310    }
311}