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
34use 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 config.default_reasoning_effort,
107 );
108 (effort, source.as_str())
109 };
110
111 if let Err(error) =
113 validate_image_fallback_for_session(&session, config.image_fallback.as_ref())
114 {
115 return Ok(ExecutePreparationOutcome::ImageFallbackError(error));
116 }
117
118 if !server_snapshot.has_pending_user_message {
120 return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
121 }
122
123 if let Some(model_ref) = effective_model_ref.as_ref() {
125 persist_model_ref(&mut session, model_ref);
126 } else {
127 persist_legacy_model_provider(
128 &mut session,
129 Some(effective_model.as_str()),
130 Some(config.provider_name.as_str()),
131 );
132 }
133 session.reasoning_effort = effective_reasoning_effort;
134
135 session
136 .metadata
137 .insert("model_source".to_string(), model_source.to_string());
138
139 if effective_reasoning_effort.is_some() {
140 session.metadata.insert(
141 "reasoning_effort_source".to_string(),
142 reasoning_effort_source.to_string(),
143 );
144 session.metadata.insert(
145 "reasoning_effort_compat".to_string(),
146 effective_reasoning_effort
147 .map(ReasoningEffort::as_str)
148 .unwrap_or_default()
149 .to_string(),
150 );
151 } else {
152 session.metadata.remove("reasoning_effort_source");
153 session.metadata.remove("reasoning_effort_compat");
154 }
155
156 if let Some(skill_mode) = input.request_skill_mode {
158 let trimmed = skill_mode.trim();
159 if trimmed.is_empty() {
160 session.clear_skill_mode();
161 } else {
162 session.set_skill_mode(trimmed);
163 }
164 }
165
166 consume_pending_clarification_resume(&mut session);
168
169 Ok(ExecutePreparationOutcome::Ready {
170 session: Box::new(session),
171 effective_model,
172 effective_reasoning_effort,
173 model_source,
174 reasoning_source: reasoning_effort_source,
175 is_child_session,
176 })
177}
178
179pub(crate) fn resolve_model_cascade(
181 session: &Session,
182 input: &ExecuteInput,
183 config: &ExecutionConfigSnapshot,
184) -> (Option<String>, &'static str) {
185 let session_model = normalize_model(Some(session.model.as_str()));
186 let request_model = normalize_model(input.request_model.as_deref());
187 let request_model_used = request_model.is_some();
188 let model_source = if session_model.is_some() {
189 "session"
190 } else if config.default_model.is_some() {
191 "provider_default"
192 } else if request_model_used {
193 "request"
194 } else {
195 "none"
196 };
197 let effective_model = session_model
198 .or_else(|| config.default_model.clone())
199 .or(request_model);
200
201 (effective_model, model_source)
202}
203
204pub(crate) fn resolve_model_ref_cascade(
206 session: &Session,
207 input: &ExecuteInput,
208 config: &ExecutionConfigSnapshot,
209) -> (
210 Option<bamboo_domain::ProviderModelRef>,
211 Option<String>,
212 &'static str,
213) {
214 let session_model_ref = session_effective_model_ref(session);
215 let request_model_ref = super::provider_model::derive_model_ref(
216 input.request_model_ref.as_ref(),
217 input.request_provider.as_deref(),
218 input.request_model.as_deref(),
219 );
220 let config_model_ref = config.default_model_ref.clone();
221
222 let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
223 (Some(model_ref), "session")
224 } else if let Some(model_ref) = request_model_ref {
225 (Some(model_ref), "request")
226 } else if let Some(model_ref) = config_model_ref {
227 (Some(model_ref), "provider_default")
228 } else {
229 (None, "none")
230 };
231
232 if let Some(model_ref) = effective_model_ref {
233 let effective_model = normalize_model(Some(model_ref.model.as_str()));
234 (Some(model_ref), effective_model, model_source)
235 } else {
236 let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
237 (None, effective_model, legacy_source)
238 }
239}
240
241fn normalize_model(model: Option<&str>) -> Option<String> {
244 model
245 .map(str::trim)
246 .filter(|m| !m.is_empty() && *m != "unknown")
247 .map(String::from)
248}