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