1use std::collections::{BTreeSet, HashMap};
37
38use serde::{Deserialize, Serialize};
39
40use rig_core::{
41 OneOrMany,
42 message::{AssistantContent, Reasoning, ToolCall, ToolFunction, ToolResult},
43};
44
45use crate::{
46 agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_message},
47 completion::{CompletionError, GetTokenUsage, Message, Usage},
48 json_utils,
49 streaming::{StreamedAssistantContent, ToolCallDeltaContent},
50};
51
52pub(crate) fn merge_reasoning_blocks(
55 accumulated_reasoning: &mut Vec<Reasoning>,
56 incoming: &Reasoning,
57) {
58 let ids_match = |existing: &Reasoning| {
59 matches!(
60 (&existing.id, &incoming.id),
61 (Some(existing_id), Some(incoming_id)) if existing_id == incoming_id
62 )
63 };
64
65 if let Some(existing) = accumulated_reasoning
66 .iter_mut()
67 .rev()
68 .find(|existing| ids_match(existing))
69 {
70 existing.content.extend(incoming.content.clone());
71 } else {
72 accumulated_reasoning.push(incoming.clone());
73 }
74}
75
76pub(crate) fn ordered_streaming_assistant_content(
79 reasoning_items: impl IntoIterator<Item = Reasoning>,
80 text_items: impl IntoIterator<Item = AssistantContent>,
81 trailing_items: impl IntoIterator<Item = AssistantContent>,
82) -> Option<OneOrMany<AssistantContent>> {
83 let mut content_items = reasoning_items
84 .into_iter()
85 .map(AssistantContent::Reasoning)
86 .collect::<Vec<_>>();
87 content_items.extend(text_items);
88 content_items.extend(trailing_items);
89
90 OneOrMany::from_iter_optional(content_items)
91}
92
93pub(crate) fn assistant_text_items_from_choice(
94 choice: &OneOrMany<AssistantContent>,
95) -> Vec<AssistantContent> {
96 choice
97 .iter()
98 .filter_map(|content| match content {
99 AssistantContent::Text(text) => (!text.text.is_empty()
100 || text.additional_params.is_some())
101 .then(|| AssistantContent::Text(text.clone())),
102 _ => None,
103 })
104 .collect()
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct StreamedInvalidToolCall {
112 pub tool_call: ToolCall,
115 pub internal_call_id: String,
117 pub args: Option<String>,
119 pub executable_tool_names: BTreeSet<String>,
121 pub allowed_tool_names: BTreeSet<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
129#[non_exhaustive]
130pub struct PartialStreamedTurn {
131 pub message_id: Option<String>,
133 pub text: Option<String>,
135 pub reasoning: Vec<Reasoning>,
138 pub pending_tool_calls: Vec<ToolCall>,
140}
141
142impl PartialStreamedTurn {
143 pub(crate) fn assistant_message(&self, current_tool_call: Option<ToolCall>) -> Option<Message> {
147 let text_items = match &self.text {
148 Some(text) if !text.is_empty() => vec![AssistantContent::text(text.clone())],
149 _ => Vec::new(),
150 };
151 let mut tool_items = self
152 .pending_tool_calls
153 .iter()
154 .cloned()
155 .map(AssistantContent::ToolCall)
156 .collect::<Vec<_>>();
157 if let Some(tool_call) = current_tool_call {
158 tool_items.push(AssistantContent::ToolCall(tool_call));
159 }
160
161 let content = ordered_streaming_assistant_content(
162 self.reasoning.iter().cloned(),
163 text_items,
164 tool_items,
165 )?;
166 Some(Message::Assistant {
167 id: self.message_id.clone(),
168 content,
169 })
170 }
171
172 pub(crate) fn rollback_messages(
176 &self,
177 invalid_tool_call: ToolCall,
178 feedback: String,
179 ) -> Option<(Message, Message)> {
180 let assistant_message = self.assistant_message(Some(invalid_tool_call.clone()))?;
181
182 let mut retry_results = self
183 .pending_tool_calls
184 .iter()
185 .map(|tool_call| {
186 tool_result_message(
187 tool_call.id.clone(),
188 tool_call.call_id.clone(),
189 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
190 )
191 })
192 .collect::<Vec<_>>();
193 retry_results.push(tool_result_message(
194 invalid_tool_call.id,
195 invalid_tool_call.call_id,
196 feedback,
197 ));
198
199 let user_message = Message::User {
200 content: OneOrMany::from_iter_optional(retry_results)?,
201 };
202
203 Some((assistant_message, user_message))
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
210#[non_exhaustive]
211pub struct StreamedTurn {
212 pub message_id: Option<String>,
214 pub choice: OneOrMany<AssistantContent>,
218 pub executable_tool_names: BTreeSet<String>,
220 pub allowed_tool_names: BTreeSet<String>,
222 #[serde(default)]
226 pub internal_call_ids: Vec<(String, String)>,
227}
228
229#[derive(Debug)]
234pub enum StreamedResolution {
235 Repaired {
239 tool_name: String,
241 },
242 TurnAbandoned {
247 skipped_tool_result: Option<ToolResult>,
250 },
251}
252
253#[derive(Debug, Clone)]
258pub enum StreamedTurnEvent {
259 EmitIngested,
262 EmitToolCallDelta {
265 id: String,
267 internal_call_id: String,
269 content: ToolCallDeltaContent,
271 },
272 InvalidToolCall(Box<StreamedInvalidToolCall>),
277 Completed {
283 usage: Usage,
286 emit_final: bool,
289 },
290}
291
292#[derive(Default)]
293struct ToolCallDeltaState {
294 name_validated: bool,
295 buffered_arguments: Vec<String>,
296}
297
298enum PendingInvalid {
299 FullCall {
301 tool_call: Box<ToolCall>,
302 internal_call_id: String,
303 },
304 NameDelta {
306 id: String,
307 internal_call_id: String,
308 },
309}
310
311pub struct StreamedTurnAssembler {
314 executable_tool_names: BTreeSet<String>,
315 allowed_tool_names: BTreeSet<String>,
316 text: String,
317 saw_text: bool,
318 accumulated_reasoning: Vec<Reasoning>,
319 pending_reasoning_delta_text: String,
320 pending_reasoning_delta_id: Option<String>,
321 pending_tool_calls: Vec<(ToolCall, String)>,
322 delta_states: HashMap<(String, String), ToolCallDeltaState>,
323 pending_invalid: Option<PendingInvalid>,
324}
325
326impl StreamedTurnAssembler {
327 pub fn new(
330 executable_tool_names: BTreeSet<String>,
331 allowed_tool_names: BTreeSet<String>,
332 ) -> Self {
333 Self {
334 executable_tool_names,
335 allowed_tool_names,
336 text: String::new(),
337 saw_text: false,
338 accumulated_reasoning: Vec::new(),
339 pending_reasoning_delta_text: String::new(),
340 pending_reasoning_delta_id: None,
341 pending_tool_calls: Vec::new(),
342 delta_states: HashMap::new(),
343 pending_invalid: None,
344 }
345 }
346
347 pub fn aggregated_text(&self) -> &str {
350 &self.text
351 }
352
353 fn canonical_choice(
356 &self,
357 provider_choice: &OneOrMany<AssistantContent>,
358 ) -> OneOrMany<AssistantContent> {
359 let mut reasoning = self.accumulated_reasoning.clone();
360 if reasoning.is_empty() && !self.pending_reasoning_delta_text.is_empty() {
361 let mut assembled = Reasoning::new(&self.pending_reasoning_delta_text);
362 if let Some(id) = self.pending_reasoning_delta_id.clone() {
363 assembled = assembled.with_id(id);
364 }
365 reasoning.push(assembled);
366 }
367
368 if !self.pending_tool_calls.is_empty() || !reasoning.is_empty() {
369 let text_items = assistant_text_items_from_choice(provider_choice);
370 let tool_items = self
371 .pending_tool_calls
372 .iter()
373 .map(|(tool_call, _)| AssistantContent::ToolCall(tool_call.clone()))
374 .collect::<Vec<_>>();
375 ordered_streaming_assistant_content(reasoning, text_items, tool_items)
376 .unwrap_or_else(|| provider_choice.clone())
377 } else {
378 provider_choice.clone()
379 }
380 }
381
382 pub fn ingest<R>(
389 &mut self,
390 item: &StreamedAssistantContent<R>,
391 ) -> Result<Vec<StreamedTurnEvent>, CompletionError>
392 where
393 R: Clone + Unpin + GetTokenUsage,
394 {
395 if self.pending_invalid.is_some() {
396 return Err(CompletionError::ResponseError(
397 "streamed turn ingested while an invalid tool call awaits resolution".to_string(),
398 ));
399 }
400
401 match item {
402 StreamedAssistantContent::Text(text) => {
403 if !self.saw_text {
404 self.text.clear();
405 self.saw_text = true;
406 }
407 self.text.push_str(&text.text);
408 Ok(vec![StreamedTurnEvent::EmitIngested])
409 }
410 StreamedAssistantContent::Reasoning(reasoning) => {
411 merge_reasoning_blocks(&mut self.accumulated_reasoning, reasoning);
412 Ok(vec![StreamedTurnEvent::EmitIngested])
413 }
414 StreamedAssistantContent::ReasoningDelta { reasoning, id } => {
415 self.pending_reasoning_delta_text.push_str(reasoning);
420 if self.pending_reasoning_delta_id.is_none() {
421 self.pending_reasoning_delta_id = id.clone();
422 }
423 Ok(vec![StreamedTurnEvent::EmitIngested])
424 }
425 StreamedAssistantContent::ToolCall {
426 tool_call,
427 internal_call_id,
428 } => {
429 if !self.allowed_tool_names.contains(&tool_call.function.name) {
430 let invalid = StreamedInvalidToolCall {
431 tool_call: tool_call.clone(),
432 internal_call_id: internal_call_id.clone(),
433 args: Some(json_utils::serialize_json_value(
434 &tool_call.function.arguments,
435 )),
436 executable_tool_names: self.executable_tool_names.clone(),
437 allowed_tool_names: self.allowed_tool_names.clone(),
438 };
439 self.pending_invalid = Some(PendingInvalid::FullCall {
440 tool_call: Box::new(tool_call.clone()),
441 internal_call_id: internal_call_id.clone(),
442 });
443 return Ok(vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]);
444 }
445
446 self.pending_tool_calls
447 .push((tool_call.clone(), internal_call_id.clone()));
448 Ok(Vec::new())
449 }
450 StreamedAssistantContent::ToolCallDelta {
451 id,
452 internal_call_id,
453 content,
454 } => {
455 let key = (id.clone(), internal_call_id.clone());
456 match content {
457 ToolCallDeltaContent::Name(name) => {
458 if !self.allowed_tool_names.contains(name) {
459 let buffered_args = self
460 .delta_states
461 .get(&key)
462 .map(|state| state.buffered_arguments.join(""))
463 .unwrap_or_default();
464 let invalid = StreamedInvalidToolCall {
465 tool_call: self.name_delta_diagnostic_tool_call(
466 id,
467 name,
468 &buffered_args,
469 ),
470 internal_call_id: internal_call_id.clone(),
471 args: Some(buffered_args),
472 executable_tool_names: self.executable_tool_names.clone(),
473 allowed_tool_names: self.allowed_tool_names.clone(),
474 };
475 self.pending_invalid = Some(PendingInvalid::NameDelta {
476 id: id.clone(),
477 internal_call_id: internal_call_id.clone(),
478 });
479 return Ok(vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]);
480 }
481
482 Ok(self.validate_delta_name(&key, name.clone()))
483 }
484 ToolCallDeltaContent::Delta(arguments) => {
485 let state = self.delta_states.entry(key.clone()).or_default();
486 if state.name_validated {
487 Ok(vec![StreamedTurnEvent::EmitToolCallDelta {
488 id: id.clone(),
489 internal_call_id: internal_call_id.clone(),
490 content: ToolCallDeltaContent::Delta(arguments.clone()),
491 }])
492 } else {
493 state.buffered_arguments.push(arguments.clone());
494 Ok(Vec::new())
495 }
496 }
497 }
498 }
499 StreamedAssistantContent::Final(final_response) => {
500 if let Some(err) = self.pending_delta_error() {
501 return Err(err);
502 }
503
504 let usage = final_response.token_usage();
505 let emit_final = self.saw_text;
506 self.saw_text = false;
507 Ok(vec![StreamedTurnEvent::Completed { usage, emit_final }])
508 }
509 StreamedAssistantContent::Unknown(_) => {
510 Ok(vec![StreamedTurnEvent::EmitIngested])
515 }
516 }
517 }
518
519 pub fn resolve_pending_invalid(
524 &mut self,
525 resolution: &StreamedResolution,
526 ) -> Vec<StreamedTurnEvent> {
527 let Some(pending) = self.pending_invalid.take() else {
528 return Vec::new();
529 };
530
531 match (resolution, pending) {
532 (
533 StreamedResolution::Repaired { tool_name },
534 PendingInvalid::FullCall {
535 mut tool_call,
536 internal_call_id,
537 },
538 ) => {
539 tool_call.function.name = tool_name.clone();
540 self.pending_tool_calls.push((*tool_call, internal_call_id));
541 Vec::new()
542 }
543 (
544 StreamedResolution::Repaired { tool_name },
545 PendingInvalid::NameDelta {
546 id,
547 internal_call_id,
548 },
549 ) => {
550 let key = (id, internal_call_id);
551 self.validate_delta_name(&key, tool_name.clone())
552 }
553 (
554 StreamedResolution::TurnAbandoned { .. },
555 PendingInvalid::NameDelta {
556 id,
557 internal_call_id,
558 },
559 ) => {
560 self.delta_states.remove(&(id, internal_call_id));
563 Vec::new()
564 }
565 (StreamedResolution::TurnAbandoned { .. }, PendingInvalid::FullCall { .. }) => {
566 Vec::new()
567 }
568 }
569 }
570
571 pub fn pending_delta_error(&self) -> Option<CompletionError> {
574 self.delta_states
575 .iter()
576 .find(|(_, state)| !state.name_validated && !state.buffered_arguments.is_empty())
577 .map(|((id, internal_call_id), state)| {
578 CompletionError::ResponseError(format!(
579 "streamed tool call arguments received before a validated tool name for id `{id}` and internal_call_id `{internal_call_id}` ({} buffered argument delta(s))",
580 state.buffered_arguments.len()
581 ))
582 })
583 }
584
585 pub fn partial_turn(&self, message_id: Option<String>) -> PartialStreamedTurn {
587 let mut reasoning = self.accumulated_reasoning.clone();
588 if reasoning.is_empty() && !self.pending_reasoning_delta_text.is_empty() {
589 let mut assembled = Reasoning::new(&self.pending_reasoning_delta_text);
590 if let Some(id) = self.pending_reasoning_delta_id.clone() {
591 assembled = assembled.with_id(id);
592 }
593 reasoning.push(assembled);
594 }
595
596 PartialStreamedTurn {
597 message_id,
598 text: self.saw_text.then(|| self.text.clone()),
599 reasoning,
600 pending_tool_calls: self
601 .pending_tool_calls
602 .iter()
603 .map(|(tool_call, _)| tool_call.clone())
604 .collect(),
605 }
606 }
607
608 pub fn finish(
612 self,
613 message_id: Option<String>,
614 final_choice: &OneOrMany<AssistantContent>,
615 ) -> StreamedTurn {
616 let choice = self.canonical_choice(final_choice);
617 let internal_call_ids: Vec<(String, String)> = self
618 .pending_tool_calls
619 .iter()
620 .map(|(tool_call, internal_call_id)| (tool_call.id.clone(), internal_call_id.clone()))
621 .collect();
622
623 StreamedTurn {
624 message_id,
625 choice,
626 executable_tool_names: self.executable_tool_names,
627 allowed_tool_names: self.allowed_tool_names,
628 internal_call_ids,
629 }
630 }
631
632 fn name_delta_diagnostic_tool_call(
633 &self,
634 id: &str,
635 name: &str,
636 buffered_args: &str,
637 ) -> ToolCall {
638 let diagnostic_args = if buffered_args.trim().is_empty() {
639 serde_json::Value::Null
640 } else {
641 serde_json::from_str(buffered_args).unwrap_or(serde_json::Value::Null)
642 };
643 ToolCall::new(
644 id.to_string(),
645 ToolFunction::new(name.to_string(), diagnostic_args),
646 )
647 }
648
649 fn validate_delta_name(
650 &mut self,
651 key: &(String, String),
652 name: String,
653 ) -> Vec<StreamedTurnEvent> {
654 let state = self.delta_states.entry(key.clone()).or_default();
655 state.name_validated = true;
656 let buffered_arguments = std::mem::take(&mut state.buffered_arguments);
657
658 let mut events = vec![StreamedTurnEvent::EmitToolCallDelta {
659 id: key.0.clone(),
660 internal_call_id: key.1.clone(),
661 content: ToolCallDeltaContent::Name(name),
662 }];
663 events.extend(buffered_arguments.into_iter().map(|arguments| {
664 StreamedTurnEvent::EmitToolCallDelta {
665 id: key.0.clone(),
666 internal_call_id: key.1.clone(),
667 content: ToolCallDeltaContent::Delta(arguments),
668 }
669 }));
670 events
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use crate::agent::hook::InvalidToolCallAction;
678 use crate::agent::run::{AgentRun, AgentRunStep};
679 use crate::completion::PromptError;
680 use crate::test_utils::MockResponse;
681 use rig_core::message::{Text, ToolResultContent, UserContent};
682 use serde_json::json;
683
684 fn tool_names(names: &[&str]) -> BTreeSet<String> {
685 names.iter().map(|name| (*name).to_string()).collect()
686 }
687
688 fn assembler() -> StreamedTurnAssembler {
689 StreamedTurnAssembler::new(tool_names(&["add"]), tool_names(&["add"]))
690 }
691
692 fn text_item(text: &str) -> StreamedAssistantContent<MockResponse> {
693 StreamedAssistantContent::Text(Text::new(text.to_string()))
694 }
695
696 fn tool_call(id: &str, name: &str) -> ToolCall {
697 ToolCall::new(
698 id.to_string(),
699 ToolFunction::new(name.to_string(), json!({"x": 1})),
700 )
701 }
702
703 fn tool_call_item(id: &str, name: &str) -> StreamedAssistantContent<MockResponse> {
704 StreamedAssistantContent::ToolCall {
705 tool_call: tool_call(id, name),
706 internal_call_id: format!("internal_{id}"),
707 }
708 }
709
710 fn final_item() -> StreamedAssistantContent<MockResponse> {
711 StreamedAssistantContent::Final(MockResponse::with_usage(Usage::new()))
712 }
713
714 fn name_delta(id: &str, name: &str) -> StreamedAssistantContent<MockResponse> {
715 StreamedAssistantContent::ToolCallDelta {
716 id: id.to_string(),
717 internal_call_id: format!("internal_{id}"),
718 content: ToolCallDeltaContent::Name(name.to_string()),
719 }
720 }
721
722 fn args_delta(id: &str, arguments: &str) -> StreamedAssistantContent<MockResponse> {
723 StreamedAssistantContent::ToolCallDelta {
724 id: id.to_string(),
725 internal_call_id: format!("internal_{id}"),
726 content: ToolCallDeltaContent::Delta(arguments.to_string()),
727 }
728 }
729
730 fn expect_invalid(events: Vec<StreamedTurnEvent>) -> StreamedInvalidToolCall {
731 match events.into_iter().next() {
732 Some(StreamedTurnEvent::InvalidToolCall(invalid)) => *invalid,
733 other => panic!("expected InvalidToolCall, got {other:?}"),
734 }
735 }
736
737 #[test]
738 fn text_accumulates_and_emits() {
739 let mut asm = assembler();
740 let events = asm
741 .ingest(&text_item("hel"))
742 .expect("ingest should succeed");
743 assert!(matches!(
744 events.as_slice(),
745 [StreamedTurnEvent::EmitIngested]
746 ));
747 asm.ingest(&text_item("lo")).expect("ingest should succeed");
748 assert_eq!(asm.aggregated_text(), "hello");
749 }
750
751 #[test]
752 fn unknown_item_emits_to_consumer_without_touching_accumulation() {
753 let mut asm = assembler();
754 asm.ingest(&text_item("answer"))
755 .expect("ingest text should succeed");
756
757 let events = asm
758 .ingest(&StreamedAssistantContent::<MockResponse>::Unknown(
759 json!({ "type": "web_search_call", "id": "ws_1" }),
760 ))
761 .expect("ingest unknown should succeed");
762
763 assert!(matches!(
765 events.as_slice(),
766 [StreamedTurnEvent::EmitIngested]
767 ));
768 assert_eq!(asm.aggregated_text(), "answer");
770 }
771
772 #[test]
773 fn argument_deltas_buffer_until_name_validates() {
774 let mut asm = assembler();
775
776 let events = asm
777 .ingest(&args_delta("tc_1", "{\"x\""))
778 .expect("ingest should succeed");
779 assert!(events.is_empty(), "arguments must buffer before the name");
780
781 let events = asm
782 .ingest(&name_delta("tc_1", "add"))
783 .expect("ingest should succeed");
784 let contents: Vec<_> = events
785 .iter()
786 .map(|event| match event {
787 StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
788 other => panic!("expected EmitToolCallDelta, got {other:?}"),
789 })
790 .collect();
791 assert_eq!(
792 contents,
793 vec![
794 ToolCallDeltaContent::Name("add".to_string()),
795 ToolCallDeltaContent::Delta("{\"x\"".to_string()),
796 ]
797 );
798
799 let events = asm
801 .ingest(&args_delta("tc_1", ":1}"))
802 .expect("ingest should succeed");
803 assert_eq!(events.len(), 1);
804 }
805
806 #[test]
807 fn buffered_arguments_without_validated_name_error_at_final() {
808 let mut asm = assembler();
809 asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
810 .expect("ingest should succeed");
811
812 assert!(asm.pending_delta_error().is_some());
813 assert!(asm.ingest(&final_item()).is_err());
814 }
815
816 #[test]
817 fn finish_orders_reasoning_text_then_tool_calls() {
818 let mut asm = assembler();
819 asm.ingest(&StreamedAssistantContent::<MockResponse>::ReasoningDelta {
820 id: Some("rs_1".to_string()),
821 reasoning: "think".to_string(),
822 })
823 .expect("ingest should succeed");
824 asm.ingest(&tool_call_item("tc_1", "add"))
825 .expect("ingest should succeed");
826
827 let final_choice = OneOrMany::many(vec![
829 AssistantContent::text("answer"),
830 AssistantContent::ToolCall(tool_call("tc_1", "add")),
831 ])
832 .expect("two items");
833
834 let turn = asm.finish(Some("msg_1".to_string()), &final_choice);
835 let kinds: Vec<&'static str> = turn
836 .choice
837 .iter()
838 .map(|item| match item {
839 AssistantContent::Reasoning(_) => "reasoning",
840 AssistantContent::Text(_) => "text",
841 AssistantContent::ToolCall(_) => "tool_call",
842 _ => "other",
843 })
844 .collect();
845 assert_eq!(kinds, vec!["reasoning", "text", "tool_call"]);
846 }
847
848 #[test]
849 fn finish_passes_raw_choice_through_for_plain_text_turns() {
850 let mut asm = assembler();
851 asm.ingest(&text_item("hi")).expect("ingest should succeed");
852
853 let final_choice = OneOrMany::one(AssistantContent::text("hi"));
854 let turn = asm.finish(None, &final_choice);
855 assert_eq!(
856 serde_json::to_value(&turn.choice).expect("serialize"),
857 serde_json::to_value(&final_choice).expect("serialize"),
858 );
859 }
860
861 #[test]
862 fn streamed_run_completes_a_tool_roundtrip() {
863 let mut run = AgentRun::new("add things").max_turns(2);
864
865 let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
867 panic!("expected CallModel");
868 };
869 let mut asm = assembler();
870 assert!(
871 asm.ingest(&tool_call_item("tc_1", "add"))
872 .expect("ingest should succeed")
873 .is_empty()
874 );
875 let usage = Usage {
876 input_tokens: 5,
877 output_tokens: 7,
878 total_tokens: 12,
879 ..Usage::new()
880 };
881 run.record_streamed_completion_call(usage)
882 .expect("record should succeed");
883 let final_choice = OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "add")));
884 run.streamed_turn(asm.finish(Some("msg_1".to_string()), &final_choice))
885 .expect("streamed_turn should succeed");
886
887 let AgentRunStep::CallTools { calls } = run.next_step().expect("next_step") else {
888 panic!("expected CallTools");
889 };
890 assert_eq!(calls.len(), 1);
891 assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_tc_1"));
892 run.tool_results(vec![UserContent::tool_result(
893 "tc_1".to_string(),
894 OneOrMany::one(ToolResultContent::text("2")),
895 )])
896 .expect("tool_results should succeed");
897
898 let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
900 panic!("expected CallModel");
901 };
902 let asm = assembler();
903 run.record_streamed_completion_call(Usage::new())
904 .expect("record should succeed");
905 let final_choice = OneOrMany::one(AssistantContent::text("done"));
906 run.streamed_turn(asm.finish(None, &final_choice))
907 .expect("streamed_turn should succeed");
908
909 let AgentRunStep::Done(response) = run.next_step().expect("next_step") else {
910 panic!("expected Done");
911 };
912 assert_eq!(response.output, "done");
913 assert_eq!(response.usage, usage);
914 assert_eq!(response.completion_calls.len(), 2);
915 assert_eq!(response.completion_calls[0].usage, usage);
916 assert_eq!(response.completion_calls[1].usage, Usage::new());
917 assert_eq!(
919 response
920 .messages
921 .expect("messages should be recorded")
922 .len(),
923 4
924 );
925 }
926
927 #[test]
928 fn streamed_invalid_tool_call_retry_rolls_back_with_partial_turn() {
929 let mut run = AgentRun::new("use the tool")
930 .max_turns(2)
931 .max_invalid_tool_call_retries(1);
932 run.next_step().expect("next_step");
933
934 let mut asm = assembler();
935 asm.ingest(&text_item("thinking ")).expect("ingest");
936 let invalid = expect_invalid(
937 asm.ingest(&tool_call_item("tc_1", "default_api"))
938 .expect("ingest should succeed"),
939 );
940 let partial = asm.partial_turn(Some("msg_1".to_string()));
941 assert_eq!(partial.text.as_deref(), Some("thinking "));
942
943 let context = run.streamed_invalid_tool_call_context(&partial, &invalid);
944 assert!(context.is_streaming);
945 assert_eq!(context.tool_name, "default_api");
946 assert_eq!(context.internal_call_id.as_deref(), Some("internal_tc_1"));
947
948 let resolution = run
949 .resolve_streamed_invalid_tool_call(
950 &partial,
951 &invalid,
952 InvalidToolCallAction::retry("use add instead"),
953 )
954 .expect("retry should be accepted");
955 assert!(matches!(
956 resolution,
957 StreamedResolution::TurnAbandoned {
958 skipped_tool_result: None
959 }
960 ));
961 asm.resolve_pending_invalid(&resolution);
962
963 run.record_streamed_completion_call(Usage::new())
965 .expect("record after rollback should succeed");
966
967 assert_eq!(run.messages().len(), 3);
969 let AgentRunStep::CallModel { turn, .. } = run.next_step().expect("next_step") else {
970 panic!("expected CallModel retry");
971 };
972 assert_eq!(turn, 2);
973 }
974
975 #[test]
976 fn streamed_invalid_tool_call_stop_leaves_run_terminal() {
977 let mut run = AgentRun::new("use the tool");
978 run.next_step().expect("next_step");
979
980 let mut asm = assembler();
981 let invalid = expect_invalid(
982 asm.ingest(&tool_call_item("tc_1", "default_api"))
983 .expect("ingest should succeed"),
984 );
985 let partial = asm.partial_turn(Some("msg_1".to_string()));
986
987 let err = run
988 .resolve_streamed_invalid_tool_call(
989 &partial,
990 &invalid,
991 InvalidToolCallAction::stop("operator stop"),
992 )
993 .expect_err("stop should cancel the run");
994 assert!(matches!(
995 err,
996 PromptError::PromptCancelled { reason, .. } if reason == "operator stop"
997 ));
998
999 let err = run
1000 .next_step()
1001 .expect_err("a stopped streamed run must remain terminal");
1002 assert!(matches!(
1003 err,
1004 PromptError::PromptCancelled { reason, .. }
1005 if reason.contains("next_step called after the run already failed")
1006 ));
1007 }
1008
1009 #[test]
1010 fn streamed_invalid_tool_call_retry_cannot_emit_call_past_total_budget() {
1011 let mut run = AgentRun::new("use the tool")
1012 .max_turns(1)
1013 .max_invalid_tool_call_retries(1);
1014 run.next_step().expect("initial model call");
1015
1016 let mut asm = assembler();
1017 let invalid = expect_invalid(
1018 asm.ingest(&tool_call_item("tc_1", "default_api"))
1019 .expect("ingest should succeed"),
1020 );
1021 let partial = asm.partial_turn(Some("msg_1".to_string()));
1022 let resolution = run
1023 .resolve_streamed_invalid_tool_call(
1024 &partial,
1025 &invalid,
1026 InvalidToolCallAction::retry("use add instead"),
1027 )
1028 .expect("retry resolution should be accepted");
1029 assert!(matches!(
1030 resolution,
1031 StreamedResolution::TurnAbandoned {
1032 skipped_tool_result: None
1033 }
1034 ));
1035 run.record_streamed_completion_call(Usage::new())
1036 .expect("completion call should be recorded");
1037 assert_eq!(run.completion_calls().len(), 1);
1038
1039 let err = run
1040 .next_step()
1041 .expect_err("retry must not emit a second model call");
1042 assert!(matches!(
1043 err,
1044 PromptError::MaxTurnsError { max_turns: 1, .. }
1045 ));
1046 assert_eq!(run.turn(), 1);
1047 }
1048
1049 #[test]
1050 fn streamed_invalid_tool_call_skip_returns_synthetic_result() {
1051 let mut run = AgentRun::new("use the tool").max_turns(2);
1052 run.next_step().expect("next_step");
1053
1054 let mut asm = assembler();
1055 let invalid = expect_invalid(
1056 asm.ingest(&tool_call_item("tc_1", "default_api"))
1057 .expect("ingest should succeed"),
1058 );
1059 let partial = asm.partial_turn(None);
1060
1061 let resolution = run
1062 .resolve_streamed_invalid_tool_call(
1063 &partial,
1064 &invalid,
1065 InvalidToolCallAction::skip("not available"),
1066 )
1067 .expect("skip should be accepted");
1068 let StreamedResolution::TurnAbandoned {
1069 skipped_tool_result: Some(tool_result),
1070 } = &resolution
1071 else {
1072 panic!("expected skipped tool result");
1073 };
1074 assert_eq!(tool_result.id, "tc_1");
1075 }
1076
1077 #[test]
1078 fn streamed_invalid_name_delta_repair_replays_buffered_arguments() {
1079 let mut run = AgentRun::new("use the tool").max_turns(2);
1080 run.next_step().expect("next_step");
1081
1082 let mut asm = assembler();
1083 asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1084 .expect("ingest should succeed");
1085 let invalid = expect_invalid(
1086 asm.ingest(&name_delta("tc_1", "default_api"))
1087 .expect("ingest should succeed"),
1088 );
1089 assert_eq!(invalid.args.as_deref(), Some("{\"x\":1}"));
1090
1091 let partial = asm.partial_turn(None);
1092 let resolution = run
1093 .resolve_streamed_invalid_tool_call(
1094 &partial,
1095 &invalid,
1096 InvalidToolCallAction::repair("add"),
1097 )
1098 .expect("repair should be accepted");
1099 assert!(matches!(
1100 resolution,
1101 StreamedResolution::Repaired { ref tool_name } if tool_name == "add"
1102 ));
1103
1104 let events = asm.resolve_pending_invalid(&resolution);
1105 let contents: Vec<_> = events
1106 .iter()
1107 .map(|event| match event {
1108 StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1109 other => panic!("expected EmitToolCallDelta, got {other:?}"),
1110 })
1111 .collect();
1112 assert_eq!(
1113 contents,
1114 vec![
1115 ToolCallDeltaContent::Name("add".to_string()),
1116 ToolCallDeltaContent::Delta("{\"x\":1}".to_string()),
1117 ]
1118 );
1119 }
1120
1121 #[test]
1122 fn streamed_turn_rejects_unknown_tool_calls_fail_fast() {
1123 let mut run = AgentRun::new("use the tool");
1124 run.next_step().expect("next_step");
1125
1126 let turn = StreamedTurn {
1127 message_id: None,
1128 choice: OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "unknown"))),
1129 executable_tool_names: tool_names(&["add"]),
1130 allowed_tool_names: tool_names(&["add"]),
1131 internal_call_ids: Vec::new(),
1132 };
1133 let err = run
1134 .streamed_turn(turn)
1135 .expect_err("unknown tool should fail fast");
1136 assert!(matches!(
1137 err,
1138 PromptError::UnknownToolCall { tool_name, .. } if tool_name == "unknown"
1139 ));
1140 }
1141
1142 #[test]
1143 fn streamed_completion_call_record_requires_a_model_call() {
1144 let mut run = AgentRun::new("hello");
1147 let err = run
1148 .record_streamed_completion_call(Usage::new())
1149 .expect_err("recording before any model call must be rejected");
1150 assert!(matches!(err, PromptError::PromptCancelled { .. }));
1151
1152 run.next_step().expect("next_step should still succeed");
1154 run.record_streamed_completion_call(Usage::new())
1155 .expect("recording during a pending model call succeeds");
1156 }
1157
1158 #[test]
1159 fn duplicate_tool_call_ids_keep_distinct_internal_ids_through_the_run() {
1160 let mut run = AgentRun::new("do both").max_turns(2);
1161 run.next_step().expect("next_step");
1162
1163 let mut asm = assembler();
1164 asm.ingest(&StreamedAssistantContent::<MockResponse>::ToolCall {
1165 tool_call: tool_call("tc_1", "add"),
1166 internal_call_id: "internal_a".to_string(),
1167 })
1168 .expect("ingest should succeed");
1169 asm.ingest(&StreamedAssistantContent::<MockResponse>::ToolCall {
1170 tool_call: tool_call("tc_1", "add"),
1171 internal_call_id: "internal_b".to_string(),
1172 })
1173 .expect("ingest should succeed");
1174 run.record_streamed_completion_call(Usage::new())
1175 .expect("record should succeed");
1176
1177 let final_choice = OneOrMany::many(vec![
1178 AssistantContent::ToolCall(tool_call("tc_1", "add")),
1179 AssistantContent::ToolCall(tool_call("tc_1", "add")),
1180 ])
1181 .expect("two items");
1182 run.streamed_turn(asm.finish(None, &final_choice))
1183 .expect("streamed_turn should succeed");
1184
1185 let serialized = serde_json::to_string(&run).expect("serialize");
1188 let mut restored: AgentRun = serde_json::from_str(&serialized).expect("deserialize");
1189 let AgentRunStep::CallTools { calls } = restored.next_step().expect("next_step") else {
1190 panic!("expected CallTools");
1191 };
1192 assert_eq!(calls.len(), 2);
1193 assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_a"));
1194 assert_eq!(calls[1].internal_call_id.as_deref(), Some("internal_b"));
1195 }
1196
1197 #[test]
1198 fn streamed_turn_records_the_completion_call_when_the_driver_did_not() {
1199 let mut run = AgentRun::new("hello");
1200 run.next_step().expect("next_step");
1201
1202 let asm = assembler();
1203 let final_choice = OneOrMany::one(AssistantContent::text("done"));
1204 run.streamed_turn(asm.finish(None, &final_choice))
1205 .expect("streamed_turn should succeed");
1206
1207 assert_eq!(run.completion_calls().len(), 1);
1210 assert_eq!(run.completion_calls()[0].usage, Usage::new());
1211 }
1212
1213 #[test]
1214 fn streamed_completion_call_is_recorded_once_per_turn() {
1215 let mut run = AgentRun::new("hello");
1216 run.next_step().expect("next_step");
1217
1218 run.record_streamed_completion_call(Usage::new())
1219 .expect("first record succeeds");
1220 let err = run
1221 .record_streamed_completion_call(Usage::new())
1222 .expect_err("second record for the same turn must be rejected");
1223 assert!(matches!(err, PromptError::PromptCancelled { .. }));
1224 assert_eq!(run.completion_calls().len(), 1);
1225 }
1226
1227 #[test]
1228 fn streamed_run_serde_round_trips_while_tools_pend() {
1229 let mut run = AgentRun::new("add things").max_turns(2);
1230 run.next_step().expect("next_step");
1231
1232 let mut asm = assembler();
1233 asm.ingest(&tool_call_item("tc_1", "add"))
1234 .expect("ingest should succeed");
1235 run.record_streamed_completion_call(Usage::new())
1236 .expect("record should succeed");
1237 let final_choice = OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "add")));
1238 run.streamed_turn(asm.finish(None, &final_choice))
1239 .expect("streamed_turn should succeed");
1240 run.next_step().expect("CallTools step");
1241
1242 let serialized = serde_json::to_string(&run).expect("serialize mid-run");
1243 let mut restored: AgentRun =
1244 serde_json::from_str(&serialized).expect("deserialize mid-run");
1245 restored
1246 .tool_results(vec![UserContent::tool_result(
1247 "tc_1".to_string(),
1248 OneOrMany::one(ToolResultContent::text("2")),
1249 )])
1250 .expect("tool_results should succeed");
1251 assert!(matches!(
1252 restored.next_step().expect("next turn"),
1253 AgentRunStep::CallModel { turn: 2, .. }
1254 ));
1255 }
1256}