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, session_effective_model_ref,
9};
10use super::repository::SessionAccess;
11use super::types::{
12 ExecuteInput, ExecutePreparationOutcome, ExecutionConfigSnapshot, ServerExecuteSnapshot,
13};
14
15mod billing;
16mod resume_markers;
17mod sync;
18mod validation;
19
20#[cfg(test)]
21mod tests;
22
23pub use billing::{billable_user_turn_count, is_billable_user_turn, is_system_resume_message};
24pub use resume_markers::{
25 clear_startup_handoff, consume_pending_clarification_resume,
26 consume_pending_conclusion_with_options_resume, has_pending_clarification_resume,
27 has_pending_conclusion_with_options_resume, has_pending_retry_resume, has_pending_user_message,
28 mark_startup_handoff, startup_handoff_at,
29};
30pub use sync::evaluate_client_sync;
31
32pub use sync::is_hidden_from_ui;
33
34pub use validation::validate_image_fallback_for_session;
35
36pub async fn prepare_execute(
42 repo: &dyn SessionAccess,
43 config: ExecutionConfigSnapshot,
44 input: ExecuteInput,
45) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
46 let mut session = repo
48 .load_session(&input.session_id)
49 .await?
50 .ok_or_else(|| ExecutePreparationError::NotFound(input.session_id.clone()))?;
51
52 let is_child_session = session.kind == bamboo_agent_core::SessionKind::Child;
53 let server_snapshot = ServerExecuteSnapshot::from_session(&session);
54
55 if let Some(reason) = evaluate_client_sync(input.client_sync.as_ref(), &server_snapshot) {
57 match input.client_sync.as_ref() {
58 Some(cs) => tracing::debug!(
59 "[{}] 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={}",
60 input.session_id,
61 reason,
62 cs.client_message_count,
63 cs.client_last_message_id,
64 cs.client_has_pending_question,
65 cs.client_pending_question_tool_call_id,
66 server_snapshot.message_count,
67 server_snapshot.last_message_id,
68 server_snapshot.has_pending_question,
69 server_snapshot.pending_question_tool_call_id,
70 session.messages.len(),
71 ),
72 None => tracing::debug!(
73 "[{}] Execute sync MISMATCH reason={:?} but no client_sync was sent",
74 input.session_id,
75 reason
76 ),
77 }
78 return Ok(ExecutePreparationOutcome::SyncMismatch {
79 reason,
80 server_snapshot,
81 });
82 }
83
84 let (effective_model_ref, effective_model, model_source) = if config.provider_model_ref_enabled
88 {
89 resolve_model_ref_cascade(&session, &input, &config)
90 } else {
91 let (effective_model, model_source) = resolve_model_cascade(&session, &input, &config);
92 (None, effective_model, model_source)
93 };
94
95 let Some(effective_model) = effective_model else {
96 return Ok(ExecutePreparationOutcome::ModelRequired);
97 };
98
99 let (effective_reasoning_effort, reasoning_effort_source) = {
103 let (effort, source) = crate::model_areas::resolve_effective_reasoning_effort(
104 session.reasoning_effort,
105 input.request_reasoning_effort,
106 effective_model_ref
107 .as_ref()
108 .and_then(|model_ref| model_ref.reasoning_effort),
109 config.default_reasoning_effort,
110 );
111 (effort, source.as_str())
112 };
113
114 if !server_snapshot.has_pending_user_message {
119 return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
120 }
121
122 if let Some(model_ref) = effective_model_ref.as_ref() {
124 persist_model_ref(&mut session, model_ref);
125 } else {
126 persist_legacy_model_provider(
127 &mut session,
128 Some(effective_model.as_str()),
129 Some(config.provider_name.as_str()),
130 );
131 }
132 session.reasoning_effort = effective_reasoning_effort;
133
134 session
135 .metadata
136 .insert("model_source".to_string(), model_source.to_string());
137
138 if effective_reasoning_effort.is_some() {
139 session.metadata.insert(
140 "reasoning_effort_source".to_string(),
141 reasoning_effort_source.to_string(),
142 );
143 session.metadata.insert(
144 "reasoning_effort_compat".to_string(),
145 effective_reasoning_effort
146 .map(ReasoningEffort::as_str)
147 .unwrap_or_default()
148 .to_string(),
149 );
150 } else {
151 session.metadata.remove("reasoning_effort_source");
152 session.metadata.remove("reasoning_effort_compat");
153 }
154
155 if let Some(skill_mode) = input.request_skill_mode {
157 let trimmed = skill_mode.trim();
158 if trimmed.is_empty() {
159 session.clear_skill_mode();
160 } else {
161 session.set_skill_mode(trimmed);
162 }
163 }
164
165 consume_pending_clarification_resume(&mut session);
167
168 Ok(ExecutePreparationOutcome::Ready {
169 session: Box::new(session),
170 effective_model,
171 effective_reasoning_effort,
172 model_source,
173 reasoning_source: reasoning_effort_source,
174 is_child_session,
175 })
176}
177
178pub(crate) fn resolve_model_cascade(
180 session: &Session,
181 input: &ExecuteInput,
182 config: &ExecutionConfigSnapshot,
183) -> (Option<String>, &'static str) {
184 let session_model = normalize_model(Some(session.model.as_str()));
185 let request_model = normalize_model(input.request_model.as_deref());
186 let request_model_used = request_model.is_some();
187 let model_source = if session_model.is_some() {
188 "session"
189 } else if config.default_model.is_some() {
190 "provider_default"
191 } else if request_model_used {
192 "request"
193 } else {
194 "none"
195 };
196 let effective_model = session_model
197 .or_else(|| config.default_model.clone())
198 .or(request_model);
199
200 (effective_model, model_source)
201}
202
203pub(crate) fn resolve_model_ref_cascade(
205 session: &Session,
206 input: &ExecuteInput,
207 config: &ExecutionConfigSnapshot,
208) -> (
209 Option<bamboo_domain::ProviderModelRef>,
210 Option<String>,
211 &'static str,
212) {
213 let session_model_ref = session_effective_model_ref(session);
214 let request_model_ref = super::provider_model::derive_model_ref(
215 input.request_model_ref.as_ref(),
216 input.request_provider.as_deref(),
217 input.request_model.as_deref(),
218 );
219 let config_model_ref = config.default_model_ref.clone();
220
221 let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
222 (Some(model_ref), "session")
223 } else if let Some(model_ref) = request_model_ref {
224 (Some(model_ref), "request")
225 } else if let Some(model_ref) = config_model_ref {
226 (Some(model_ref), "provider_default")
227 } else {
228 (None, "none")
229 };
230
231 if let Some(model_ref) = effective_model_ref {
232 let effective_model = normalize_model(Some(model_ref.model.as_str()));
233 (Some(model_ref), effective_model, model_source)
234 } else {
235 let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
236 (None, effective_model, legacy_source)
237 }
238}
239
240fn normalize_model(model: Option<&str>) -> Option<String> {
243 model
244 .map(str::trim)
245 .filter(|m| !m.is_empty() && *m != "unknown")
246 .map(String::from)
247}