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, schedule_bound_model_ref,
9    session_effective_model_ref,
10};
11use super::repository::SessionAccess;
12use super::types::{
13    ExecuteInput, ExecutePreparationOutcome, ExecutionConfigSnapshot, ServerExecuteSnapshot,
14};
15
16mod billing;
17mod resume_markers;
18mod sync;
19mod validation;
20
21#[cfg(test)]
22mod tests;
23
24pub use billing::{billable_user_turn_count, is_billable_user_turn, is_system_resume_message};
25pub use resume_markers::{
26    clear_startup_handoff, consume_pending_clarification_resume,
27    consume_pending_conclusion_with_options_resume, has_pending_clarification_resume,
28    has_pending_conclusion_with_options_resume, has_pending_retry_resume, has_pending_user_message,
29    mark_startup_handoff, startup_handoff_at,
30};
31pub use sync::evaluate_client_sync;
32
33pub use sync::is_hidden_from_ui;
34
35pub use validation::validate_image_fallback_for_session;
36
37/// Prepare an execute: load session, resolve model/reasoning, validate,
38/// update metadata, return outcome.
39///
40/// The caller (handler) is responsible for runner reservation and agent spawning
41/// based on the returned outcome.
42pub async fn prepare_execute(
43    repo: &dyn SessionAccess,
44    config: ExecutionConfigSnapshot,
45    input: ExecuteInput,
46) -> Result<ExecutePreparationOutcome, ExecutePreparationError> {
47    // ---- Load session ----
48    let mut session = repo
49        .load_session(&input.session_id)
50        .await?
51        .ok_or_else(|| ExecutePreparationError::NotFound(input.session_id.clone()))?;
52
53    let is_child_session = session.kind == bamboo_agent_core::SessionKind::Child;
54    let server_snapshot = ServerExecuteSnapshot::from_session(&session);
55
56    // ---- Client sync check ----
57    if let Some(reason) = evaluate_client_sync(input.client_sync.as_ref(), &server_snapshot) {
58        match input.client_sync.as_ref() {
59            Some(cs) => tracing::debug!(
60                "[{}] 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={}",
61                input.session_id,
62                reason,
63                cs.client_message_count,
64                cs.client_last_message_id,
65                cs.client_has_pending_question,
66                cs.client_pending_question_tool_call_id,
67                server_snapshot.message_count,
68                server_snapshot.last_message_id,
69                server_snapshot.has_pending_question,
70                server_snapshot.pending_question_tool_call_id,
71                session.messages.len(),
72            ),
73            None => tracing::debug!(
74                "[{}] Execute sync MISMATCH reason={:?} but no client_sync was sent",
75                input.session_id,
76                reason
77            ),
78        }
79        return Ok(ExecutePreparationOutcome::SyncMismatch {
80            reason,
81            server_snapshot,
82        });
83    }
84
85    let scheduled_binding = if session.metadata.contains_key("created_by_schedule_id") {
86        Some(
87            schedule_bound_model_ref(&session)
88                .map_err(ExecutePreparationError::InvalidModelReference)?,
89        )
90    } else {
91        None
92    };
93
94    // Capture before resolving or mutating the candidate. The storage layer
95    // compares this profile under the physical writer guard before startup.
96    let scheduled_startup_expected = scheduled_binding
97        .as_ref()
98        .map(|_| Box::new(session.clone()));
99
100    // ---- Resolve model cascade ----
101    // Flag ON (new): session.model_ref → request.model_ref → config.default_model_ref
102    // Flag OFF (old): session.model → config.default_model → request.model
103    let (effective_model_ref, effective_model, model_source) =
104        resolve_execution_model(&session, &input, &config)?;
105
106    let Some(effective_model) = effective_model else {
107        return Ok(ExecutePreparationOutcome::ModelRequired);
108    };
109
110    // ---- Resolve reasoning cascade: session → request → model role → provider default ----
111    // Single shared cascade (see `crate::model_areas`). Stays `Option` so
112    // non-reasoning models send no reasoning parameter.
113    let (effective_reasoning_effort, reasoning_effort_source) = {
114        let (effort, source) = crate::model_areas::resolve_effective_reasoning_effort(
115            session.reasoning_effort,
116            input.request_reasoning_effort,
117            effective_model_ref
118                .as_ref()
119                .and_then(|model_ref| model_ref.reasoning_effort),
120            config.default_reasoning_effort,
121        );
122        (effort, source.as_str())
123    };
124
125    // Image capability/fallback validation happens after the caller resolves
126    // the actual provider handle, before reserving or spawning execution.
127
128    // ---- Check for pending user message ----
129    if !server_snapshot.has_pending_user_message {
130        return Ok(ExecutePreparationOutcome::NoPendingMessage { server_snapshot });
131    }
132
133    // ---- Update session metadata ----
134    if let Some(model_ref) = effective_model_ref.as_ref() {
135        persist_model_ref(&mut session, model_ref);
136    } else {
137        persist_legacy_model_provider(
138            &mut session,
139            Some(effective_model.as_str()),
140            Some(config.provider_name.as_str()),
141        );
142    }
143    session.reasoning_effort = effective_reasoning_effort;
144
145    session
146        .metadata
147        .insert("model_source".to_string(), model_source.to_string());
148
149    if effective_reasoning_effort.is_some() {
150        session.metadata.insert(
151            "reasoning_effort_source".to_string(),
152            reasoning_effort_source.to_string(),
153        );
154        session.metadata.insert(
155            "reasoning_effort_compat".to_string(),
156            effective_reasoning_effort
157                .map(ReasoningEffort::as_str)
158                .unwrap_or_default()
159                .to_string(),
160        );
161    } else {
162        session.metadata.remove("reasoning_effort_source");
163        session.metadata.remove("reasoning_effort_compat");
164    }
165
166    // ---- Skill mode ----
167    if let Some(skill_mode) = input.request_skill_mode {
168        let trimmed = skill_mode.trim();
169        if trimmed.is_empty() {
170            session.clear_skill_mode();
171        } else {
172            session.set_skill_mode(trimmed);
173        }
174    }
175
176    // ---- Consume pending clarification resume markers ----
177    consume_pending_clarification_resume(&mut session);
178
179    Ok(ExecutePreparationOutcome::Ready {
180        session: Box::new(session),
181        effective_model,
182        effective_reasoning_effort,
183        model_source,
184        reasoning_source: reasoning_effort_source,
185        is_child_session,
186        scheduled_binding,
187        scheduled_startup_expected,
188    })
189}
190
191fn resolve_execution_model(
192    session: &Session,
193    input: &ExecuteInput,
194    config: &ExecutionConfigSnapshot,
195) -> Result<
196    (
197        Option<bamboo_domain::ProviderModelRef>,
198        Option<String>,
199        &'static str,
200    ),
201    ExecutePreparationError,
202> {
203    if let Some(target) =
204        schedule_bound_model_ref(session).map_err(ExecutePreparationError::InvalidModelReference)?
205    {
206        let model = Some(target.model.clone());
207        return Ok((Some(target), model, "session"));
208    }
209    if config.provider_model_ref_enabled {
210        Ok(resolve_model_ref_cascade(session, input, config))
211    } else {
212        let (model, source) = resolve_model_cascade(session, input, config);
213        Ok((None, model, source))
214    }
215}
216
217/// Old-path model resolution: session.model → config.default_model → request.model
218pub(crate) fn resolve_model_cascade(
219    session: &Session,
220    input: &ExecuteInput,
221    config: &ExecutionConfigSnapshot,
222) -> (Option<String>, &'static str) {
223    let session_model = normalize_model(Some(session.model.as_str()));
224    let request_model = normalize_model(input.request_model.as_deref());
225    let request_model_used = request_model.is_some();
226    let model_source = if session_model.is_some() {
227        "session"
228    } else if config.default_model.is_some() {
229        "provider_default"
230    } else if request_model_used {
231        "request"
232    } else {
233        "none"
234    };
235    let effective_model = session_model
236        .or_else(|| config.default_model.clone())
237        .or(request_model);
238
239    (effective_model, model_source)
240}
241
242/// New-path model resolution: session.model_ref → request.model_ref → config.default_model_ref.
243pub(crate) fn resolve_model_ref_cascade(
244    session: &Session,
245    input: &ExecuteInput,
246    config: &ExecutionConfigSnapshot,
247) -> (
248    Option<bamboo_domain::ProviderModelRef>,
249    Option<String>,
250    &'static str,
251) {
252    let session_model_ref = session_effective_model_ref(session);
253    let request_model_ref = super::provider_model::derive_model_ref(
254        input.request_model_ref.as_ref(),
255        input.request_provider.as_deref(),
256        input.request_model.as_deref(),
257    );
258    let config_model_ref = config.default_model_ref.clone();
259
260    let (effective_model_ref, model_source) = if let Some(model_ref) = session_model_ref {
261        (Some(model_ref), "session")
262    } else if let Some(model_ref) = request_model_ref {
263        (Some(model_ref), "request")
264    } else if let Some(model_ref) = config_model_ref {
265        (Some(model_ref), "provider_default")
266    } else {
267        (None, "none")
268    };
269
270    if let Some(model_ref) = effective_model_ref {
271        let effective_model = normalize_model(Some(model_ref.model.as_str()));
272        (Some(model_ref), effective_model, model_source)
273    } else {
274        let (effective_model, legacy_source) = resolve_model_cascade(session, input, config);
275        (None, effective_model, legacy_source)
276    }
277}
278
279// ---- Internal helpers ----
280
281fn normalize_model(model: Option<&str>) -> Option<String> {
282    model
283        .map(str::trim)
284        .filter(|m| !m.is_empty() && *m != "unknown")
285        .map(String::from)
286}