Skip to main content

bamboo_engine/session_app/execute/
mod.rs

1//! Execute use case: prepare a session for agent execution.
2
3use 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
35/// Prepare an execute: load session, resolve model/reasoning, validate,
36/// update metadata, return outcome.
37///
38/// The caller (handler) is responsible for runner reservation and agent spawning
39/// based on the returned outcome.
40pub async fn prepare_execute(
41    repo: &dyn SessionAccess,
42    config: ExecutionConfigSnapshot,
43    input: ExecuteInput,
44) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
45    // ---- Load session ----
46    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    // ---- Client sync check ----
55    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    // ---- Resolve model cascade ----
84    // Flag ON (new): session.model_ref → request.model_ref → config.default_model_ref
85    // Flag OFF (old): session.model → config.default_model → request.model
86    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    // ---- Resolve reasoning effort cascade: session → request → provider default ----
99    // Single shared cascade (see `crate::model_areas`). Stays `Option` so
100    // non-reasoning models send no reasoning parameter.
101    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    // ---- Image fallback validation ----
111    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    // ---- Check for pending user message ----
118    if !server_snapshot.has_pending_user_message {
119        return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
120    }
121
122    // ---- Update session metadata ----
123    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    // ---- Skill mode ----
156    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 markers ----
168    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
180/// Old-path model resolution: session.model → config.default_model → request.model
181pub(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
205/// New-path model resolution: session.model_ref → request.model_ref → config.default_model_ref.
206pub(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
242// ---- Internal helpers ----
243
244fn 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}