bamboo_engine/runtime/model_roster.rs
1//! Cohesive model/provider value object shared by the request/spawn param structs.
2//!
3//! Historically the per-request model selection was a "data clump" of ~8 loose
4//! fields (`model`, `provider_name`, `provider_type`, plus three auxiliary
5//! role pairs `<role>_model` + `<role>_model_provider`) repeated verbatim across
6//! [`ExecuteRequest`](crate::ExecuteRequest),
7//! [`ExecuteRequestBuilder`](crate::ExecuteRequestBuilder),
8//! `SessionExecutionArgs`, and the server's `ResolvedRunConfig` /
9//! `SpawnAgentExecution`. [`ModelRoster`] collapses that clump into one value
10//! object so the param structs carry a single cohesive field.
11//!
12//! ## Lives in `bamboo-engine`
13//!
14//! [`RoleModel::provider_override`] is an `Arc<dyn LLMProvider>`, and
15//! `LLMProvider` lives in `bamboo-infrastructure`. `bamboo-domain` does **not**
16//! depend on `bamboo-infrastructure`, so hosting the roster there would force a
17//! new runtime dependency just to carry a live provider handle. `bamboo-engine`
18//! already depends on infrastructure (and is depended on by the server), making
19//! it the natural home for a value object that carries live provider handles.
20//!
21//! ## Resolution semantics are preserved byte-for-byte
22//!
23//! The roster carries *intent* only; it performs no `Config` fallback itself.
24//! A `None` auxiliary role maps exactly to the old "both name and provider were
25//! `None`" state, so the downstream `None → Config::get_*` fallbacks in
26//! [`AgentRuntime::execute`](crate::AgentRuntime) are unchanged. The primary
27//! `model`/`provider_name`/`provider_type` keep the same request→config cascade
28//! at their resolution sites.
29
30use std::sync::Arc;
31
32use bamboo_llm::LLMProvider;
33
34/// A single auxiliary role's model selection: an optional model name plus an
35/// optional dedicated provider handle.
36///
37/// Both fields are independent and optional so the mapping from the old loose
38/// `<role>_model: Option<String>` + `<role>_model_provider: Option<Arc<…>>`
39/// pair is lossless. A role is represented as `Some(RoleModel)` only when at
40/// least one of the two was set; an all-`None` role collapses to `None` on the
41/// owning [`ModelRoster`], preserving the old "fall back to `Config`" behavior.
42#[derive(Clone, Default)]
43pub struct RoleModel {
44 /// Model name for this role. `None` defers to the relevant `Config::get_*`
45 /// fallback at the resolution site.
46 pub name: Option<String>,
47 /// Optional dedicated provider handle for this role's LLM calls. `None`
48 /// uses the shared agent-loop provider.
49 pub provider_override: Option<Arc<dyn LLMProvider>>,
50}
51
52impl RoleModel {
53 /// Build a role from the old loose pair, collapsing an all-`None` pair to
54 /// `None` so it maps exactly onto the previous fallback behavior.
55 pub fn from_parts(
56 name: Option<String>,
57 provider_override: Option<Arc<dyn LLMProvider>>,
58 ) -> Option<Self> {
59 if name.is_none() && provider_override.is_none() {
60 None
61 } else {
62 Some(Self {
63 name,
64 provider_override,
65 })
66 }
67 }
68}
69
70/// Cohesive value object for a request's primary + auxiliary model selection.
71///
72/// This replaces the loose model/provider field clump on the per-request param
73/// structs. Capability-driven models (`planning_model_name`,
74/// `search_model_name`) are intentionally **not** part of the roster — they are
75/// resolved separately from `Config` and are not role-driven.
76#[derive(Clone, Default)]
77pub struct ModelRoster {
78 /// Primary (main chat) model name. `None` defers to the config default at
79 /// the resolution site (see [`AgentRuntime::execute`](crate::AgentRuntime)).
80 pub model: Option<String>,
81 /// Provider routing key for the primary model. `None` cascades to
82 /// `Config::provider` at the resolution site.
83 pub provider_name: Option<String>,
84 /// Underlying provider type for the primary model (e.g. `openai`,
85 /// `anthropic`, `copilot`). Distinct from `provider_name` so
86 /// provider-specific behavior stays correct when the routing key is an
87 /// instance id.
88 pub provider_type: Option<String>,
89 /// Fast/cheap role. `None` falls back to `Config::get_fast_model()`.
90 pub fast: Option<RoleModel>,
91 /// Memory/background role. `None` falls back to
92 /// `Config::get_memory_background_model()`.
93 pub background: Option<RoleModel>,
94 /// Summarization/compression role. `None` falls back to
95 /// `Config::get_task_summary_model()`.
96 pub summarization: Option<RoleModel>,
97}
98
99impl ModelRoster {
100 /// A roster with no overrides — every selection defers to `Config`.
101 pub fn empty() -> Self {
102 Self::default()
103 }
104
105 /// Build a roster from a resolved [`GlobalAreaModels`] plus the primary
106 /// model selection. Centralizes the fast/background/summarization mapping
107 /// (`RoleModel::from_parts(area.model_name, area.provider)`) that the
108 /// execute handler and schedule manager previously hand-rolled identically.
109 pub fn from_areas(
110 model: Option<String>,
111 provider_name: Option<String>,
112 provider_type: Option<String>,
113 areas: crate::model_areas::GlobalAreaModels,
114 ) -> Self {
115 Self {
116 model,
117 provider_name,
118 provider_type,
119 fast: RoleModel::from_parts(
120 areas.fast.as_ref().map(|m| m.model_name.clone()),
121 areas.fast.map(|m| m.provider),
122 ),
123 background: RoleModel::from_parts(
124 areas.background.as_ref().map(|m| m.model_name.clone()),
125 areas.background.map(|m| m.provider),
126 ),
127 summarization: RoleModel::from_parts(
128 areas.summarization.as_ref().map(|m| m.model_name.clone()),
129 areas.summarization.map(|m| m.provider),
130 ),
131 }
132 }
133
134 /// Fast-model name override, if any.
135 pub fn fast_model(&self) -> Option<String> {
136 self.fast.as_ref().and_then(|r| r.name.clone())
137 }
138
139 /// Fast-model dedicated provider handle, if any.
140 pub fn fast_model_provider(&self) -> Option<Arc<dyn LLMProvider>> {
141 self.fast.as_ref().and_then(|r| r.provider_override.clone())
142 }
143
144 /// Background-model name override, if any.
145 pub fn background_model(&self) -> Option<String> {
146 self.background.as_ref().and_then(|r| r.name.clone())
147 }
148
149 /// Background-model dedicated provider handle, if any.
150 pub fn background_model_provider(&self) -> Option<Arc<dyn LLMProvider>> {
151 self.background
152 .as_ref()
153 .and_then(|r| r.provider_override.clone())
154 }
155
156 /// Summarization-model name override, if any.
157 pub fn summarization_model(&self) -> Option<String> {
158 self.summarization.as_ref().and_then(|r| r.name.clone())
159 }
160
161 /// Summarization-model dedicated provider handle, if any.
162 pub fn summarization_model_provider(&self) -> Option<Arc<dyn LLMProvider>> {
163 self.summarization
164 .as_ref()
165 .and_then(|r| r.provider_override.clone())
166 }
167
168 /// Set the fast role from the old loose pair.
169 pub fn set_fast(&mut self, name: Option<String>, provider: Option<Arc<dyn LLMProvider>>) {
170 self.fast = RoleModel::from_parts(name, provider);
171 }
172
173 /// Set the background role from the old loose pair.
174 pub fn set_background(&mut self, name: Option<String>, provider: Option<Arc<dyn LLMProvider>>) {
175 self.background = RoleModel::from_parts(name, provider);
176 }
177
178 /// Set the summarization role from the old loose pair.
179 pub fn set_summarization(
180 &mut self,
181 name: Option<String>,
182 provider: Option<Arc<dyn LLMProvider>>,
183 ) {
184 self.summarization = RoleModel::from_parts(name, provider);
185 }
186}