1use std::collections::{BTreeMap, HashMap, HashSet};
18use std::sync::Arc;
19use std::time::Duration;
20
21use async_stream::stream;
22use futures::StreamExt;
23use serde_json::{Value, json};
24
25use crate::executor::inference::{BoxStream, call_inference};
26use crate::executor::messages_request::{normalize_native_web_search, web_search_budget_exhausted_result};
27use crate::executor::request::ExecutionContext;
28use crate::tool::ToolRegistry;
29use crate::types::messages::tool_seam;
30use crate::utils::common::serialize_to_string;
31
32use crate::executor::messages_loop::{GATEWAY_TOOL_TIMEOUT, MAX_GATEWAY_TOOL_ROUNDS};
34const CHUNK_TIMEOUT: Duration = Duration::from_secs(120);
37
38#[must_use]
41pub fn run_messages_stream(
42 mut request: Value,
43 registry: Arc<ToolRegistry>,
44 exec_ctx: Arc<ExecutionContext>,
45 auth: Option<String>,
46) -> BoxStream {
47 let url = format!("{}/v1/messages", exec_ctx.llm_base_url);
48 let preparation = normalize_native_web_search(&mut request);
49 request["stream"] = Value::Bool(true);
50
51 Box::pin(stream! {
52 let mut web_search_budget = match preparation {
53 Ok(budget) => budget,
54 Err(error) => { yield error_sse(&error.to_string()); return; }
55 };
56 let mut acc = MessagesStreamAccumulator::new(exec_ctx.messages_gateway_tools.clone());
57
58 for _round in 0..MAX_GATEWAY_TOOL_ROUNDS {
59 let body = match serialize_to_string(&request) {
60 Ok(b) => b,
61 Err(e) => { yield error_sse(&e.to_string()); return; }
62 };
63 let mut upstream = Box::pin(call_inference(
64 body, url.clone(), Arc::clone(&exec_ctx.client), auth.clone(), CHUNK_TIMEOUT,
65 ));
66
67 acc.begin_round();
68 while let Some(line) = upstream.next().await {
69 let line = match line {
70 Ok(l) => l,
71 Err(e) => { yield error_sse(&e.to_string()); return; }
72 };
73 for out in acc.push(&line) {
74 yield out;
75 }
76 }
77
78 if !acc.should_continue_loop() {
81 for out in acc.finish() {
82 yield out;
83 }
84 return;
85 }
86 let (assistant_content, calls) = acc.take_round();
91 let allowed_searches = web_search_budget.reserve(calls.len());
92 let resolved = execute_gateway_calls(
93 &calls,
94 ®istry,
95 &exec_ctx.messages_gateway_tools,
96 allowed_searches,
97 ).await;
98 append_round_to_history(&mut request, &assistant_content, &resolved);
99 }
100
101 yield error_sse(&format!("gateway tool loop exceeded {MAX_GATEWAY_TOOL_ROUNDS} rounds"));
103 })
104}
105
106struct StreamedCall {
108 id: String,
109 name: String,
110 input_json: String,
111}
112
113struct BufferedBlock {
118 block: Value,
120 input_json: String,
122 is_gateway_tool: bool,
124}
125
126impl BufferedBlock {
127 fn apply_delta(&mut self, delta: &Value) {
128 match delta.get("type").and_then(Value::as_str) {
129 Some("text_delta") => append_str(&mut self.block, "text", delta.get("text")),
130 Some("thinking_delta") => append_str(&mut self.block, "thinking", delta.get("thinking")),
131 Some("signature_delta") => append_str(&mut self.block, "signature", delta.get("signature")),
132 Some("input_json_delta") => {
133 if let Some(partial) = delta.get("partial_json").and_then(Value::as_str) {
134 self.input_json.push_str(partial);
135 }
136 }
137 _ => {}
138 }
139 }
140
141 fn to_block(&self) -> Value {
145 let mut block = self.block.clone();
146 if block.get("type").and_then(Value::as_str) == Some("tool_use") {
147 block["input"] = tool_seam::parse_tool_input(&self.input_json).unwrap_or_else(|_| json!({}));
148 }
149 block
150 }
151}
152
153fn append_str(block: &mut Value, field: &str, fragment: Option<&Value>) {
156 let Some(fragment) = fragment.and_then(Value::as_str) else {
157 return;
158 };
159 let combined = match block.get(field).and_then(Value::as_str) {
160 Some(existing) => format!("{existing}{fragment}"),
161 None => fragment.to_owned(),
162 };
163 block[field] = Value::from(combined);
164}
165
166struct MessagesStreamAccumulator {
169 message_started: bool,
170 next_index: u32,
172 index_map: HashMap<u64, u32>,
175 suppressed_indices: HashSet<u64>,
177 blocks: BTreeMap<u64, BufferedBlock>,
181 ended_on_tool_use: bool,
183 has_client_tool_use: bool,
187 final_message_delta: Option<Value>,
189 gateway_map: tool_seam::GatewayToolMap,
193}
194
195impl MessagesStreamAccumulator {
196 fn new(gateway_map: tool_seam::GatewayToolMap) -> Self {
197 Self {
198 message_started: false,
199 next_index: 0,
200 index_map: HashMap::new(),
201 suppressed_indices: HashSet::new(),
202 blocks: BTreeMap::new(),
203 ended_on_tool_use: false,
204 has_client_tool_use: false,
205 final_message_delta: None,
206 gateway_map,
207 }
208 }
209
210 fn begin_round(&mut self) {
211 self.index_map.clear();
212 self.suppressed_indices.clear();
213 self.blocks.clear();
214 self.ended_on_tool_use = false;
215 self.has_client_tool_use = false;
216 self.final_message_delta = None;
219 }
220
221 fn gateway_call_count(&self) -> usize {
223 self.blocks.values().filter(|b| b.is_gateway_tool).count()
224 }
225
226 fn take_round(&mut self) -> (Vec<Value>, Vec<StreamedCall>) {
231 let blocks = std::mem::take(&mut self.blocks);
232 let mut assistant_content = Vec::with_capacity(blocks.len());
233 let mut calls = Vec::new();
234 for buffered in blocks.values() {
235 assistant_content.push(buffered.to_block());
236 if buffered.is_gateway_tool {
237 calls.push(StreamedCall {
238 id: buffered.block["id"].as_str().unwrap_or_default().to_owned(),
239 name: buffered.block["name"].as_str().unwrap_or_default().to_owned(),
240 input_json: buffered.input_json.clone(),
241 });
242 }
243 }
244 (assistant_content, calls)
245 }
246
247 fn should_continue_loop(&self) -> bool {
251 self.ended_on_tool_use && self.gateway_call_count() > 0 && !self.has_client_tool_use
252 }
253
254 fn push(&mut self, line: &str) -> Vec<String> {
256 let Some(data) = line.strip_prefix("data: ") else {
257 return Vec::new();
258 };
259 let data = data.trim();
260 if data == "[DONE]" {
261 return Vec::new();
262 }
263 let Ok(mut event) = serde_json::from_str::<Value>(data) else {
264 return Vec::new();
265 };
266 match event.get("type").and_then(Value::as_str) {
267 Some("message_start") => self.on_message_start(&event),
268 Some("content_block_start") => self.on_block_start(&mut event),
269 Some("content_block_delta") => self.on_block_delta(&mut event),
270 Some("content_block_stop") => self.on_block_stop(&mut event),
271 Some("message_delta") => {
272 self.ended_on_tool_use = event["delta"]["stop_reason"].as_str() == Some("tool_use");
274 self.final_message_delta = Some(event);
275 Vec::new()
276 }
277 _ => Vec::new(),
280 }
281 }
282
283 fn on_message_start(&mut self, event: &Value) -> Vec<String> {
284 if self.message_started {
285 return Vec::new();
286 }
287 self.message_started = true;
288 vec![sse("message_start", event)]
289 }
290
291 fn on_block_start(&mut self, event: &mut Value) -> Vec<String> {
292 let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
293 let block_type = event["content_block"]["type"].as_str().unwrap_or_default();
294 let name = event["content_block"]["name"].as_str().unwrap_or_default();
295
296 let is_gateway_tool = block_type == "tool_use" && self.gateway_map.is_gateway_owned(name);
298 self.blocks.insert(
299 up_index,
300 BufferedBlock {
301 block: event["content_block"].clone(),
302 input_json: String::new(),
303 is_gateway_tool,
304 },
305 );
306
307 if block_type == "tool_use" {
308 if is_gateway_tool {
309 self.suppressed_indices.insert(up_index);
312 return Vec::new();
313 }
314 self.has_client_tool_use = true;
317 }
318
319 let client_index = self.next_index;
321 self.next_index += 1;
322 self.index_map.insert(up_index, client_index);
323 event["index"] = Value::from(client_index);
324 vec![sse("content_block_start", event)]
325 }
326
327 fn on_block_delta(&mut self, event: &mut Value) -> Vec<String> {
328 let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
329 if let Some(buffered) = self.blocks.get_mut(&up_index) {
332 buffered.apply_delta(&event["delta"]);
333 }
334 if self.suppressed_indices.contains(&up_index) {
337 return Vec::new();
338 }
339 let Some(&client_index) = self.index_map.get(&up_index) else {
340 return Vec::new();
341 };
342 event["index"] = Value::from(client_index);
343 vec![sse("content_block_delta", event)]
344 }
345
346 fn on_block_stop(&mut self, event: &mut Value) -> Vec<String> {
347 let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
348 if self.suppressed_indices.contains(&up_index) {
349 return Vec::new();
350 }
351 let Some(&client_index) = self.index_map.get(&up_index) else {
352 return Vec::new();
353 };
354 event["index"] = Value::from(client_index);
355 vec![sse("content_block_stop", event)]
356 }
357
358 fn finish(&mut self) -> Vec<String> {
360 let mut out = Vec::new();
361 if let Some(delta) = self.final_message_delta.take() {
362 out.push(sse("message_delta", &delta));
363 }
364 out.push(sse("message_stop", &json!({"type": "message_stop"})));
365 out
366 }
367}
368
369fn sse(event: &str, value: &Value) -> String {
370 let json = serialize_to_string(value).unwrap_or_default();
371 format!("event: {event}\ndata: {json}\n\n")
372}
373
374fn error_sse(message: &str) -> String {
375 let event = json!({"type": "error", "error": {"type": "api_error", "message": message}});
376 let json = serialize_to_string(&event).unwrap_or_default();
377 format!("event: error\ndata: {json}\n\n")
378}
379
380async fn execute_gateway_calls(
383 calls: &[StreamedCall],
384 registry: &ToolRegistry,
385 gateway_map: &tool_seam::GatewayToolMap,
386 allowed_searches: usize,
387) -> Vec<ResolvedStreamCall> {
388 let futures = calls.iter().enumerate().map(|(index, c)| async move {
389 if index >= allowed_searches {
390 return ResolvedStreamCall {
391 tool_result_block: web_search_budget_exhausted_result(&c.id),
392 };
393 }
394 let (output, is_error) = match tool_seam::parse_tool_input(&c.input_json) {
397 Ok(input) => {
398 let call = tool_seam::tool_use_to_call(&c.id, &c.name, &input, gateway_map);
399 match tokio::time::timeout(GATEWAY_TOOL_TIMEOUT, registry.dispatch(&call)).await {
400 Ok(Some(result)) => match result.output {
401 Ok(o) => (o.output, false),
402 Err(e) => (format!("tool execution failed: {e}"), true),
403 },
404 Ok(None) => (format!("no handler for tool '{}'", c.name), true),
405 Err(_) => (
406 format!("gateway tool '{}' timed out after {GATEWAY_TOOL_TIMEOUT:?}", c.name),
407 true,
408 ),
409 }
410 }
411 Err(reason) => (format!("{reason}; tool was not run"), true),
412 };
413 ResolvedStreamCall {
414 tool_result_block: tool_seam::tool_result_block(&c.id, &output, is_error),
415 }
416 });
417 futures::future::join_all(futures).await
418}
419
420struct ResolvedStreamCall {
424 tool_result_block: Value,
425}
426
427fn append_round_to_history(request: &mut Value, assistant_content: &[Value], resolved: &[ResolvedStreamCall]) {
432 let assistant = json!({ "role": "assistant", "content": assistant_content });
433 let user = json!({
434 "role": "user",
435 "content": resolved.iter().map(|r| r.tool_result_block.clone()).collect::<Vec<_>>()
436 });
437 if let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) {
438 messages.push(assistant);
439 messages.push(user);
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn line(v: &Value) -> String {
448 format!("data: {v}")
449 }
450
451 fn acc() -> MessagesStreamAccumulator {
453 MessagesStreamAccumulator::new(tool_seam::GatewayToolMap::default())
454 }
455
456 #[test]
459 fn single_round_text_passes_through() {
460 let mut acc = acc();
461 acc.begin_round();
462 let mut out = Vec::new();
463 out.extend(acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}}))));
464 out.extend(acc.push(&line(
465 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
466 )));
467 out.extend(acc.push(&line(
468 &json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}),
469 )));
470 out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 0}))));
471 out.extend(acc.push(&line(
472 &json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}}),
473 )));
474 out.extend(acc.push(&line(&json!({"type": "message_stop"}))));
475 assert!(!acc.should_continue_loop(), "text-only round is terminal");
476 out.extend(acc.finish());
477 let s = out.join("");
478 assert_eq!(s.matches("event: message_start").count(), 1);
479 assert_eq!(s.matches("event: message_stop").count(), 1);
480 assert!(s.contains("text_delta"));
481 assert!(s.contains("end_turn"));
482 }
483
484 #[test]
487 fn gateway_tool_round_suppresses_tool_use_and_reconstructs_call() {
488 let mut acc = acc();
489 acc.begin_round();
490 let mut out = Vec::new();
491 out.extend(acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}}))));
492 out.extend(acc.push(&line(
494 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}),
495 )));
496 out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 0}))));
497 out.extend(acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "tid", "name": "web_search", "input": {}}}))));
499 out.extend(acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":"}}))));
500 out.extend(acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "\"rust\"}"}}))));
501 out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 1}))));
502 out.extend(acc.push(&line(
503 &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
504 )));
505 out.extend(acc.push(&line(&json!({"type": "message_stop"}))));
506
507 let s = out.join("");
508 assert!(acc.should_continue_loop(), "pure gateway-tool round continues the loop");
509 assert!(!s.contains("tool_use"), "gateway tool_use must not surface: {s}");
510 assert!(!s.contains("message_stop"), "intermediate terminal suppressed");
511 assert!(s.contains("thinking"), "thinking forwarded");
512 let (_assistant, calls) = acc.take_round();
513 assert_eq!(calls.len(), 1);
514 assert_eq!(calls[0].name, "web_search");
515 assert_eq!(calls[0].input_json, "{\"query\":\"rust\"}");
516 }
517
518 #[test]
521 fn indices_are_contiguous_across_rounds() {
522 let mut acc = acc();
523 acc.begin_round();
525 acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
526 acc.push(&line(
527 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}),
528 ));
529 acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
530 acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
531 acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
532 acc.begin_round();
534 let out = acc.push(&line(
535 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
536 ));
537 let started: Value =
538 serde_json::from_str(out[0].lines().nth(1).unwrap().strip_prefix("data: ").unwrap()).unwrap();
539 assert_eq!(started["index"], 1, "round-2 text rebased to contiguous client index 1");
540 }
541
542 #[test]
546 fn mixed_client_and_gateway_tool_use_stops_the_loop() {
547 let mut acc = acc();
548 acc.begin_round();
549 acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
550 acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "g"}})));
552 acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
553 let out = acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "name": "get_weather", "id": "c"}})));
555 acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
556 acc.push(&line(
557 &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
558 ));
559
560 let started: Value =
562 serde_json::from_str(out[0].lines().nth(1).unwrap().strip_prefix("data: ").unwrap()).unwrap();
563 assert_eq!(
564 started["content_block"]["name"], "get_weather",
565 "client tool_use forwarded"
566 );
567 assert!(
569 !acc.should_continue_loop(),
570 "mixed round is terminal — loop must not continue"
571 );
572 }
573
574 #[test]
578 fn repro_f6_begin_round_resets_stale_terminal() {
579 let mut acc = acc();
580 acc.begin_round();
582 acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
583 acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
584 acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
585 acc.push(&line(
586 &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
587 ));
588 acc.begin_round();
590 acc.push(&line(
591 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
592 ));
593 acc.push(&line(
594 &json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}),
595 ));
596 acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
597 let out = acc.finish().join("");
598 assert!(
599 !out.contains(r#""stop_reason":"tool_use""#),
600 "must not emit round 1's stale tool_use terminal: {out}"
601 );
602 }
603
604 #[test]
609 fn repro_f3_stream_history_preserves_thinking_text_and_signature() {
610 let mut acc = acc();
611 acc.begin_round();
612 acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
613 acc.push(&line(
615 &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}),
616 ));
617 acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "let me search"}})));
618 acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "SIG=="}})));
619 acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
620 acc.push(&line(
622 &json!({"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}),
623 ));
624 acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Searching..."}})));
625 acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
626 acc.push(&line(&json!({"type": "content_block_start", "index": 2, "content_block": {"type": "tool_use", "id": "tid", "name": "web_search", "input": {}}})));
628 acc.push(&line(&json!({"type": "content_block_delta", "index": 2, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":\"rust\"}"}})));
629 acc.push(&line(&json!({"type": "content_block_stop", "index": 2})));
630 acc.push(&line(
631 &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
632 ));
633
634 let (assistant, _calls) = acc.take_round();
635 let types: Vec<&str> = assistant.iter().filter_map(|b| b["type"].as_str()).collect();
636 assert_eq!(
637 types,
638 vec!["thinking", "text", "tool_use"],
639 "full assistant turn preserved in order, not just the gateway tool_use: {assistant:?}"
640 );
641 assert_eq!(assistant[0]["thinking"], "let me search", "thinking text reconstructed");
642 assert_eq!(
643 assistant[0]["signature"], "SIG==",
644 "signature preserved for the next round"
645 );
646 assert_eq!(assistant[1]["text"], "Searching...", "text reconstructed");
647 assert_eq!(
648 assistant[2]["input"]["query"], "rust",
649 "gateway call input reconstructed"
650 );
651 }
652
653 #[tokio::test]
656 async fn repro_f4_malformed_partial_json_is_not_dispatched_with_empty_args() {
657 let mut acc = acc();
658 acc.begin_round();
659 acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
660 acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
661 acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":"}})));
663 let (_assistant, calls) = acc.take_round();
664 assert_eq!(calls.len(), 1);
665 assert!(
667 serde_json::from_str::<serde_json::Value>(&calls[0].input_json).is_err(),
668 "incomplete partial_json is invalid JSON"
669 );
670 let resolved = execute_gateway_calls(
674 &calls,
675 &no_op_registry().await,
676 &tool_seam::GatewayToolMap::default(),
677 usize::MAX,
678 )
679 .await;
680 let content = resolved[0].tool_result_block["content"].as_str().unwrap_or_default();
681 assert!(
682 content.contains("invalid") || content.contains("malformed") || content.contains("could not"),
683 "malformed args must yield an error tool_result, not an empty-arg dispatch: {content:?}"
684 );
685 }
686
687 async fn no_op_registry() -> ToolRegistry {
691 let mut tools = [];
692 let mut executors = crate::tool::GatewayExecutors::from_env(std::sync::Arc::new(reqwest::Client::new()));
693 ToolRegistry::build_with_handlers(&mut tools, &mut executors)
694 .await
695 .unwrap()
696 }
697}