Skip to main content

agentic_core/executor/
messages_loop.rs

1//! Messages-native gateway tool loop.
2//!
3//! Runs the server-side gateway-tool loop for `/v1/messages` **natively**: the
4//! client's Anthropic request is forwarded to vLLM `/v1/messages` essentially
5//! untouched (preserving every Anthropic field), the assistant turn is
6//! inspected, any gateway-owned `tool_use` is executed server-side and hidden,
7//! the loop appends the `tool_result` and re-POSTs, until the model stops asking
8//! for a gateway tool. Only the final assistant message reaches the client.
9//!
10//! This never touches `RequestPayload`/`ResponsePayload`; it reuses only the
11//! protocol-neutral tool layer (`ToolRegistry::dispatch`) via
12//! [`crate::types::messages::tool_seam`]. Non-streaming only; streaming lives in
13//! `messages_stream`.
14
15use std::time::Duration;
16
17use futures::future::join_all;
18use serde_json::{Value, json};
19
20use crate::executor::error::{ExecutorError, ExecutorResult};
21use crate::executor::inference::fetch_response_json_with_headers;
22use crate::executor::messages_request::{normalize_native_web_search, web_search_budget_exhausted_result};
23use crate::executor::request::ExecutionContext;
24use crate::tool::ToolRegistry;
25use crate::types::messages::tool_seam;
26use crate::utils::common::{deserialize_from_str, serialize_to_string};
27
28/// Max gateway rounds before the loop gives up. Each round is one upstream
29/// `/v1/messages` call. Shared with the streaming loop (`messages_stream`).
30/// Kept in sync with the Responses loop's `engine::MAX_GATEWAY_TOOL_ROUNDS`
31/// (a future Layering-ADR consolidation would unify these).
32pub(super) const MAX_GATEWAY_TOOL_ROUNDS: usize = 10;
33
34/// Per gateway-tool-call timeout — a hung tool becomes an error `tool_result`
35/// fed back to the model, never a whole-request failure (edge E5). Shared with
36/// the streaming loop; matches the Responses loop's `gateway::GATEWAY_TOOL_TIMEOUT`.
37pub(super) const GATEWAY_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
38
39/// Per-request transport data reused for every upstream Messages round.
40#[derive(Clone, Debug)]
41pub struct MessagesUpstream {
42    url: String,
43    headers: reqwest::header::HeaderMap,
44}
45
46impl MessagesUpstream {
47    #[must_use]
48    pub fn new(base_url: &str, query: Option<&str>, headers: reqwest::header::HeaderMap) -> Self {
49        let mut url = format!("{}/v1/messages", base_url.trim_end_matches('/'));
50        if let Some(query) = query.filter(|query| !query.is_empty()) {
51            url.push('?');
52            url.push_str(query);
53        }
54        Self { url, headers }
55    }
56
57    pub(super) fn url(&self) -> &str {
58        &self.url
59    }
60
61    pub(super) fn headers(&self) -> &reqwest::header::HeaderMap {
62        &self.headers
63    }
64}
65
66/// A Messages loop result paired with safe metadata from the relevant upstream response.
67pub struct MessagesResponse<T> {
68    /// The completed message or client-facing stream.
69    pub body: T,
70    /// Safe metadata retained from the terminal response, or the initial response for streaming.
71    pub headers: http::HeaderMap,
72}
73
74/// The `tool_result` block for one executed gateway call, fed back next round.
75/// (The model's own `tool_use` block is carried forward via the preserved
76/// assistant content, not reconstructed here — see `append_round_to_history`.)
77struct ResolvedCall {
78    tool_result_block: Value,
79}
80
81/// Run the Messages-native gateway tool loop and return the final assistant
82/// message (Anthropic JSON `Value`).
83///
84/// `request` is the client's parsed request body as JSON — forwarded upstream
85/// with `stream:false` forced and its `messages` extended each round.
86///
87/// # Errors
88/// Returns [`ExecutorError`] on upstream failure or unparseable upstream JSON.
89/// Gateway-tool execution failures do **not** error — they become error
90/// `tool_result`s fed back to the model.
91pub async fn run_messages_loop(
92    mut request: Value,
93    registry: &ToolRegistry,
94    exec_ctx: &ExecutionContext,
95    upstream: &MessagesUpstream,
96) -> ExecutorResult<MessagesResponse<Value>> {
97    let mut web_search_budget = normalize_native_web_search(&mut request)?;
98    // The loop drives turns itself; force non-streaming upstream regardless of
99    // what the client asked (the handler routes streaming elsewhere).
100    request["stream"] = Value::Bool(false);
101
102    for _round in 0..MAX_GATEWAY_TOOL_ROUNDS {
103        let body = serialize_to_string(&request).map_err(ExecutorError::JsonError)?;
104        let (resp_text, response_headers) =
105            fetch_response_json_with_headers(body, &upstream.url, &exec_ctx.client, &upstream.headers).await?;
106        let message: Value = deserialize_from_str(&resp_text).map_err(ExecutorError::JsonError)?;
107
108        // Any error body from upstream is surfaced verbatim (handler maps it to
109        // the Anthropic error envelope).
110        if message.get("type").and_then(Value::as_str) == Some("error") {
111            return Ok(MessagesResponse {
112                body: message,
113                headers: response_headers,
114            });
115        }
116
117        let content = message.get("content").and_then(Value::as_array);
118        let stop_reason = message.get("stop_reason").and_then(Value::as_str);
119
120        // Split the assistant turn into gateway-owned tool_use vs everything the
121        // client should see. A client-owned tool_use means we cannot continue
122        // the loop server-side — return the turn to the client (edge E7).
123        let Some(content) = content else {
124            return Ok(MessagesResponse {
125                body: message,
126                headers: response_headers,
127            });
128        };
129        let gateway_map = &exec_ctx.messages_gateway_tools;
130        let mut gateway_calls: Vec<Value> = Vec::new();
131        let mut has_client_tool_use = false;
132        for block in content {
133            if block.get("type").and_then(Value::as_str) == Some("tool_use") {
134                let name = block.get("name").and_then(Value::as_str).unwrap_or_default();
135                if gateway_map.is_gateway_owned(name) {
136                    gateway_calls.push(block.clone());
137                } else {
138                    has_client_tool_use = true;
139                }
140            }
141        }
142
143        // Terminal when the model didn't ask for a gateway tool, or stopped for
144        // another reason. A client-owned tool_use is also terminal (the client
145        // must run it) — but the gateway tool_use, if any, must still be hidden
146        // (F5): strip gateway blocks from the client-facing content.
147        if gateway_calls.is_empty() || stop_reason != Some("tool_use") {
148            return Ok(MessagesResponse {
149                body: message,
150                headers: response_headers,
151            });
152        }
153        if has_client_tool_use {
154            // Strip the gateway tool_use from the client-facing content (compute
155            // before mutating to end the immutable borrow of `message`).
156            let stripped = tool_seam::strip_gateway_tool_use(content, gateway_map);
157            let mut message = message;
158            message["content"] = Value::Array(stripped);
159            return Ok(MessagesResponse {
160                body: message,
161                headers: response_headers,
162            });
163        }
164
165        // Pure gateway-tool round: execute the calls, then feed the model's FULL
166        // assistant turn (thinking/text/tool_use, order preserved — F3) plus the
167        // tool_results back for the next round. Gateway blocks stay internal.
168        let assistant_content = content.clone();
169        let allowed_searches = web_search_budget.reserve(gateway_calls.len());
170        let resolved = execute_gateway_calls(&gateway_calls, registry, gateway_map, allowed_searches).await;
171        append_round_to_history(&mut request, &assistant_content, &resolved);
172    }
173
174    // Round budget exhausted — re-run once more is not attempted; return the
175    // last message. (Open Q1: a dedicated pause_turn signal could go here.)
176    // Reaching here means every round emitted a gateway tool_use; surface a
177    // minimal terminal so the client isn't left hanging.
178    Ok(MessagesResponse {
179        body: json!({
180            "type": "error",
181            "error": {
182                "type": "api_error",
183                "message": format!("gateway tool loop exceeded {MAX_GATEWAY_TOOL_ROUNDS} rounds")
184            }
185        }),
186        headers: http::HeaderMap::new(),
187    })
188}
189
190/// Execute the gateway-owned `tool_use` blocks concurrently, each bounded by the
191/// per-call timeout. A failure or timeout becomes an error `tool_result` (E5).
192async fn execute_gateway_calls(
193    gateway_calls: &[Value],
194    registry: &ToolRegistry,
195    gateway_map: &tool_seam::GatewayToolMap,
196    allowed_searches: usize,
197) -> Vec<ResolvedCall> {
198    let futures = gateway_calls.iter().enumerate().map(|(index, block)| async move {
199        let id = block.get("id").and_then(Value::as_str).unwrap_or_default();
200        let name = block.get("name").and_then(Value::as_str).unwrap_or_default();
201
202        if index >= allowed_searches {
203            return ResolvedCall {
204                tool_result_block: web_search_budget_exhausted_result(id),
205            };
206        }
207
208        // F4: reject a malformed/absent input rather than dispatching with args
209        // the model never supplied. The block's `input` is already-parsed JSON
210        // here (non-streaming), so validate it's an object.
211        let input = block.get("input").cloned().unwrap_or(Value::Null);
212        let (output, is_error) = if input.is_object() {
213            let call = tool_seam::tool_use_to_call(id, name, &input, gateway_map);
214            match tokio::time::timeout(GATEWAY_TOOL_TIMEOUT, registry.dispatch(&call)).await {
215                Ok(Some(result)) => match result.output {
216                    Ok(tool_output) => (tool_output.output, false),
217                    Err(e) => (format!("tool execution failed: {e}"), true),
218                },
219                Ok(None) => (format!("no handler for tool '{name}'"), true),
220                Err(_) => (
221                    format!("gateway tool '{name}' timed out after {GATEWAY_TOOL_TIMEOUT:?}"),
222                    true,
223                ),
224            }
225        } else {
226            (
227                "invalid tool arguments (not a JSON object); tool was not run".to_owned(),
228                true,
229            )
230        };
231
232        ResolvedCall {
233            tool_result_block: tool_seam::tool_result_block(id, &output, is_error),
234        }
235    });
236    join_all(futures).await
237}
238
239/// Append the model's assistant turn (preserving its `thinking`/`text`/`tool_use`
240/// blocks in order — F3) and a following user turn of `tool_result`s to the
241/// request `messages`, so the next upstream round sees the full conversation
242/// state. These stay internal — the client never sees them (hide-the-call).
243fn append_round_to_history(request: &mut Value, assistant_content: &[Value], resolved: &[ResolvedCall]) {
244    let assistant = json!({ "role": "assistant", "content": assistant_content });
245    let user = json!({
246        "role": "user",
247        "content": resolved.iter().map(|r| r.tool_result_block.clone()).collect::<Vec<_>>()
248    });
249    if let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) {
250        messages.push(assistant);
251        messages.push(user);
252    }
253}