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