Skip to main content

bamboo_server/handlers/agent/execute/handler/
mod.rs

1use actix_web::{web, HttpResponse};
2
3use super::image_fallback::resolve_image_fallback;
4use super::{ExecuteRequest, ExecuteSyncInfo, ExecuteSyncReason};
5use crate::app_state::AppState;
6use bamboo_engine::model_areas::resolve_global_area_models;
7use bamboo_engine::model_config_helper::{
8    get_default_model_for_provider, get_reasoning_effort_for_provider, resolve_gold_config,
9    resolve_provider_type,
10};
11
12use self::response::{
13    already_running_response, bad_request_error_response, completed_response,
14    internal_server_error_response,
15};
16
17mod ready;
18mod response;
19#[cfg(test)]
20mod tests;
21mod validation;
22
23/// Execute the AI agent on a chat session.
24pub async fn handler(
25    state: web::Data<AppState>,
26    path: web::Path<String>,
27    req: web::Json<ExecuteRequest>,
28) -> HttpResponse {
29    let session_id = path.into_inner();
30    tracing::debug!(
31        "[{}] Execute requested: model={:?}, model_ref={:?}, reasoning_effort={:?}, has_client_sync={}",
32        session_id,
33        req.model,
34        req.model_ref
35            .as_ref()
36            .map(|m| format!("{}/{}", m.provider, m.model)),
37        req.reasoning_effort,
38        req.client_sync.is_some(),
39    );
40
41    // ---- Build execution config snapshot from server config ----
42    let config_snapshot = state.config.read().await.clone();
43    let image_fallback = match resolve_image_fallback(&config_snapshot) {
44        Ok(value) => value,
45        Err(error) => return internal_server_error_response(error),
46    };
47
48    let disabled_tools_vec: Vec<String> =
49        config_snapshot.disabled_tool_names().into_iter().collect();
50    let disabled_skill_ids_vec: Vec<String> =
51        config_snapshot.disabled_skill_ids().into_iter().collect();
52    let requested_provider = req
53        .model_ref
54        .as_ref()
55        .map(|model_ref| model_ref.provider.as_str())
56        .or(req.provider.as_deref())
57        .unwrap_or(config_snapshot.provider.as_str());
58
59    let requested_provider_type = resolve_provider_type(
60        &config_snapshot,
61        requested_provider,
62        &state.provider_registry,
63    );
64    // Auxiliary (non-chat) models are global: resolved from config for the
65    // requested provider, never from the session. One call replaces the
66    // fast/background/summarization trio.
67    let areas = resolve_global_area_models(
68        &config_snapshot,
69        requested_provider,
70        &state.provider_registry,
71    );
72
73    let config = bamboo_engine::session_app::types::ExecutionConfigSnapshot {
74        default_model: get_default_model_for_provider(&config_snapshot, requested_provider).ok(),
75        default_model_ref: config_snapshot.defaults.as_ref().map(|d| d.chat.clone()),
76        default_reasoning_effort: get_reasoning_effort_for_provider(
77            &config_snapshot,
78            requested_provider,
79        ),
80        disabled_tools: disabled_tools_vec.clone(),
81        disabled_skill_ids: disabled_skill_ids_vec.clone(),
82        provider_name: requested_provider.to_string(),
83        provider_type: requested_provider_type.clone(),
84        fast_model: areas.fast.as_ref().map(|m| m.model_name.clone()),
85        fast_model_ref: areas.fast_ref.clone(),
86        background_model: areas.background.as_ref().map(|m| m.model_name.clone()),
87        background_model_ref: areas.background_ref.clone(),
88        summarization_model: areas.summarization.as_ref().map(|m| m.model_name.clone()),
89        summarization_model_ref: areas.summarization_ref.clone(),
90        image_fallback: image_fallback.clone(),
91        gold_config: resolve_gold_config(&config_snapshot, None),
92        provider_model_ref_enabled: config_snapshot.features.provider_model_ref,
93    };
94
95    let input = bamboo_engine::session_app::types::ExecuteInput {
96        session_id: session_id.clone(),
97        request_model: req.model.clone(),
98        request_model_ref: req.model_ref.clone(),
99        request_provider: req.provider.clone(),
100        request_reasoning_effort: req.reasoning_effort,
101        request_skill_mode: req.skill_mode.clone(),
102        client_sync: req.client_sync.as_ref().map(|cs| {
103            bamboo_engine::session_app::types::ExecuteClientSync {
104                client_message_count: cs.client_message_count,
105                client_last_message_id: cs.client_last_message_id.clone(),
106                client_has_pending_question: cs.client_has_pending_question,
107                client_pending_question_tool_call_id: cs
108                    .client_pending_question_tool_call_id
109                    .clone(),
110            }
111        }),
112    };
113
114    let outcome = match bamboo_engine::session_app::execute::prepare_execute(
115        state.as_ref(),
116        config.clone(),
117        input,
118    )
119    .await
120    {
121        Ok(outcome) => outcome,
122        Err(error) => {
123            return match error {
124                bamboo_engine::session_app::errors::ExecutePreparationError::NotFound(_) => {
125                    tracing::warn!("[{session_id}] Execute session not found");
126                    HttpResponse::NotFound().json(serde_json::json!({
127                        "error": crate::error::error_value("Session not found"),
128                        "session_id": session_id
129                    }))
130                }
131                bamboo_engine::session_app::errors::ExecutePreparationError::LoadFailed(
132                    load_err,
133                ) => {
134                    let err_msg = load_err.to_string();
135                    tracing::error!("[{session_id}] Execute session load error: {err_msg}");
136                    HttpResponse::InternalServerError().json(serde_json::json!({
137                        "error": crate::error::error_value(format!(
138                            "Failed to load session: {err_msg}"
139                        ))
140                    }))
141                }
142                _ => internal_server_error_response(format!("Execute preparation failed: {error}")),
143            };
144        }
145    };
146
147    match outcome {
148        bamboo_engine::session_app::types::ExecutePreparationOutcome::Ready {
149            session,
150            effective_model,
151            effective_reasoning_effort,
152            model_source,
153            reasoning_source,
154            is_child_session,
155        } => {
156            ready::handle_execute_ready(
157                &state,
158                &session_id,
159                ready::ReadyExecution {
160                    session: *session,
161                    effective_model,
162                    effective_reasoning_effort,
163                    model_source,
164                    reasoning_source,
165                    is_child_session,
166                    no_human_approver: req.no_human_approver,
167                    run_budget: req.run_budget,
168                },
169                &config,
170                &config_snapshot,
171                image_fallback,
172                disabled_tools_vec,
173                disabled_skill_ids_vec,
174            )
175            .await
176        }
177
178        bamboo_engine::session_app::types::ExecutePreparationOutcome::AlreadyRunning {
179            server_snapshot,
180        } => {
181            let sync_info = server_snapshot_to_sync_info(&server_snapshot, None);
182            already_running_response(&session_id, sync_info, None)
183        }
184
185        bamboo_engine::session_app::types::ExecutePreparationOutcome::NoPendingMessage {
186            server_snapshot,
187        } => {
188            tracing::debug!(
189                "[{}] No pending user message, returning completed status",
190                session_id
191            );
192            let sync_info = server_snapshot_to_sync_info(&server_snapshot, None);
193            completed_response(&session_id, sync_info)
194        }
195
196        bamboo_engine::session_app::types::ExecutePreparationOutcome::SyncMismatch {
197            reason,
198            server_snapshot,
199        } => {
200            tracing::debug!(
201                "[{}] Execute SyncMismatch (reason={:?}); returning completed/need_sync without starting a runner",
202                session_id,
203                reason
204            );
205            state
206                .metrics_service
207                .collector()
208                .execute_sync_mismatch(reason.as_str(), chrono::Utc::now());
209            let sync_info = server_snapshot_to_sync_info(&server_snapshot, Some(reason));
210            completed_response(&session_id, sync_info)
211        }
212
213        bamboo_engine::session_app::types::ExecutePreparationOutcome::ModelRequired => {
214            bad_request_error_response("no model configured for session or provider")
215        }
216
217        bamboo_engine::session_app::types::ExecutePreparationOutcome::ImageFallbackError(error) => {
218            bad_request_error_response(error)
219        }
220    }
221}
222
223/// Convert a crate's `ExecuteSyncReason` to the handler's `ExecuteSyncReason`.
224fn crate_sync_reason_to_handler(
225    reason: bamboo_engine::session_app::types::ExecuteSyncReason,
226) -> ExecuteSyncReason {
227    match reason {
228        bamboo_engine::session_app::types::ExecuteSyncReason::PendingQuestionMismatch => {
229            ExecuteSyncReason::PendingQuestionMismatch
230        }
231        bamboo_engine::session_app::types::ExecuteSyncReason::MessageCountMismatch => {
232            ExecuteSyncReason::MessageCountMismatch
233        }
234        bamboo_engine::session_app::types::ExecuteSyncReason::LastMessageIdMismatch => {
235            ExecuteSyncReason::LastMessageIdMismatch
236        }
237    }
238}
239
240fn server_snapshot_to_sync_info(
241    server_snapshot: &bamboo_engine::session_app::types::ServerExecuteSnapshot,
242    reason: Option<bamboo_engine::session_app::types::ExecuteSyncReason>,
243) -> ExecuteSyncInfo {
244    ExecuteSyncInfo {
245        need_sync: reason.is_some(),
246        reason: reason.map(crate_sync_reason_to_handler),
247        server_message_count: server_snapshot.message_count,
248        server_last_message_id: server_snapshot.last_message_id.clone(),
249        has_pending_question: server_snapshot.has_pending_question,
250        pending_question_tool_call_id: server_snapshot.pending_question_tool_call_id.clone(),
251        has_pending_user_message: server_snapshot.has_pending_user_message,
252    }
253}
254
255fn build_sync_info_from_session(session: &bamboo_agent_core::Session) -> ExecuteSyncInfo {
256    // Reuse `server_snapshot_to_sync_info` so the visible-message filter
257    // (matching GET /history) lives in exactly one place. Override
258    // `has_pending_user_message` to false: this builder is used when the
259    // runner is already started (Ready) or already running for the session,
260    // i.e. the client should not retry execute.
261    let snapshot = bamboo_engine::session_app::types::ServerExecuteSnapshot::from_session(session);
262    let mut info = server_snapshot_to_sync_info(&snapshot, None);
263    info.has_pending_user_message = false;
264    info
265}