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