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 consume_pending_clarification_resume, consume_pending_conclusion_with_options_resume,
26 has_pending_clarification_resume, has_pending_conclusion_with_options_resume,
27 has_pending_retry_resume, has_pending_user_message,
28};
29pub use sync::evaluate_client_sync;
30
31pub use sync::is_hidden_from_ui;
32
33use validation::validate_image_fallback_for_session;
34
35pub async fn prepare_execute(
41 repo: &dyn SessionAccess,
42 config: ExecutionConfigSnapshot,
43 input: ExecuteInput,
44) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
45 let mut session = repo
47 .load_session(&input.session_id)
48 .await?
49 .ok_or_else(|| ExecutePreparationError::NotFound(input.session_id.clone()))?;
50
51 let is_child_session = session.kind == bamboo_agent_core::SessionKind::Child;
52 let server_snapshot = ServerExecuteSnapshot::from_session(&session);
53
54 if let Some(reason) = evaluate_client_sync(input.client_sync.as_ref(), &server_snapshot) {
56 match input.client_sync.as_ref() {
57 Some(cs) => tracing::debug!(
58 "[{}] 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={}",
59 input.session_id,
60 reason,
61 cs.client_message_count,
62 cs.client_last_message_id,
63 cs.client_has_pending_question,
64 cs.client_pending_question_tool_call_id,
65 server_snapshot.message_count,
66 server_snapshot.last_message_id,
67 server_snapshot.has_pending_question,
68 server_snapshot.pending_question_tool_call_id,
69 session.messages.len(),
70 ),
71 None => tracing::debug!(
72 "[{}] Execute sync MISMATCH reason={:?} but no client_sync was sent",
73 input.session_id,
74 reason
75 ),
76 }
77 return Ok(ExecutePreparationOutcome::SyncMismatch {
78 reason,
79 server_snapshot,
80 });
81 }
82
83 let (effective_model_ref, effective_model, model_source) = if config.provider_model_ref_enabled
87 {
88 resolve_model_ref_cascade(&session, &input, &config)
89 } else {
90 let (effective_model, model_source) = resolve_model_cascade(&session, &input, &config);
91 (None, effective_model, model_source)
92 };
93
94 let Some(effective_model) = effective_model else {
95 return Ok(ExecutePreparationOutcome::ModelRequired);
96 };
97
98 let (effective_reasoning_effort, reasoning_effort_source) = {
102 let (effort, source) = crate::model_areas::resolve_effective_reasoning_effort(
103 session.reasoning_effort,
104 input.request_reasoning_effort,
105 config.default_reasoning_effort,
106 );
107 (effort, source.as_str())
108 };
109
110 if let Err(error) =
112 validate_image_fallback_for_session(&session, config.image_fallback.as_ref())
113 {
114 return Ok(ExecutePreparationOutcome::ImageFallbackError(error));
115 }
116
117 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.metadata.remove("skill_mode");
160 } else {
161 session
162 .metadata
163 .insert("skill_mode".to_string(), trimmed.to_string());
164 }
165 }
166
167 consume_pending_clarification_resume(&mut session);
169
170 Ok(ExecutePreparationOutcome::Ready {
171 session: Box::new(session),
172 effective_model,
173 effective_reasoning_effort,
174 model_source,
175 reasoning_source: reasoning_effort_source,
176 is_child_session,
177 })
178}
179
180pub(crate) fn resolve_model_cascade(
182 session: &Session,
183 input: &ExecuteInput,
184 config: &ExecutionConfigSnapshot,
185) -> (Option<String>, &'static str) {
186 let session_model = normalize_model(Some(session.model.as_str()));
187 let request_model = normalize_model(input.request_model.as_deref());
188 let request_model_used = request_model.is_some();
189 let model_source = if session_model.is_some() {
190 "session"
191 } else if config.default_model.is_some() {
192 "provider_default"
193 } else if request_model_used {
194 "request"
195 } else {
196 "none"
197 };
198 let effective_model = session_model
199 .or_else(|| config.default_model.clone())
200 .or(request_model);
201
202 (effective_model, model_source)
203}
204
205pub(crate) fn resolve_model_ref_cascade(
207 session: &Session,
208 input: &ExecuteInput,
209 config: &ExecutionConfigSnapshot,
210) -> (
211 Option<bamboo_domain::ProviderModelRef>,
212 Option<String>,
213 &'static str,
214) {
215 let session_model_ref = session_effective_model_ref(session);
216 let request_model_ref = super::provider_model::derive_model_ref(
217 input.request_model_ref.as_ref(),
218 input.request_provider.as_deref(),
219 input.request_model.as_deref(),
220 );
221 let config_model_ref = config.default_model_ref.clone();
222
223 let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
224 (Some(model_ref), "session")
225 } else if let Some(model_ref) = request_model_ref {
226 (Some(model_ref), "request")
227 } else if let Some(model_ref) = config_model_ref {
228 (Some(model_ref), "provider_default")
229 } else {
230 (None, "none")
231 };
232
233 if let Some(model_ref) = effective_model_ref {
234 let effective_model = normalize_model(Some(model_ref.model.as_str()));
235 (Some(model_ref), effective_model, model_source)
236 } else {
237 let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
238 (None, effective_model, legacy_source)
239 }
240}
241
242fn normalize_model(model: Option<&str>) -> Option<String> {
245 model
246 .map(str::trim)
247 .filter(|m| !m.is_empty() && *m != "unknown")
248 .map(String::from)
249}