bamboo_engine/session_app/execute/
mod.rs1use bamboo_domain::reasoning::ReasoningEffort;
4use bamboo_domain::Session;
5
6use super::errors::ExecutePreparationError;
7use super::provider_model::{
8 persist_legacy_model_provider, persist_model_ref, schedule_bound_model_ref,
9 session_effective_model_ref,
10};
11use super::repository::SessionAccess;
12use super::types::{
13 ExecuteInput, ExecutePreparationOutcome, ExecutionConfigSnapshot, ServerExecuteSnapshot,
14};
15
16mod billing;
17mod resume_markers;
18mod sync;
19mod validation;
20
21#[cfg(test)]
22mod tests;
23
24pub use billing::{billable_user_turn_count, is_billable_user_turn, is_system_resume_message};
25pub use resume_markers::{
26 clear_startup_handoff, consume_pending_clarification_resume,
27 consume_pending_conclusion_with_options_resume, has_pending_clarification_resume,
28 has_pending_conclusion_with_options_resume, has_pending_retry_resume, has_pending_user_message,
29 mark_startup_handoff, startup_handoff_at,
30};
31pub use sync::evaluate_client_sync;
32
33pub use sync::is_hidden_from_ui;
34
35pub use validation::validate_image_fallback_for_session;
36
37pub async fn prepare_execute(
43 repo: &dyn SessionAccess,
44 config: ExecutionConfigSnapshot,
45 input: ExecuteInput,
46) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
47 let mut session = repo
49 .load_session(&input.session_id)
50 .await?
51 .ok_or_else(|| ExecutePreparationError::NotFound(input.session_id.clone()))?;
52
53 let is_child_session = session.kind == bamboo_agent_core::SessionKind::Child;
54 let server_snapshot = ServerExecuteSnapshot::from_session(&session);
55
56 if let Some(reason) = evaluate_client_sync(input.client_sync.as_ref(), &server_snapshot) {
58 match input.client_sync.as_ref() {
59 Some(cs) => tracing::debug!(
60 "[{}] Execute sync MISMATCH reason={:?}: client(count={}, last_id={:?}, pending_q={}, pq_tool={:?}) vs server(count={}, last_id={:?}, pending_q={}, pq_tool={:?}); total_messages_in_session={}",
61 input.session_id,
62 reason,
63 cs.client_message_count,
64 cs.client_last_message_id,
65 cs.client_has_pending_question,
66 cs.client_pending_question_tool_call_id,
67 server_snapshot.message_count,
68 server_snapshot.last_message_id,
69 server_snapshot.has_pending_question,
70 server_snapshot.pending_question_tool_call_id,
71 session.messages.len(),
72 ),
73 None => tracing::debug!(
74 "[{}] Execute sync MISMATCH reason={:?} but no client_sync was sent",
75 input.session_id,
76 reason
77 ),
78 }
79 return Ok(ExecutePreparationOutcome::SyncMismatch {
80 reason,
81 server_snapshot,
82 });
83 }
84
85 let scheduled_binding = if session.metadata.contains_key("created_by_schedule_id") {
86 Some(
87 schedule_bound_model_ref(&session)
88 .map_err(ExecutePreparationError::InvalidModelReference)?,
89 )
90 } else {
91 None
92 };
93
94 let scheduled_startup_expected = scheduled_binding
97 .as_ref()
98 .map(|_| Box::new(session.clone()));
99
100 let (effective_model_ref, effective_model, model_source) =
104 resolve_execution_model(&session, &input, &config)?;
105
106 let Some(effective_model) = effective_model else {
107 return Ok(ExecutePreparationOutcome::ModelRequired);
108 };
109
110 let (effective_reasoning_effort, reasoning_effort_source) = {
114 let (effort, source) = crate::model_areas::resolve_effective_reasoning_effort(
115 session.reasoning_effort,
116 input.request_reasoning_effort,
117 effective_model_ref
118 .as_ref()
119 .and_then(|model_ref| model_ref.reasoning_effort),
120 config.default_reasoning_effort,
121 );
122 (effort, source.as_str())
123 };
124
125 if !server_snapshot.has_pending_user_message {
130 return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
131 }
132
133 if let Some(model_ref) = effective_model_ref.as_ref() {
135 persist_model_ref(&mut session, model_ref);
136 } else {
137 persist_legacy_model_provider(
138 &mut session,
139 Some(effective_model.as_str()),
140 Some(config.provider_name.as_str()),
141 );
142 }
143 session.reasoning_effort = effective_reasoning_effort;
144
145 session
146 .metadata
147 .insert("model_source".to_string(), model_source.to_string());
148
149 if effective_reasoning_effort.is_some() {
150 session.metadata.insert(
151 "reasoning_effort_source".to_string(),
152 reasoning_effort_source.to_string(),
153 );
154 session.metadata.insert(
155 "reasoning_effort_compat".to_string(),
156 effective_reasoning_effort
157 .map(ReasoningEffort::as_str)
158 .unwrap_or_default()
159 .to_string(),
160 );
161 } else {
162 session.metadata.remove("reasoning_effort_source");
163 session.metadata.remove("reasoning_effort_compat");
164 }
165
166 if let Some(skill_mode) = input.request_skill_mode {
168 let trimmed = skill_mode.trim();
169 if trimmed.is_empty() {
170 session.clear_skill_mode();
171 } else {
172 session.set_skill_mode(trimmed);
173 }
174 }
175
176 consume_pending_clarification_resume(&mut session);
178
179 Ok(ExecutePreparationOutcome::Ready {
180 session: Box::new(session),
181 effective_model,
182 effective_reasoning_effort,
183 model_source,
184 reasoning_source: reasoning_effort_source,
185 is_child_session,
186 scheduled_binding,
187 scheduled_startup_expected,
188 })
189}
190
191fn resolve_execution_model(
192 session: &Session,
193 input: &ExecuteInput,
194 config: &ExecutionConfigSnapshot,
195) -> Result<
196 (
197 Option<bamboo_domain::ProviderModelRef>,
198 Option<String>,
199 &'static str,
200 ),
201 ExecutePreparationError,
202> {
203 if let Some(target) =
204 schedule_bound_model_ref(session).map_err(ExecutePreparationError::InvalidModelReference)?
205 {
206 let model = Some(target.model.clone());
207 return Ok((Some(target), model, "session"));
208 }
209 if config.provider_model_ref_enabled {
210 Ok(resolve_model_ref_cascade(session, input, config))
211 } else {
212 let (model, source) = resolve_model_cascade(session, input, config);
213 Ok((None, model, source))
214 }
215}
216
217pub(crate) fn resolve_model_cascade(
219 session: &Session,
220 input: &ExecuteInput,
221 config: &ExecutionConfigSnapshot,
222) -> (Option<String>, &'static str) {
223 let session_model = normalize_model(Some(session.model.as_str()));
224 let request_model = normalize_model(input.request_model.as_deref());
225 let request_model_used = request_model.is_some();
226 let model_source = if session_model.is_some() {
227 "session"
228 } else if config.default_model.is_some() {
229 "provider_default"
230 } else if request_model_used {
231 "request"
232 } else {
233 "none"
234 };
235 let effective_model = session_model
236 .or_else(|| config.default_model.clone())
237 .or(request_model);
238
239 (effective_model, model_source)
240}
241
242pub(crate) fn resolve_model_ref_cascade(
244 session: &Session,
245 input: &ExecuteInput,
246 config: &ExecutionConfigSnapshot,
247) -> (
248 Option<bamboo_domain::ProviderModelRef>,
249 Option<String>,
250 &'static str,
251) {
252 let session_model_ref = session_effective_model_ref(session);
253 let request_model_ref = super::provider_model::derive_model_ref(
254 input.request_model_ref.as_ref(),
255 input.request_provider.as_deref(),
256 input.request_model.as_deref(),
257 );
258 let config_model_ref = config.default_model_ref.clone();
259
260 let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
261 (Some(model_ref), "session")
262 } else if let Some(model_ref) = request_model_ref {
263 (Some(model_ref), "request")
264 } else if let Some(model_ref) = config_model_ref {
265 (Some(model_ref), "provider_default")
266 } else {
267 (None, "none")
268 };
269
270 if let Some(model_ref) = effective_model_ref {
271 let effective_model = normalize_model(Some(model_ref.model.as_str()));
272 (Some(model_ref), effective_model, model_source)
273 } else {
274 let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
275 (None, effective_model, legacy_source)
276 }
277}
278
279fn normalize_model(model: Option<&str>) -> Option<String> {
282 model
283 .map(str::trim)
284 .filter(|m| !m.is_empty() && *m != "unknown")
285 .map(String::from)
286}