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