1use rig_core::{
2 message::{AssistantContent, UserContent},
3 telemetry::SpanCombinator,
4 wasm_compat::{WasmBoxedFuture, WasmCompatSend},
5};
6
7use crate::{
8 agent::completion::{PreparedCompletionRequest, build_prepared_completion_request},
9 agent::hook::{
10 AgentHook, HookContext, HookStack, InvalidToolCallAction, ModelSelection,
11 ModelSelectionAction, ModelTurnFinished, ReasoningDelta, StepEventKind,
12 StreamResponseFinish, TextDelta, ToolCallDelta,
13 },
14 agent::prompt_request::{assistant_text_from_choice, is_empty_assistant_turn},
15 agent::run::{
16 AgentRun, AgentRunStep, PendingToolCall,
17 streamed::{StreamedResolution, StreamedTurnAssembler, StreamedTurnEvent},
18 },
19 agent::runner::{
20 AgentRunner, CompletionCallOutcome, ModelTurnDecision, ToolExecution, append_run_messages,
21 build_chat_span, new_execute_tool_span, observe_action, resolve_completion_call,
22 resolve_model_turn_action, run_single_tool,
23 },
24 streaming::{StreamedAssistantContent, StreamedUserContent, ToolCallDeltaContent},
25 tool::{ToolContext, server::ToolRegistrySnapshot},
26};
27use futures::{Stream, StreamExt, stream};
28use serde::{Deserialize, Serialize};
29use std::{collections::VecDeque, pin::Pin, sync::Arc};
30use tracing_futures::Instrument;
31
32use super::{CompletionCall, PromptResponse, forward_prompt_setters};
33use crate::{
34 agent::{Agent, model::ModelHandle},
35 completion::{CompletionError, PromptError},
36};
37use rig_core::message::{Message, Text};
38
39#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
44pub type StreamingResult =
45 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + Send>>;
46
47#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
48pub type StreamingResult = Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>>>>;
49
50#[derive(Deserialize, Serialize, Debug, Clone)]
51#[serde(tag = "type", rename_all = "camelCase")]
52pub enum MultiTurnStreamItem {
53 StreamAssistantItem(StreamedAssistantContent),
69 ToolExecutionCommitted {
79 tool_call: rig_core::message::ToolCall,
85 internal_call_id: String,
89 },
90 StreamUserItem(StreamedUserContent),
96 CompletionCall(CompletionCall),
114 ModelTurnRetried {
121 turn: usize,
123 },
124 FinalResponse(PromptResponse),
127}
128
129fn final_response_from_content(
132 content: Vec<AssistantContent>,
133 aggregated_usage: crate::completion::Usage,
134 completion_calls: Vec<CompletionCall>,
135 history: Option<Vec<Message>>,
136) -> PromptResponse {
137 let mut response = PromptResponse::new(assistant_text_from_choice(&content), aggregated_usage)
138 .with_content(content)
139 .with_completion_calls(completion_calls);
140 response.messages = history;
141 response
142}
143
144impl MultiTurnStreamItem {
145 pub(crate) fn stream_item(item: StreamedAssistantContent) -> Self {
146 Self::StreamAssistantItem(item)
147 }
148
149 pub fn final_response(
154 content: Vec<AssistantContent>,
155 aggregated_usage: crate::completion::Usage,
156 ) -> Self {
157 Self::FinalResponse(final_response_from_content(
158 content,
159 aggregated_usage,
160 Vec::new(),
161 None,
162 ))
163 }
164
165 pub(crate) fn final_response_with_completion_calls(
166 content: Vec<AssistantContent>,
167 aggregated_usage: crate::completion::Usage,
168 completion_calls: Vec<CompletionCall>,
169 history: Option<Vec<Message>>,
170 ) -> Self {
171 Self::FinalResponse(final_response_from_content(
172 content,
173 aggregated_usage,
174 completion_calls,
175 history,
176 ))
177 }
178}
179
180async fn drain_stream_usage(
183 stream: &mut crate::streaming::StreamingCompletionResponse,
184) -> Result<crate::completion::Usage, StreamingError> {
185 while let Some(content) = stream.next().await {
186 match content {
187 Ok(StreamedAssistantContent::Final(final_resp)) => {
188 return Ok(final_resp.usage);
189 }
190 Ok(_) => {}
191 Err(err) => return Err(err.into()),
192 }
193 }
194
195 Ok(crate::completion::Usage::new())
196}
197
198fn finalize_streamed_choice(
212 last_final_choice: &[AssistantContent],
213 output: &str,
214) -> Option<Vec<AssistantContent>> {
215 let finalized_via_output_tool = last_final_choice
216 .iter()
217 .any(|item| matches!(item, AssistantContent::ToolCall(_)));
218 if !finalized_via_output_tool {
219 return None;
220 }
221 let mut items: Vec<AssistantContent> = last_final_choice
222 .iter()
223 .filter(|item| {
224 !matches!(
225 item,
226 AssistantContent::ToolCall(_) | AssistantContent::Text(_)
227 )
228 })
229 .cloned()
230 .collect();
231 items.push(AssistantContent::text(output.to_string()));
233 Some(items)
234}
235
236#[derive(Debug, thiserror::Error)]
237pub enum StreamingError {
238 #[error("CompletionError: {0}")]
239 Completion(#[from] CompletionError),
240 #[error("PromptError: {0}")]
241 Prompt(#[from] Box<PromptError>),
242}
243
244impl From<rig_core::memory::MemoryError> for StreamingError {
245 fn from(err: rig_core::memory::MemoryError) -> Self {
246 Self::Prompt(Box::new(PromptError::MemoryError(err)))
247 }
248}
249
250pub struct StreamingPromptRequest {
258 runner: AgentRunner,
260}
261
262impl StreamingPromptRequest {
263 pub fn new(agent: Arc<Agent>, prompt: impl Into<Message>) -> StreamingPromptRequest {
266 Self::from_agent(agent.as_ref(), prompt)
267 }
268
269 pub fn from_agent(agent: &Agent, prompt: impl Into<Message>) -> StreamingPromptRequest {
272 StreamingPromptRequest {
273 runner: AgentRunner::from_agent(agent, prompt),
274 }
275 }
276
277 pub fn max_turns(mut self, turns: usize) -> Self {
286 self.runner = self.runner.max_turns(turns);
287 self
288 }
289
290 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
298 self.runner = self.runner.tool_concurrency(concurrency);
299 self
300 }
301
302 pub fn add_hook<H>(mut self, hook: H) -> Self
309 where
310 H: AgentHook + 'static,
311 {
312 self.runner = self.runner.add_hook(hook);
313 self
314 }
315
316 forward_prompt_setters!(runner);
317
318 async fn send(self) -> StreamingResult {
319 self.runner.stream().await
320 }
321}
322
323#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
328pub(crate) type DriveStream<'a> =
329 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + Send + 'a>>;
330
331#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
332pub(crate) type DriveStream<'a> =
333 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem, StreamingError>> + 'a>>;
334
335#[allow(clippy::large_enum_variant)]
346pub(crate) enum DriveItem {
347 Item(MultiTurnStreamItem),
351 Done(Box<PromptResponse>),
355}
356
357pub(crate) trait TurnSource: WasmCompatSend {
365 fn open_chat_span(
368 &self,
369 runner: &AgentRunner,
370 effective_preamble: Option<&str>,
371 ) -> tracing::Span;
372
373 #[allow(clippy::too_many_arguments)]
377 fn run_model_turn<'a>(
378 &'a mut self,
379 runner: &'a AgentRunner,
380 hook_ctx: &'a HookContext,
381 run: &'a mut AgentRun,
382 prepared: PreparedCompletionRequest,
383 chat_span: tracing::Span,
384 agent_span: &'a tracing::Span,
385 prompt: Message,
386 ) -> DriveStream<'a>;
387
388 fn run_tool_calls<'a>(
391 &'a self,
392 runner: &'a AgentRunner,
393 hook_ctx: &'a HookContext,
394 run: &'a mut AgentRun,
395 calls: Vec<PendingToolCall>,
396 tool_snapshot: Arc<ToolRegistrySnapshot>,
397 ) -> DriveStream<'a>;
398
399 fn record_run_level_telemetry(
402 &self,
403 agent_span: &tracing::Span,
404 response: &PromptResponse,
405 created_agent_span: bool,
406 );
407
408 fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem>;
411}
412
413pub(crate) fn streaming_error_into_prompt(err: StreamingError) -> PromptError {
417 match err {
418 StreamingError::Completion(err) => PromptError::CompletionError(err),
419 StreamingError::Prompt(err) => *err,
420 }
421}
422
423pub(crate) fn store_error_usage(runner: &AgentRunner, run: &AgentRun) {
424 if let Some(usage) = &runner.error_usage {
425 *usage.lock().unwrap_or_else(|error| error.into_inner()) = run.usage();
426 }
427}
428
429pub(crate) fn drive_agent<S>(
437 runner: AgentRunner,
438 mut source: S,
439 mut run: AgentRun,
440 agent_span: tracing::Span,
441 created_agent_span: bool,
442 memory_handle: Option<(Arc<dyn rig_core::memory::ConversationMemory>, String)>,
443 is_streaming: bool,
444) -> impl Stream<Item = Result<DriveItem, StreamingError>>
445where
446 S: TurnSource,
447{
448 async_stream::stream! {
449 let hook_ctx = HookContext::new(is_streaming, runner.config.name.clone());
453 let mut pending_tool_snapshot: Option<Arc<ToolRegistrySnapshot>> = None;
457 let mut previous_model: Option<ModelHandle> = None;
463
464 macro_rules! drive_step {
469 ($label:lifetime, $step_stream:expr) => {{
470 let mut step_stream = $step_stream;
471 let mut step_error = None;
472 while let Some(item) = step_stream.next().await {
473 match item {
474 Ok(item) => yield Ok(DriveItem::Item(item)),
475 Err(err) => {
476 step_error = Some(err);
477 break;
478 }
479 }
480 }
481 drop(step_stream);
482 if let Some(err) = step_error {
483 store_error_usage(&runner, &run);
484 yield Err(err);
485 break $label;
486 }
487 }};
488 }
489
490 'outer: loop {
491 let step = match run.next_step() {
492 Ok(step) => step,
493 Err(err) => {
494 store_error_usage(&runner, &run);
495 yield Err(Box::new(err).into());
496 break 'outer;
497 }
498 };
499
500 match step {
501 AgentRunStep::CallModel { prompt, history, turn } => {
502 drop(pending_tool_snapshot.take());
503 if runner.config.max_turns > 1 {
504 tracing::info!("Current conversation Turns: {}/{}", turn, runner.config.max_turns);
505 }
506 hook_ctx.set_turn(turn);
507
508 let request_patch =
512 match resolve_completion_call(&runner.config.hooks, &hook_ctx, &prompt, &history, turn).await {
513 CompletionCallOutcome::Terminate(reason) => {
514 store_error_usage(&runner, &run);
515 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
516 break 'outer;
517 }
518 CompletionCallOutcome::Proceed(request_patch) => request_patch,
519 };
520
521 let selected_model = match runner.config.hooks.on_model_select(
527 &hook_ctx,
528 ModelSelection {
529 prompt: &prompt,
530 history: &history,
531 request_patch: request_patch.as_ref(),
532 previous_model: previous_model.as_ref(),
533 default_model: &runner.config.model,
534 selected_model: &runner.config.model,
535 },
536 ) {
537 ModelSelectionAction::Continue => runner.config.model.clone(),
538 ModelSelectionAction::Select(model) => model,
539 ModelSelectionAction::Stop(reason) => {
540 store_error_usage(&runner, &run);
541 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
542 break 'outer;
543 }
544 };
545
546 let effective_preamble = request_patch
551 .as_ref()
552 .and_then(|o| o.preamble.as_deref())
553 .or(runner.config.preamble.as_deref());
554
555 let chat_span = source.open_chat_span(&runner, effective_preamble);
556
557 let committed_output_tool = run.output_tool_name().map(str::to_owned);
560 let mut prepared = match build_prepared_completion_request(
561 &runner,
562 &selected_model,
563 prompt.clone(),
564 &history,
565 committed_output_tool.as_deref(),
566 request_patch.as_ref(),
567 )
568 .await
569 {
570 Ok(prepared) => prepared,
571 Err(err) => {
572 store_error_usage(&runner, &run);
573 yield Err(err.into());
574 break 'outer;
575 }
576 };
577 run.set_output_tool_name(prepared.output_tool_name.clone());
578 let turn_tool_snapshot = prepared.tool_snapshot.clone();
579 if runner.config.record_telemetry_content {
580 let input_messages = prepared.builder.messages_for_telemetry();
581 rig_core::telemetry::record_model_input(&chat_span, &input_messages, true);
582 prepared.builder = prepared.builder.record_content_telemetry(false);
583 }
584
585 previous_model = Some(selected_model);
592
593 drive_step!('outer, source.run_model_turn(
594 &runner,
595 &hook_ctx,
596 &mut run,
597 prepared,
598 chat_span,
599 &agent_span,
600 prompt,
601 ));
602 pending_tool_snapshot = Some(turn_tool_snapshot);
603 }
604 AgentRunStep::CallTools { calls } => {
605 let Some(tool_snapshot) = pending_tool_snapshot.take() else {
606 store_error_usage(&runner, &run);
607 yield Err(StreamingError::Completion(CompletionError::ResponseError(
608 "agent requested tool execution without a prepared registry snapshot"
609 .to_string(),
610 )));
611 break 'outer;
612 };
613 drive_step!('outer, source.run_tool_calls(
614 &runner,
615 &hook_ctx,
616 &mut run,
617 calls,
618 tool_snapshot,
619 ));
620 }
621 AgentRunStep::Done(response) => {
622 tracing::info!(
625 turn = run.turn(),
626 max_turns = runner.config.max_turns,
627 "Agent run finished"
628 );
629 source.record_run_level_telemetry(&agent_span, &response, created_agent_span);
630 append_run_messages(
631 memory_handle.as_ref(),
632 response.messages.as_deref().unwrap_or_default(),
633 )
634 .await;
635 if let Some(final_item) = source.final_item(&response) {
639 yield Ok(DriveItem::Item(final_item));
640 }
641 yield Ok(DriveItem::Done(Box::new(response)));
642 break 'outer;
643 }
644 }
645 }
646 }
647}
648
649pub(crate) fn drive_tool_calls<'a, F>(
673 runner: &'a AgentRunner,
674 hook_ctx: &'a HookContext,
675 run: &'a mut AgentRun,
676 calls: Vec<PendingToolCall>,
677 tool_snapshot: Arc<ToolRegistrySnapshot>,
678 chain_tool_span: F,
679 forward_items: bool,
680) -> DriveStream<'a>
681where
682 F: Fn(tracing::Span) -> tracing::Span + WasmCompatSend + 'a,
683{
684 struct PreparedToolCall {
688 tool_call: rig_core::message::ToolCall,
689 preresolved_result: Option<UserContent>,
690 internal_call_id: String,
691 span: tracing::Span,
692 }
693 enum ToolSurface {
701 Executed(Box<rig_core::message::ToolCall>),
703 Skipped,
704 Preresolved,
705 }
706 struct CollectedToolResult {
709 content: UserContent,
710 internal_call_id: String,
711 surface: ToolSurface,
712 }
713
714 Box::pin(async_stream::stream! {
715 let full_history_for_errors = run.full_history();
716 let call_count = calls.len();
717
718 let mut prepared: Vec<PreparedToolCall> = Vec::with_capacity(call_count);
725 for pending in calls {
726 let internal_call_id = pending.internal_call_id.unwrap_or_else(rig_core::id::generate);
727 let (span, preresolved_result) = match pending.preresolved_result {
728 Some(result) => (tracing::Span::none(), Some(result)),
729 None => {
730 if forward_items {
731 yield Ok(MultiTurnStreamItem::stream_item(
732 StreamedAssistantContent::ToolCall {
733 tool_call: pending.tool_call.clone(),
734 internal_call_id: internal_call_id.clone(),
735 },
736 ));
737 }
738 (chain_tool_span(new_execute_tool_span()), None)
739 }
740 };
741 prepared.push(PreparedToolCall {
742 tool_call: pending.tool_call,
743 preresolved_result,
744 internal_call_id,
745 span,
746 });
747 }
748
749 let mut collected: Vec<Option<CollectedToolResult>> =
755 (0..call_count).map(|_| None).collect();
756 let mut first_error: Option<(usize, PromptError)> = None;
757
758 {
759 let terminating = Arc::new(std::sync::atomic::AtomicBool::new(false));
766 let unordered = stream::iter(prepared.into_iter().enumerate())
767 .map(|(index, call)| {
768 let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
769 let tool_snapshot = &tool_snapshot;
770 let full_history_for_errors = &full_history_for_errors;
771 let terminating = terminating.clone();
772 async move {
773 if let Some(result) = preresolved_result {
774 return (
775 index,
776 Some(Ok(CollectedToolResult {
777 content: result,
778 internal_call_id,
779 surface: ToolSurface::Preresolved,
780 })),
781 );
782 }
783 if terminating.load(std::sync::atomic::Ordering::SeqCst) {
785 return (index, None);
786 }
787 let outcome = run_single_tool(
788 runner,
789 hook_ctx,
790 tool_snapshot,
791 &tool_call,
792 &internal_call_id,
793 full_history_for_errors,
794 )
795 .await;
796 let mapped = outcome.map(|o| {
797 let surface = match o.execution {
798 ToolExecution::Executed(effective) => {
799 ToolSurface::Executed(effective)
800 }
801 ToolExecution::Skipped => ToolSurface::Skipped,
802 };
803 CollectedToolResult {
804 content: o.content,
805 internal_call_id,
806 surface,
807 }
808 });
809 (index, Some(mapped))
810 }
811 .instrument(span)
812 })
813 .buffer_unordered(runner.concurrency.max(1));
814 futures::pin_mut!(unordered);
815
816 while let Some((index, outcome)) = unordered.next().await {
817 let result = match outcome {
819 Some(result) => result,
820 None => continue,
821 };
822 match result {
823 Ok(collected_result) => {
824 if let Some(slot) = collected.get_mut(index) {
825 *slot = Some(collected_result);
826 }
827 }
828 Err(err) => {
829 terminating.store(true, std::sync::atomic::Ordering::SeqCst);
832 if first_error.as_ref().is_none_or(|(i, _)| index < *i) {
833 first_error = Some((index, err));
834 }
835 }
836 }
837 }
838 }
839
840 if let Some((_, err)) = first_error {
843 yield Err(StreamingError::Prompt(Box::new(err)));
844 return;
845 }
846
847 let mut committed: Vec<UserContent> = Vec::with_capacity(call_count);
856 let mut surface_items: Vec<MultiTurnStreamItem> =
857 Vec::with_capacity(call_count.saturating_mul(2));
858 for slot in collected {
859 let CollectedToolResult { content, internal_call_id, surface } = match slot {
860 Some(collected_result) => collected_result,
861 None => {
862 yield Err(StreamingError::Prompt(Box::new(PromptError::CompletionError(
863 CompletionError::ResponseError(
864 "tool execution finished without producing every result".to_string(),
865 ),
866 ))));
867 return;
868 }
869 };
870 if forward_items {
871 let surface_result = match surface {
875 ToolSurface::Executed(tool_call) => {
876 surface_items.push(MultiTurnStreamItem::ToolExecutionCommitted {
877 tool_call: *tool_call,
878 internal_call_id: internal_call_id.clone(),
879 });
880 true
881 }
882 ToolSurface::Skipped => true,
883 ToolSurface::Preresolved => false,
884 };
885 if surface_result
886 && let UserContent::ToolResult(tool_result) = &content
887 {
888 surface_items.push(MultiTurnStreamItem::StreamUserItem(
889 StreamedUserContent::ToolResult {
890 tool_result: tool_result.clone(),
891 internal_call_id,
892 },
893 ));
894 }
895 }
896 committed.push(content);
897 }
898
899 if let Err(err) = run.tool_results(committed) {
900 yield Err(Box::new(err).into());
901 return;
902 }
903
904 for item in surface_items {
905 yield Ok(item);
906 }
907 })
908}
909
910pub(crate) struct StreamingTurnSource {
913 last_final_choice: Vec<AssistantContent>,
916 last_message_id: Option<String>,
917 agent_name: String,
919 created_agent_span: bool,
923 record_telemetry_content: bool,
925 observes_text_delta: bool,
928 observes_reasoning_delta: bool,
929 observes_tool_call_delta: bool,
930 has_hooks: bool,
933}
934
935impl StreamingTurnSource {
936 pub(crate) fn new(
937 hooks: &HookStack,
938 agent_name: String,
939 created_agent_span: bool,
940 record_telemetry_content: bool,
941 ) -> Self {
942 Self {
943 last_final_choice: Vec::new(),
948 last_message_id: None,
949 agent_name,
950 created_agent_span,
951 record_telemetry_content,
952 observes_text_delta: hooks.observes(StepEventKind::TextDelta),
953 observes_reasoning_delta: hooks.observes(StepEventKind::ReasoningDelta),
954 observes_tool_call_delta: hooks.observes(StepEventKind::ToolCallDelta),
955 has_hooks: !hooks.is_empty(),
956 }
957 }
958
959 fn record_turn_telemetry(
963 &self,
964 agent_span: &tracing::Span,
965 chat_span: &tracing::Span,
966 choice: &[AssistantContent],
967 record_content: bool,
968 ) {
969 if self.created_agent_span && self.record_telemetry_content {
970 agent_span.record("gen_ai.completion", assistant_text_from_choice(choice));
971 }
972 rig_core::telemetry::record_model_output(chat_span, choice, record_content);
973 }
974}
975
976impl TurnSource for StreamingTurnSource {
977 fn open_chat_span(
978 &self,
979 runner: &AgentRunner,
980 effective_preamble: Option<&str>,
981 ) -> tracing::Span {
982 build_chat_span!(runner, effective_preamble, "chat_streaming", "chat")
983 }
984
985 fn run_model_turn<'a>(
986 &'a mut self,
987 runner: &'a AgentRunner,
988 hook_ctx: &'a HookContext,
989 run: &'a mut AgentRun,
990 prepared: PreparedCompletionRequest,
991 chat_span: tracing::Span,
992 agent_span: &'a tracing::Span,
993 current_prompt: Message,
994 ) -> DriveStream<'a> {
995 Box::pin(async_stream::stream! {
996 let attempt_max_tokens = prepared.max_tokens;
1001
1002 let mut stream = match prepared
1003 .builder
1004 .stream()
1005 .instrument(chat_span.clone())
1006 .await
1007 {
1008 Ok(stream) => stream,
1009 Err(err) => {
1010 yield Err(err.into());
1011 return;
1012 }
1013 };
1014 let mut last_usage = crate::completion::Usage::new();
1017
1018 let mut assembler = StreamedTurnAssembler::new(
1019 prepared.executable_tool_names.clone(),
1020 prepared.allowed_tool_names.clone(),
1021 );
1022 let mut completion_call_emitted = false;
1023 let mut turn_abandoned = false;
1024 let mut provider_final_seen = false;
1025 let mut pending_final = None;
1026 let mut turn_recovered = false;
1030
1031 macro_rules! emit_completion_call {
1039 ($usage:expr) => {{
1040 let reason = stream
1045 .response
1046 .as_ref()
1047 .and_then(|response| response.finish_reason.clone());
1048 emit_completion_call!($usage, reason)
1049 }};
1050 ($usage:expr, $finish_reason:expr) => {{
1051 let usage = $usage;
1052 last_usage = usage;
1053 if !completion_call_emitted {
1054 chat_span.record_token_usage(&usage);
1055 match run.record_streamed_completion_call(
1061 usage,
1062 stream.identity(),
1063 $finish_reason,
1064 stream
1065 .response
1066 .as_ref()
1067 .map_or(serde_json::Value::Null, |response| response.raw.clone()),
1068 ) {
1069 Ok(call) => {
1070 completion_call_emitted = true;
1071 Ok(Some(MultiTurnStreamItem::CompletionCall(call)))
1072 }
1073 Err(err) => Err(Box::new(err).into()),
1074 }
1075 } else {
1076 Ok(None)
1077 }
1078 }};
1079 }
1080
1081 'turn: while let Some(item) = stream.next().await {
1082 let item = match item {
1083 Ok(item) => item,
1084 Err(err) => {
1085 yield Err(err.into());
1086 return;
1087 }
1088 };
1089 if provider_final_seen {
1090 yield Err(CompletionError::ResponseError(
1091 "provider stream emitted visible assistant content after its final response"
1092 .to_string(),
1093 )
1094 .into());
1095 return;
1096 }
1097 let mut events: VecDeque<StreamedTurnEvent> = match assembler.ingest(&item) {
1098 Ok(events) => events.into(),
1099 Err(err) => {
1100 yield Err(err.into());
1101 return;
1102 }
1103 };
1104 let mut item_slot = Some(item);
1107 while let Some(event) = events.pop_front() {
1108 match event {
1109 StreamedTurnEvent::EmitIngested => {
1110 if self.observes_text_delta
1111 && let Some(StreamedAssistantContent::Text(text)) =
1112 item_slot.as_ref()
1113 && let Some(reason) = observe_action(
1114 runner
1115 .config.hooks
1116 .on_text_delta(
1117 hook_ctx,
1118 TextDelta {
1119 delta: &text.text,
1120 aggregated: assembler.aggregated_text(),
1121 },
1122 )
1123 .await,
1124 )
1125 {
1126 yield Err(StreamingError::Prompt(Box::new(
1127 run.cancel_error(reason),
1128 )));
1129 return;
1130 }
1131 if self.observes_reasoning_delta
1132 && let Some(StreamedAssistantContent::ReasoningDelta {
1133 id,
1134 provider_id,
1135 reasoning,
1136 }) = item_slot.as_ref()
1137 {
1138 let Some(aggregated) = assembler.aggregated_reasoning(id) else {
1139 yield Err(CompletionError::ResponseError(format!(
1140 "reasoning delta `{id}` was ingested without a pending aggregate"
1141 ))
1142 .into());
1143 return;
1144 };
1145 if let Some(reason) = observe_action(
1146 runner
1147 .config.hooks
1148 .on_reasoning_delta(
1149 hook_ctx,
1150 ReasoningDelta {
1151 id,
1152 provider_id: provider_id.as_deref(),
1153 delta: reasoning,
1154 aggregated,
1155 },
1156 )
1157 .await,
1158 ) {
1159 yield Err(StreamingError::Prompt(Box::new(
1160 run.cancel_error(reason),
1161 )));
1162 return;
1163 }
1164 }
1165 if let Some(item) = item_slot.take() {
1166 yield Ok(MultiTurnStreamItem::stream_item(item));
1167 }
1168 }
1169 StreamedTurnEvent::EmitToolCallDelta {
1170 internal_call_id,
1171 content,
1172 } => {
1173 if self.observes_tool_call_delta {
1174 let (delta_name, delta_text) = match &content {
1175 ToolCallDeltaContent::Name(name) => (Some(name.as_str()), ""),
1176 ToolCallDeltaContent::Delta(delta) => (None, delta.as_str()),
1177 };
1178 if let Some(reason) = observe_action(
1179 runner
1180 .config.hooks
1181 .on_tool_call_delta(
1182 hook_ctx,
1183 ToolCallDelta {
1184 internal_call_id: &internal_call_id,
1185 tool_name: delta_name,
1186 delta: delta_text,
1187 },
1188 )
1189 .await,
1190 ) {
1191 yield Err(StreamingError::Prompt(Box::new(
1192 run.cancel_error(reason),
1193 )));
1194 return;
1195 }
1196 }
1197
1198 yield Ok(MultiTurnStreamItem::StreamAssistantItem(
1199 StreamedAssistantContent::ToolCallDelta {
1200 internal_call_id,
1201 content,
1202 },
1203 ));
1204 }
1205 StreamedTurnEvent::Completed {
1206 usage,
1207 emit_final,
1208 finish_reason,
1209 } => {
1210 match emit_completion_call!(usage, finish_reason) {
1211 Ok(Some(item)) => yield Ok(item),
1212 Ok(None) => {}
1213 Err(err) => {
1214 yield Err(err);
1215 return;
1216 }
1217 }
1218 provider_final_seen = true;
1219
1220 if emit_final
1221 && matches!(
1222 item_slot.as_ref(),
1223 Some(StreamedAssistantContent::Final(_))
1224 )
1225 {
1226 pending_final = item_slot.take();
1227 }
1228 }
1229 StreamedTurnEvent::InvalidToolCall(invalid) => {
1230 let partial = assembler.partial_turn(stream.message_id.clone());
1231 let action = if self.has_hooks {
1235 let context =
1236 run.streamed_invalid_tool_call_context(&partial, &invalid);
1237 runner
1238 .config.hooks
1239 .on_invalid_tool_call(hook_ctx, &context)
1240 .await
1241 .unwrap_or_else(InvalidToolCallAction::fail)
1242 } else {
1243 InvalidToolCallAction::fail()
1244 };
1245
1246 let resolution =
1247 match run.resolve_streamed_invalid_tool_call(&partial, &invalid, action) {
1248 Ok(resolution) => resolution,
1249 Err(err) => {
1250 yield Err(Box::new(err).into());
1251 return;
1252 }
1253 };
1254
1255 match resolution {
1256 StreamedResolution::Repaired { .. } => {
1257 turn_recovered = true;
1261 events.extend(assembler.resolve_pending_invalid(&resolution));
1262 }
1263 StreamedResolution::TurnAbandoned {
1264 ref skipped_tool_result,
1265 } => {
1266 let skipped_tool_result = skipped_tool_result.clone();
1267 assembler.resolve_pending_invalid(&resolution);
1268
1269 if let Some(err) = assembler.pending_delta_error() {
1270 yield Err(err.into());
1271 return;
1272 }
1273 let drained_usage = match drain_stream_usage(&mut stream).await {
1274 Ok(usage) => usage,
1275 Err(err) => {
1276 yield Err(err);
1277 return;
1278 }
1279 };
1280 match emit_completion_call!(drained_usage) {
1281 Ok(Some(item)) => yield Ok(item),
1282 Ok(None) => {}
1283 Err(err) => {
1284 yield Err(err);
1285 return;
1286 }
1287 }
1288 if let Some(tool_result) = skipped_tool_result {
1289 yield Ok(MultiTurnStreamItem::StreamUserItem(
1290 StreamedUserContent::ToolResult {
1291 tool_result: *tool_result,
1292 internal_call_id: invalid.internal_call_id.clone(),
1293 },
1294 ));
1295 }
1296 turn_abandoned = true;
1297 break 'turn;
1298 }
1299 }
1300 }
1301 }
1302 }
1303 }
1304
1305 if turn_abandoned {
1306 return;
1307 }
1308
1309 if !provider_final_seen {
1315 yield Err(CompletionError::ResponseError(
1316 "provider stream ended without a terminal record; treating the turn as truncated"
1317 .to_string(),
1318 )
1319 .into());
1320 return;
1321 }
1322
1323 if let Some(err) = assembler.pending_delta_error() {
1324 yield Err(err.into());
1325 return;
1326 }
1327
1328 if !completion_call_emitted {
1336 let fallback_finish_reason = stream
1337 .response
1338 .as_ref()
1339 .and_then(|response| response.finish_reason.clone());
1340 match run.record_streamed_completion_call(
1341 crate::completion::Usage::new(),
1342 stream.identity(),
1343 fallback_finish_reason,
1344 stream
1345 .response
1346 .as_ref()
1347 .map_or(serde_json::Value::Null, |response| response.raw.clone()),
1348 ) {
1349 Ok(call) => yield Ok(MultiTurnStreamItem::CompletionCall(call)),
1350 Err(err) => {
1351 yield Err(Box::new(err).into());
1352 return;
1353 }
1354 }
1355 }
1356
1357 let final_turn_content = stream.choice.clone();
1358 let streamed_turn = assembler.finish(stream.message_id.clone(), &final_turn_content);
1359 let identity = rig_core::completion::ResponseIdentity {
1365 message_id: streamed_turn.message_id.clone(),
1366 ..stream.identity()
1367 };
1368 let attempt_raw = stream
1372 .response
1373 .as_ref()
1374 .map_or(&serde_json::Value::Null, |response| &response.raw);
1375 if pending_final.is_some()
1376 && !turn_recovered
1377 && let Some(reason) = observe_action(
1378 runner
1379 .config.hooks
1380 .on_stream_response_finish(
1381 hook_ctx,
1382 StreamResponseFinish {
1383 prompt: ¤t_prompt,
1384 content: &streamed_turn.choice,
1385 usage: last_usage,
1386 message_id: streamed_turn.message_id.as_deref(),
1387 identity: &identity,
1388 raw: attempt_raw,
1389 },
1390 )
1391 .await,
1392 )
1393 {
1394 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1395 return;
1396 }
1397 self.last_message_id = streamed_turn.message_id.clone();
1398 let canonical_choice = streamed_turn.choice.clone();
1405 let attempt_finish_reason = streamed_turn.finish_reason.clone();
1410 if let Err(err) = run.streamed_turn(streamed_turn) {
1411 yield Err(Box::new(err).into());
1412 return;
1413 }
1414 if !turn_recovered {
1420 let action = runner
1421 .config.hooks
1422 .on_model_turn_finished(
1423 hook_ctx,
1424 ModelTurnFinished {
1425 turn: hook_ctx.turn(),
1426 content: &canonical_choice,
1427 usage: last_usage,
1428 identity: &identity,
1429 finish_reason: attempt_finish_reason.as_ref(),
1430 max_tokens: attempt_max_tokens,
1431 raw: attempt_raw,
1432 },
1433 )
1434 .await;
1435 match resolve_model_turn_action(run, action) {
1436 Ok(ModelTurnDecision::Advance) => {}
1437 Ok(ModelTurnDecision::Retried) => {
1438 yield Ok(MultiTurnStreamItem::ModelTurnRetried {
1439 turn: hook_ctx.turn(),
1440 });
1441 return;
1442 }
1443 Ok(ModelTurnDecision::Terminate(reason)) => {
1444 self.record_turn_telemetry(
1450 agent_span,
1451 &chat_span,
1452 &canonical_choice,
1453 runner.config.record_telemetry_content,
1454 );
1455 if let Some(item) = pending_final.take() {
1456 yield Ok(MultiTurnStreamItem::stream_item(item));
1457 }
1458 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1459 return;
1460 }
1461 Err(err) => {
1462 yield Err(StreamingError::Prompt(Box::new(err)));
1463 return;
1464 }
1465 }
1466 }
1467
1468 self.record_turn_telemetry(
1471 agent_span,
1472 &chat_span,
1473 &canonical_choice,
1474 runner.config.record_telemetry_content,
1475 );
1476
1477 if let Some(item) = pending_final {
1478 yield Ok(MultiTurnStreamItem::stream_item(item));
1479 }
1480 self.last_final_choice = final_turn_content;
1481 })
1482 }
1483
1484 fn run_tool_calls<'a>(
1485 &'a self,
1486 runner: &'a AgentRunner,
1487 hook_ctx: &'a HookContext,
1488 run: &'a mut AgentRun,
1489 calls: Vec<PendingToolCall>,
1490 tool_snapshot: Arc<ToolRegistrySnapshot>,
1491 ) -> DriveStream<'a> {
1492 drive_tool_calls(
1495 runner,
1496 hook_ctx,
1497 run,
1498 calls,
1499 tool_snapshot,
1500 |span| span,
1501 true,
1502 )
1503 }
1504
1505 fn record_run_level_telemetry(
1506 &self,
1507 agent_span: &tracing::Span,
1508 response: &PromptResponse,
1509 created_agent_span: bool,
1510 ) {
1511 if created_agent_span {
1512 agent_span.record_token_usage(&response.usage);
1513 }
1514 }
1515
1516 fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem> {
1517 let final_choice = finalize_streamed_choice(&self.last_final_choice, &response.output)
1520 .unwrap_or_else(|| {
1521 if is_empty_assistant_turn(&self.last_final_choice) {
1522 tracing::warn!(
1523 agent_name = self.agent_name.as_str(),
1524 message_id = ?self.last_message_id,
1525 "Streaming turn completed without assistant text; final response will be empty"
1526 );
1527 }
1528 self.last_final_choice.clone()
1529 });
1530 let final_messages: Option<Vec<Message>> =
1533 Some(response.messages.clone().unwrap_or_default());
1534 Some(MultiTurnStreamItem::final_response_with_completion_calls(
1535 final_choice,
1536 response.usage,
1537 response.completion_calls.clone(),
1538 final_messages,
1539 ))
1540 }
1541}
1542
1543impl AgentRunner {
1544 pub async fn stream(self) -> StreamingResult {
1554 let (agent_span, created_agent_span) = self.open_agent_span();
1555
1556 let (history_override, memory_handle) = match self.resolve_history_and_memory().await {
1557 Ok(resolved) => resolved,
1558 Err(err) => {
1559 let stream = async_stream::stream! {
1560 yield Err(StreamingError::from(err));
1561 };
1562 return Box::pin(stream.instrument(agent_span));
1565 }
1566 };
1567
1568 let run = self.build_run(history_override);
1569 let source = StreamingTurnSource::new(
1570 &self.config.hooks,
1571 self.agent_name_or_default().to_string(),
1572 created_agent_span,
1573 self.config.record_telemetry_content,
1574 );
1575
1576 let driver = drive_agent(
1580 self,
1581 source,
1582 run,
1583 agent_span.clone(),
1584 created_agent_span,
1585 memory_handle,
1586 true,
1587 )
1588 .filter_map(|item| {
1589 std::future::ready(match item {
1590 Ok(DriveItem::Item(item)) => Some(Ok(item)),
1591 Ok(DriveItem::Done(_)) => None,
1592 Err(err) => Some(Err(err)),
1593 })
1594 });
1595
1596 Box::pin(driver.instrument(agent_span))
1597 }
1598}
1599
1600impl IntoFuture for StreamingPromptRequest {
1601 type Output = StreamingResult; type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
1603
1604 fn into_future(self) -> Self::IntoFuture {
1605 Box::pin(async move { self.send().await })
1607 }
1608}
1609
1610pub async fn stream_to_stdout(
1618 stream: &mut StreamingResult,
1619) -> Result<PromptResponse, std::io::Error> {
1620 let mut final_res = PromptResponse::empty();
1621 print!("Response: ");
1622 while let Some(content) = stream.next().await {
1623 match content {
1624 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
1625 Text { text, .. },
1626 ))) => {
1627 print!("{text}");
1628 std::io::Write::flush(&mut std::io::stdout())?;
1629 }
1630 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning {
1631 reasoning,
1632 ..
1633 })) => {
1634 let reasoning = reasoning.display_text();
1635 print!("{reasoning}");
1636 std::io::Write::flush(&mut std::io::stdout())?;
1637 }
1638 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
1639 final_res = res;
1640 }
1641 Ok(MultiTurnStreamItem::ModelTurnRetried { turn }) => {
1642 print!("\n[model turn {turn} rejected; retry requested]\nResponse: ");
1643 std::io::Write::flush(&mut std::io::stdout())?;
1644 }
1645 Err(err) => {
1646 eprintln!("Error: {err}");
1647 }
1648 _ => {}
1649 }
1650 }
1651
1652 Ok(final_res)
1653}
1654
1655#[cfg(test)]
1656#[allow(irrefutable_let_patterns, unreachable_patterns)]
1657mod migrated_tests {
1658 use crate::agent::{
1659 InvalidToolCallAction, InvalidToolCallContext, ModelTurnAction, ModelTurnFinished,
1660 ObservationAction, ReasoningDelta, StepEventKind, StreamResponseFinish, TextDelta,
1661 ToolCall, ToolCallAction, ToolCallDelta,
1662 };
1663
1664 use super::*;
1665 use crate::agent::AgentBuilder;
1666 use crate::agent::hook::{AgentHook, HookContext};
1667 use crate::agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_output};
1668 use crate::client::AgentClientExt;
1669 use crate::completion::{
1670 CompletionRequest, FinishReason, Prompt, PromptError, ToolDefinition, Usage,
1671 };
1672 use crate::streaming::{StreamingPrompt, ToolCallDeltaContent};
1673 use crate::test_utils::{
1674 AppendFailingMemory, FailingMemory, MockAddTool, MockBarrierTool, MockCompletionModel,
1675 MockContextProbeTool, MockStreamEvent, MockSubtractTool, MockToolError, MockTurn,
1676 SessionId, mock_final,
1677 };
1678 use crate::tool::{Tool, ToolContext};
1679 use futures::{StreamExt, TryStreamExt};
1680 use rig_core::client::ProviderClient;
1681 use rig_core::message::{
1682 AssistantContent, DocumentSourceKind, ImageMediaType, Message, ReasoningContent,
1683 ToolChoice, ToolResultContent, UserContent,
1684 };
1685 use rig_core::providers::anthropic;
1686 use serde::Deserialize;
1687 use std::collections::{BTreeSet, HashMap};
1688 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1689 use std::sync::{Arc, Mutex};
1690 use std::time::Duration;
1691 use tracing::field::{Field, Visit};
1692 use tracing::{Id, Subscriber};
1693 use tracing_subscriber::layer::{Context, SubscriberExt};
1694 use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
1695
1696 struct StopAgentStreamingBeforeCompletion;
1697
1698 impl AgentHook for StopAgentStreamingBeforeCompletion {
1699 async fn on_completion_call(
1700 &self,
1701 _ctx: &HookContext,
1702 _event: crate::agent::CompletionCallEvent<'_>,
1703 ) -> crate::agent::CompletionCallAction {
1704 crate::agent::CompletionCallAction::stop("agent streaming stopped")
1705 }
1706 }
1707
1708 #[tokio::test]
1709 async fn public_streaming_request_constructor_preserves_agent_hooks() {
1710 let model = MockCompletionModel::from_stream_turns([[
1711 MockStreamEvent::text("should not run"),
1712 MockStreamEvent::final_response(Usage::new()),
1713 ]]);
1714 let agent = Arc::new(
1715 AgentBuilder::new(model.clone())
1716 .add_hook(StopAgentStreamingBeforeCompletion)
1717 .build(),
1718 );
1719
1720 let mut stream = StreamingPromptRequest::new(agent, "go").await;
1721 let error = stream
1722 .try_next()
1723 .await
1724 .expect_err("the configured agent hook should terminate the stream");
1725
1726 assert!(matches!(
1727 error,
1728 StreamingError::Prompt(error)
1729 if matches!(*error, PromptError::PromptCancelled { ref reason, .. }
1730 if reason == "agent streaming stopped")
1731 ));
1732 assert_eq!(model.request_count(), 0);
1733 }
1734
1735 #[tokio::test]
1736 async fn text_only_stream_without_terminal_record_is_rejected_as_truncated() {
1737 let model =
1738 MockCompletionModel::from_stream_turns([[MockStreamEvent::text("partial answer")]]);
1739 let agent = Arc::new(AgentBuilder::new(model.clone()).build());
1740
1741 let mut stream = StreamingPromptRequest::new(agent, "go").await;
1742 let mut saw_error = false;
1743 let mut saw_completion_call = false;
1744 while let Some(item) = stream.next().await {
1745 match item {
1746 Err(error) => {
1747 assert!(
1748 error.to_string().contains("terminal record"),
1749 "truncation should surface as a terminal-record error, got: {error}"
1750 );
1751 saw_error = true;
1752 break;
1753 }
1754 Ok(MultiTurnStreamItem::CompletionCall(_)) => saw_completion_call = true,
1755 Ok(_) => {}
1756 }
1757 }
1758 assert!(
1759 saw_error,
1760 "a stream ending without a terminal record must be rejected, not \
1761 treated as a successful completion"
1762 );
1763 assert!(
1767 !saw_completion_call,
1768 "no completion call may be recorded for a truncated stream"
1769 );
1770 }
1771
1772 #[tokio::test]
1773 async fn tool_call_stream_without_terminal_record_dispatches_no_tools() {
1774 let calls = Arc::new(AtomicU32::new(0));
1775 let add_tool = CountingAddTool {
1776 calls: calls.clone(),
1777 };
1778 let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::tool_call(
1779 "tool_call_1",
1780 "add",
1781 serde_json::json!({"x": 1, "y": 2}),
1782 )]]);
1783 let agent = AgentBuilder::new(model.clone()).tool(add_tool).build();
1784
1785 let mut stream = agent.stream_prompt("go").max_turns(3).await;
1786 let mut saw_error = false;
1787 while let Some(item) = stream.next().await {
1788 if item.is_err() {
1789 saw_error = true;
1790 break;
1791 }
1792 }
1793 assert!(
1794 saw_error,
1795 "a truncated tool-call turn must error rather than complete"
1796 );
1797 assert_eq!(
1798 calls.load(Ordering::SeqCst),
1799 0,
1800 "a tool call from a stream the provider never confirmed complete \
1801 must not be dispatched"
1802 );
1803 }
1804
1805 #[test]
1806 fn finalize_streamed_choice_surfaces_output_over_tool_call_and_prose() {
1807 use rig_core::message::{ToolCall, ToolFunction};
1808
1809 let output_call = AssistantContent::ToolCall(ToolCall::from_wire(
1810 "c1",
1811 ToolFunction::new(
1812 "final_result".to_string(),
1813 serde_json::json!({"city": "Tokyo"}),
1814 ),
1815 ));
1816
1817 let with_prose = vec![
1820 AssistantContent::text("Sure, here is the weather:"),
1821 output_call.clone(),
1822 ];
1823 let final_choice = finalize_streamed_choice(&with_prose, r#"{"city":"Tokyo"}"#)
1824 .expect("a turn with the output-tool call is finalized via it");
1825 assert_eq!(
1826 assistant_text_from_choice(&final_choice),
1827 r#"{"city":"Tokyo"}"#
1828 );
1829 assert!(
1830 !final_choice
1831 .iter()
1832 .any(|item| matches!(item, AssistantContent::ToolCall(_))),
1833 "no unanswered tool_use should remain in the final content"
1834 );
1835
1836 let only_call = vec![output_call];
1838 let final_choice = finalize_streamed_choice(&only_call, r#"{"city":"Tokyo"}"#)
1839 .expect("finalized via output tool");
1840 assert_eq!(
1841 assistant_text_from_choice(&final_choice),
1842 r#"{"city":"Tokyo"}"#
1843 );
1844
1845 let text_only = vec![AssistantContent::text(r#"{"city":"Tokyo"}"#)];
1847 assert!(finalize_streamed_choice(&text_only, r#"{"city":"Tokyo"}"#).is_none());
1848 }
1849
1850 #[test]
1851 fn tool_result_output_preserves_multimodal_tool_output() {
1852 let instruction = serde_json::json!({
1853 "instruction": "Use the image part to answer."
1854 });
1855 let mut content = vec![ToolResultContent::json(instruction.clone())];
1856 content.push(ToolResultContent::image_base64(
1857 "base64data==",
1858 Some(ImageMediaType::PNG),
1859 None,
1860 ));
1861 let user_content = tool_result_output(
1862 rig_core::message::ToolCallId::new_or_mint("tool_call_1"),
1863 rig_core::message::ProviderCallId::new("call_1"),
1864 "render_reference_image".to_string(),
1865 crate::tool::ToolOutput::content(content).expect("fixture content is non-empty"),
1866 );
1867
1868 let tool_result = match user_content {
1869 UserContent::ToolResult(tool_result) => tool_result,
1870 other => panic!("expected tool result content, got {other:?}"),
1871 };
1872
1873 assert_eq!(tool_result.call, "tool_call_1");
1874 assert_eq!(
1875 tool_result
1876 .provider
1877 .as_ref()
1878 .map(|provider| provider.call_id.as_str()),
1879 Some("call_1")
1880 );
1881 assert_eq!(tool_result.content.len(), 2);
1882
1883 let mut items = tool_result.content.iter();
1884 match items.next() {
1885 Some(ToolResultContent::Json { value }) => {
1886 assert_eq!(value, &instruction);
1887 }
1888 other => panic!("expected structured JSON payload first, got {other:?}"),
1889 }
1890
1891 match items.next() {
1892 Some(ToolResultContent::Image(image)) => {
1893 assert_eq!(image.media_type, Some(ImageMediaType::PNG));
1894 assert!(matches!(
1895 image.data,
1896 DocumentSourceKind::Base64(ref data) if data == "base64data=="
1897 ));
1898 }
1899 other => panic!("expected image payload second, got {other:?}"),
1900 }
1901 }
1902
1903 fn validate_follow_up_tool_history(request: &CompletionRequest) -> Result<(), String> {
1904 let history = request.chat_history.clone();
1905 if history.len() != 3 {
1906 return Err(format!(
1907 "follow-up request should contain [original user prompt, assistant tool call, user tool result]: {history:?}"
1908 ));
1909 }
1910
1911 if !matches!(
1912 history.first(),
1913 Some(Message::User { content })
1914 if matches!(
1915 content.first(),
1916 Some(UserContent::Text(text)) if text.text == "do tool work"
1917 )
1918 ) {
1919 return Err(format!(
1920 "follow-up request should begin with the original user prompt: {history:?}"
1921 ));
1922 }
1923
1924 if !matches!(
1928 history.get(1),
1929 Some(Message::Assistant { content, .. })
1930 if matches!(
1931 content.first(),
1932 Some(AssistantContent::ToolCall(tool_call))
1933 if tool_call.id == "call_1"
1934 && tool_call.provider.as_ref().is_some_and(|provider| {
1935 provider.call_id == "call_1"
1936 && provider.item_id.as_deref() == Some("tool_call_1")
1937 })
1938 )
1939 ) {
1940 return Err(format!(
1941 "follow-up request is missing the assistant tool call in position 2: {history:?}"
1942 ));
1943 }
1944
1945 if !matches!(
1946 history.get(2),
1947 Some(Message::User { content })
1948 if matches!(
1949 content.first(),
1950 Some(UserContent::ToolResult(tool_result))
1951 if tool_result.call == "call_1"
1952 && tool_result.provider.as_ref().is_some_and(|provider| {
1953 provider.call_id == "call_1"
1954 && provider.item_id.as_deref() == Some("tool_call_1")
1955 })
1956 )
1957 ) {
1958 return Err(format!(
1959 "follow-up request should end with the user tool result: {history:?}"
1960 ));
1961 }
1962
1963 Ok(())
1964 }
1965
1966 fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1967 history.iter().any(|message| {
1968 matches!(
1969 message,
1970 Message::Assistant { content, .. }
1971 if content.iter().any(|item| matches!(
1972 item,
1973 AssistantContent::ToolCall(tool_call)
1974 if tool_call.function.name == tool_name
1975 ))
1976 )
1977 })
1978 }
1979
1980 fn assert_retry_transcript_ids_pair(assistant: &Message, results: &Message) {
1985 let Message::Assistant { content, .. } = assistant else {
1986 panic!("expected the assistant tool-call turn, got {assistant:?}");
1987 };
1988 let call_ids: Vec<&str> = content
1989 .iter()
1990 .filter_map(|item| match item {
1991 AssistantContent::ToolCall(tool_call) => Some(tool_call.id.as_str()),
1992 _ => None,
1993 })
1994 .collect();
1995 let Message::User { content } = results else {
1996 panic!("expected the user retry-result turn, got {results:?}");
1997 };
1998 let result_ids: Vec<&str> = content
1999 .iter()
2000 .filter_map(|item| match item {
2001 UserContent::ToolResult(result) => Some(result.call.as_str()),
2002 _ => None,
2003 })
2004 .collect();
2005 assert!(
2006 call_ids.iter().all(|id| !id.is_empty()),
2007 "every tool call carries a non-empty id: {call_ids:?}"
2008 );
2009 let unique_calls: BTreeSet<&str> = call_ids.iter().copied().collect();
2010 assert_eq!(
2011 unique_calls.len(),
2012 call_ids.len(),
2013 "tool-call ids must be unique: {call_ids:?}"
2014 );
2015 let unique_results: BTreeSet<&str> = result_ids.iter().copied().collect();
2016 assert_eq!(
2017 unique_results.len(),
2018 result_ids.len(),
2019 "retry-result ids must be unique: {result_ids:?}"
2020 );
2021 assert_eq!(
2022 unique_calls, unique_results,
2023 "retry results must answer exactly the turn's tool calls"
2024 );
2025 }
2026
2027 fn history_contains_text(history: &[Message], expected: &str) -> bool {
2028 history.iter().any(|message| {
2029 matches!(
2030 message,
2031 Message::Assistant { content, .. }
2032 if content.iter().any(|item| matches!(
2033 item,
2034 AssistantContent::Text(text) if text.text == expected
2035 ))
2036 )
2037 })
2038 }
2039
2040 fn assistant_reasoning_precedes_tool_call(
2041 history: &[Message],
2042 expected_reasoning: &str,
2043 tool_name: &str,
2044 ) -> bool {
2045 history.iter().any(|message| {
2046 let Message::Assistant { content, .. } = message else {
2047 return false;
2048 };
2049
2050 let reasoning_index = content.iter().position(|item| {
2051 matches!(
2052 item,
2053 AssistantContent::Reasoning(reasoning)
2054 if reasoning.content.iter().any(|content| matches!(
2055 content,
2056 ReasoningContent::Text { text, .. }
2057 if text == expected_reasoning
2058 ))
2059 )
2060 });
2061 let tool_index = content.iter().position(|item| {
2062 matches!(
2063 item,
2064 AssistantContent::ToolCall(tool_call)
2065 if tool_call.function.name == tool_name
2066 )
2067 });
2068
2069 matches!((reasoning_index, tool_index), (Some(reasoning), Some(tool)) if reasoning < tool)
2070 })
2071 }
2072
2073 fn assistant_reasoning_precedes_text_and_tool_call(
2074 history: &[Message],
2075 expected_reasoning: &str,
2076 expected_text: &str,
2077 tool_name: &str,
2078 ) -> bool {
2079 history.iter().any(|message| {
2080 let Message::Assistant { content, .. } = message else {
2081 return false;
2082 };
2083
2084 let reasoning_index = content.iter().position(|item| {
2085 matches!(
2086 item,
2087 AssistantContent::Reasoning(reasoning)
2088 if reasoning.content.iter().any(|content| matches!(
2089 content,
2090 ReasoningContent::Text { text, .. }
2091 if text == expected_reasoning
2092 ))
2093 )
2094 });
2095 let text_index = content.iter().position(|item| {
2096 matches!(
2097 item,
2098 AssistantContent::Text(text) if text.text == expected_text
2099 )
2100 });
2101 let tool_index = content.iter().position(|item| {
2102 matches!(
2103 item,
2104 AssistantContent::ToolCall(tool_call)
2105 if tool_call.function.name == tool_name
2106 )
2107 });
2108
2109 matches!(
2110 (reasoning_index, text_index, tool_index),
2111 (Some(reasoning), Some(text), Some(tool))
2112 if reasoning < text && text < tool
2113 )
2114 })
2115 }
2116
2117 #[derive(Clone)]
2118 struct PanicOnUnknownToolHook;
2119
2120 impl AgentHook for PanicOnUnknownToolHook {
2121 async fn on_tool_call_delta(
2122 &self,
2123 _: &HookContext,
2124 _: ToolCallDelta<'_>,
2125 ) -> ObservationAction {
2126 panic!("unknown tool call delta should fail before delta hooks run")
2127 }
2128 async fn on_tool_call(&self, _: &HookContext, _: ToolCall<'_>) -> ToolCallAction {
2129 panic!("unknown tool call should fail before tool hooks run")
2130 }
2131 async fn on_stream_response_finish(
2132 &self,
2133 _: &HookContext,
2134 _: StreamResponseFinish<'_>,
2135 ) -> ObservationAction {
2136 panic!("unknown tool call should fail before stream finish hooks run")
2137 }
2138 }
2139
2140 #[derive(Clone)]
2141 struct CountingAddTool {
2142 calls: Arc<AtomicU32>,
2143 }
2144
2145 #[derive(Clone)]
2146 struct CountingSubtractTool {
2147 calls: Arc<AtomicU32>,
2148 }
2149
2150 #[derive(Deserialize)]
2151 struct CountingOperationArgs {
2152 x: i32,
2153 y: i32,
2154 }
2155
2156 fn arithmetic_tool_definition(name: &str, description: &str) -> ToolDefinition {
2157 ToolDefinition {
2158 name: name.to_string(),
2159 description: description.to_string(),
2160 parameters: serde_json::json!({
2161 "type": "object",
2162 "properties": {
2163 "x": {
2164 "type": "number",
2165 "description": "The first operand"
2166 },
2167 "y": {
2168 "type": "number",
2169 "description": "The second operand"
2170 }
2171 },
2172 "required": ["x", "y"],
2173 }),
2174 }
2175 }
2176
2177 impl Tool for CountingAddTool {
2178 const NAME: &'static str = "add";
2179 type Error = MockToolError;
2180 type Args = CountingOperationArgs;
2181 type Output = i32;
2182
2183 fn description(&self) -> String {
2184 "Add x and y together".to_string()
2185 }
2186
2187 fn parameters(&self) -> serde_json::Value {
2188 arithmetic_tool_definition(Self::NAME, "Add x and y together").parameters
2189 }
2190
2191 async fn call(
2192 &self,
2193 _context: &mut ToolContext,
2194 args: Self::Args,
2195 ) -> Result<Self::Output, Self::Error> {
2196 self.calls.fetch_add(1, Ordering::SeqCst);
2197 Ok(args.x + args.y)
2198 }
2199 }
2200
2201 impl Tool for CountingSubtractTool {
2202 const NAME: &'static str = "subtract";
2203 type Error = MockToolError;
2204 type Args = CountingOperationArgs;
2205 type Output = i32;
2206
2207 fn description(&self) -> String {
2208 "Subtract y from x".to_string()
2209 }
2210
2211 fn parameters(&self) -> serde_json::Value {
2212 arithmetic_tool_definition(Self::NAME, "Subtract y from x").parameters
2213 }
2214
2215 async fn call(
2216 &self,
2217 _context: &mut ToolContext,
2218 args: Self::Args,
2219 ) -> Result<Self::Output, Self::Error> {
2220 self.calls.fetch_add(1, Ordering::SeqCst);
2221 Ok(args.x - args.y)
2222 }
2223 }
2224
2225 fn streaming_tool_then_text_model() -> MockCompletionModel {
2226 MockCompletionModel::from_stream_turns([
2227 vec![
2228 MockStreamEvent::tool_call(
2229 "tool_call_1",
2230 "add",
2231 serde_json::json!({"x": 1, "y": 2}),
2232 )
2233 .with_call_id("call_1"),
2234 MockStreamEvent::final_response_with_total_tokens(4),
2235 ],
2236 vec![
2237 MockStreamEvent::text("done"),
2238 MockStreamEvent::final_response_with_total_tokens(6),
2239 ],
2240 ])
2241 }
2242
2243 fn streamed_call(call_index: usize, usage: Usage) -> CompletionCall {
2249 let terminal = mock_final(usage);
2250 CompletionCall::new(call_index, usage)
2251 .with_raw(serde_json::to_value(&terminal).expect("mock terminal serializes"))
2252 }
2253
2254 fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
2255 Usage {
2256 input_tokens,
2257 output_tokens,
2258 total_tokens: input_tokens + output_tokens,
2259 cached_input_tokens: 0,
2260 cache_creation_input_tokens: 0,
2261 tool_use_prompt_tokens: 0,
2262 reasoning_tokens: 0,
2263 }
2264 }
2265
2266 #[tokio::test]
2267 async fn execution_commit_items_are_not_emitted_when_run_commit_fails() {
2268 let runner = AgentBuilder::new(MockCompletionModel::default())
2269 .build()
2270 .runner("go");
2271 let tool_snapshot = Arc::new(
2272 runner
2273 .tool_server_handle
2274 .snapshot_tool_defs(None)
2275 .await
2276 .expect("empty tool snapshot should build"),
2277 );
2278
2279 let mut run = AgentRun::new("go").max_turns(2);
2280 assert!(matches!(
2281 run.next_step().expect("initial model step"),
2282 AgentRunStep::CallModel { .. }
2283 ));
2284
2285 let tool_name = "missing".to_string();
2286 let advertised = BTreeSet::from([tool_name.clone()]);
2287 let turn = crate::agent::run::ModelTurn::new(
2288 None,
2289 vec![AssistantContent::ToolCall(
2290 rig_core::message::ToolCall::new(
2291 rig_core::message::ToolCallId::new_or_mint("expected_call"),
2292 rig_core::message::ToolFunction::new(tool_name, serde_json::json!({})),
2293 ),
2294 )],
2295 Usage::new(),
2296 advertised.clone(),
2297 advertised,
2298 );
2299 assert!(matches!(
2300 run.model_response(turn)
2301 .expect("tool turn should be accepted"),
2302 crate::agent::run::ModelTurnOutcome::Continue { .. }
2303 ));
2304
2305 let mut calls = match run.next_step().expect("tool step") {
2306 AgentRunStep::CallTools { calls } => calls,
2307 other => panic!("expected tool step, got {other:?}"),
2308 };
2309 calls[0].tool_call.id = rig_core::message::ToolCallId::new_or_mint("mismatched_call");
2312
2313 let hook_context = HookContext::new(true, None);
2314 hook_context.set_turn(1);
2315 let mut stream = drive_tool_calls(
2316 &runner,
2317 &hook_context,
2318 &mut run,
2319 calls,
2320 tool_snapshot,
2321 |span| span,
2322 true,
2323 );
2324
2325 let mut saw_commit = false;
2326 let mut saw_result = false;
2327 let mut saw_error = false;
2328 while let Some(item) = stream.next().await {
2329 match item {
2330 Ok(MultiTurnStreamItem::ToolExecutionCommitted { .. }) => saw_commit = true,
2331 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2332 ..
2333 })) => saw_result = true,
2334 Err(_) => saw_error = true,
2335 _ => {}
2336 }
2337 }
2338
2339 assert!(
2340 saw_error,
2341 "the mismatched result must fail run-state commit"
2342 );
2343 assert!(!saw_commit, "a failed run-state commit cannot be announced");
2344 assert!(!saw_result, "an uncommitted result cannot be surfaced");
2345 }
2346
2347 #[derive(Clone, Debug, Default)]
2348 struct CapturedSpan {
2349 id: u64,
2350 name: String,
2351 parent_id: Option<u64>,
2352 fields: HashMap<String, u64>,
2353 string_fields: HashMap<String, String>,
2354 record_counts: HashMap<String, usize>,
2355 }
2356
2357 #[derive(Clone, Default)]
2358 struct CapturedSpans(Arc<Mutex<Vec<CapturedSpan>>>);
2359
2360 impl CapturedSpans {
2361 fn clear(&self) {
2362 if let Ok(mut spans) = self.0.lock() {
2363 spans.clear();
2364 }
2365 }
2366
2367 fn insert(&self, id: &Id, name: &str, parent_id: Option<u64>) {
2368 let id = id.into_u64();
2369 if let Ok(mut spans) = self.0.lock() {
2370 spans.push(CapturedSpan {
2371 id,
2372 name: name.to_string(),
2373 parent_id,
2374 fields: HashMap::new(),
2375 string_fields: HashMap::new(),
2376 record_counts: HashMap::new(),
2377 });
2378 }
2379 }
2380
2381 fn record(&self, id: &Id, fields: Vec<CapturedField>) {
2382 if let Ok(mut spans) = self.0.lock()
2383 && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2384 {
2385 for field in fields {
2386 match field {
2387 CapturedField::Number(name, value) => {
2388 *span.record_counts.entry(name.clone()).or_insert(0) += 1;
2389 span.fields.insert(name, value);
2390 }
2391 CapturedField::Text(name, value) => {
2392 *span.record_counts.entry(name.clone()).or_insert(0) += 1;
2393 span.fields.insert(name.clone(), 0);
2394 span.string_fields.insert(name, value);
2395 }
2396 }
2397 }
2398 }
2399 }
2400
2401 fn record_strings(&self, id: &Id, fields: Vec<(String, String)>) {
2402 if let Ok(mut spans) = self.0.lock()
2403 && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2404 {
2405 span.string_fields.extend(fields);
2406 }
2407 }
2408
2409 fn snapshot(&self) -> Vec<CapturedSpan> {
2410 self.0.lock().map(|spans| spans.clone()).unwrap_or_default()
2411 }
2412 }
2413
2414 struct SpanCaptureLayer {
2415 spans: CapturedSpans,
2416 }
2417
2418 impl<S> Layer<S> for SpanCaptureLayer
2419 where
2420 S: Subscriber,
2421 S: for<'lookup> LookupSpan<'lookup>,
2422 {
2423 fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
2424 let parent_id = attrs
2425 .parent()
2426 .map(Id::into_u64)
2427 .or_else(|| ctx.current_span().id().map(Id::into_u64));
2428 self.spans.insert(id, attrs.metadata().name(), parent_id);
2429 let mut string_fields = Vec::new();
2430 attrs.record(&mut SpanStringCaptureVisitor {
2431 fields: &mut string_fields,
2432 });
2433 self.spans.record_strings(id, string_fields);
2434 }
2435
2436 fn on_record(&self, span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
2437 let mut fields = Vec::new();
2438 values.record(&mut SpanFieldCaptureVisitor {
2439 fields: &mut fields,
2440 });
2441 self.spans.record(span, fields);
2442 let mut string_fields = Vec::new();
2443 values.record(&mut SpanStringCaptureVisitor {
2444 fields: &mut string_fields,
2445 });
2446 self.spans.record_strings(span, string_fields);
2447 }
2448 }
2449
2450 enum CapturedField {
2451 Number(String, u64),
2452 Text(String, String),
2453 }
2454
2455 struct SpanFieldCaptureVisitor<'a> {
2456 fields: &'a mut Vec<CapturedField>,
2457 }
2458
2459 struct SpanStringCaptureVisitor<'a> {
2460 fields: &'a mut Vec<(String, String)>,
2461 }
2462
2463 impl Visit for SpanStringCaptureVisitor<'_> {
2464 fn record_str(&mut self, field: &Field, value: &str) {
2465 self.fields
2466 .push((field.name().to_string(), value.to_string()));
2467 }
2468
2469 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2470 self.fields
2471 .push((field.name().to_string(), format!("{value:?}")));
2472 }
2473 }
2474
2475 impl Visit for SpanFieldCaptureVisitor<'_> {
2476 fn record_u64(&mut self, field: &Field, value: u64) {
2477 self.fields
2478 .push(CapturedField::Number(field.name().to_string(), value));
2479 }
2480
2481 fn record_str(&mut self, field: &Field, value: &str) {
2484 self.fields.push(CapturedField::Text(
2485 field.name().to_string(),
2486 value.to_string(),
2487 ));
2488 }
2489
2490 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2491 self.fields.push(CapturedField::Text(
2492 field.name().to_string(),
2493 format!("{value:?}"),
2494 ));
2495 }
2496 }
2497
2498 async fn assert_stream_usage_recorded_on_chat_spans(
2499 agent: crate::agent::Agent,
2500 prompt: &str,
2501 max_turns: usize,
2502 expected_usages: &[Usage],
2503 ) {
2504 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2509 let spans = CapturedSpans::default();
2510 let subscriber = Registry::default().with(SpanCaptureLayer {
2511 spans: spans.clone(),
2512 });
2513 let _default = tracing::subscriber::set_default(subscriber);
2514
2515 let warmup_model = MockCompletionModel::from_stream_turns([[
2526 MockStreamEvent::text("warmup"),
2527 MockStreamEvent::final_response(Usage::default()),
2528 ]]);
2529 let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2530 let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2531 while let Some(item) = warmup_stream
2532 .try_next()
2533 .await
2534 .expect("warmup stream should not error")
2535 {
2536 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2537 break;
2538 }
2539 }
2540 tracing::callsite::rebuild_interest_cache();
2541 spans.clear();
2542
2543 let empty_history: &[Message] = &[];
2544 let outer_span = tracing::info_span!("outer", gen_ai.completion = tracing::field::Empty);
2547
2548 async {
2549 let mut stream = agent
2550 .stream_prompt(prompt)
2551 .history(empty_history)
2552 .max_turns(max_turns)
2553 .await;
2554
2555 while let Some(item) = stream.try_next().await.expect("stream should not error") {
2556 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2557 break;
2558 }
2559 }
2560 }
2561 .instrument(outer_span)
2562 .await;
2563
2564 let span_snapshot = spans.snapshot();
2565 let outer_span_id = span_snapshot
2566 .iter()
2567 .find(|span| span.name == "outer")
2568 .map(|span| span.id)
2569 .expect("outer span should be captured");
2570 let chat_spans = span_snapshot
2571 .iter()
2572 .filter(|span| span.name == "chat_streaming")
2573 .collect::<Vec<_>>();
2574
2575 assert_eq!(chat_spans.len(), expected_usages.len());
2576 assert!(
2577 span_snapshot.iter().all(|span| span.name != "invoke_agent"),
2578 "outer span path should not create invoke_agent"
2579 );
2580
2581 for (chat_span, expected_usage) in chat_spans.into_iter().zip(expected_usages) {
2582 assert_eq!(chat_span.parent_id, Some(outer_span_id));
2583 assert_eq!(
2584 chat_span
2585 .string_fields
2586 .get("gen_ai.operation.name")
2587 .map(String::as_str),
2588 Some("chat")
2589 );
2590 assert_eq!(
2591 chat_span.fields.get("gen_ai.usage.input_tokens"),
2592 Some(&expected_usage.input_tokens)
2593 );
2594 assert_eq!(
2595 chat_span.fields.get("gen_ai.usage.output_tokens"),
2596 Some(&expected_usage.output_tokens)
2597 );
2598 assert_eq!(
2599 chat_span.fields.get("gen_ai.usage.cache_read.input_tokens"),
2600 Some(&expected_usage.cached_input_tokens)
2601 );
2602 assert_eq!(
2603 chat_span
2604 .fields
2605 .get("gen_ai.usage.cache_creation.input_tokens"),
2606 Some(&expected_usage.cache_creation_input_tokens)
2607 );
2608 assert_eq!(
2609 chat_span.fields.get("gen_ai.usage.tool_use_prompt_tokens"),
2610 Some(&expected_usage.tool_use_prompt_tokens)
2611 );
2612 assert_eq!(
2613 chat_span.fields.get("gen_ai.usage.reasoning_tokens"),
2614 Some(&expected_usage.reasoning_tokens)
2615 );
2616 }
2617
2618 let outer_span = span_snapshot
2619 .iter()
2620 .find(|span| span.id == outer_span_id)
2621 .expect("outer span should be present");
2622 assert!(
2623 outer_span
2624 .fields
2625 .keys()
2626 .all(|field| !field.starts_with("gen_ai.usage.")),
2627 "usage should not be recorded onto the caller's outer span"
2628 );
2629 assert!(
2630 !outer_span.fields.contains_key("gen_ai.completion"),
2631 "gen_ai.completion should not be recorded onto the caller's outer span \
2632 (parity with the blocking driver)"
2633 );
2634 }
2635
2636 async fn capture_stream_message_telemetry(
2637 record_telemetry_content: bool,
2638 ) -> (CapturedSpan, Vec<CompletionRequest>) {
2639 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2640 let spans = CapturedSpans::default();
2641 let subscriber = Registry::default().with(SpanCaptureLayer {
2642 spans: spans.clone(),
2643 });
2644 let _default = tracing::subscriber::set_default(subscriber);
2645
2646 let warmup_model = MockCompletionModel::from_stream_turns([[
2647 MockStreamEvent::text("warmup"),
2648 MockStreamEvent::final_response(Usage::default()),
2649 ]]);
2650 let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2651 let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2652 while let Some(item) = warmup_stream
2653 .try_next()
2654 .await
2655 .expect("warmup stream should not error")
2656 {
2657 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2658 break;
2659 }
2660 }
2661 tracing::callsite::rebuild_interest_cache();
2662 spans.clear();
2663
2664 let model = MockCompletionModel::from_stream_turns([[
2665 MockStreamEvent::text("stream response secret"),
2666 MockStreamEvent::final_response(Usage::default()),
2667 ]]);
2668 let recorded_model = model.clone();
2669 let builder = AgentBuilder::new(model);
2670 let agent = if record_telemetry_content {
2671 builder
2672 .record_content_telemetry(true)
2673 .context("static stream context secret")
2674 .build()
2675 } else {
2676 builder.context("static stream context secret").build()
2677 };
2678
2679 let mut stream = agent
2680 .stream_prompt("stream prompt secret")
2681 .max_turns(1)
2682 .await;
2683 while let Some(item) = stream.try_next().await.expect("stream should not error") {
2684 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2685 break;
2686 }
2687 }
2688
2689 let span = spans
2690 .snapshot()
2691 .into_iter()
2692 .find(|span| span.name == "chat_streaming")
2693 .expect("chat_streaming span should be captured");
2694 (span, recorded_model.requests())
2695 }
2696
2697 async fn capture_unary_message_telemetry(
2698 record_telemetry_content: bool,
2699 ) -> (CapturedSpan, CapturedSpan, Vec<CompletionRequest>) {
2700 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2701 let spans = CapturedSpans::default();
2702 let subscriber = Registry::default().with(SpanCaptureLayer {
2703 spans: spans.clone(),
2704 });
2705 let _default = tracing::subscriber::set_default(subscriber);
2706
2707 let warmup_agent =
2708 crate::agent::AgentBuilder::new(MockCompletionModel::text("warmup")).build();
2709 warmup_agent
2710 .prompt("warmup")
2711 .await
2712 .expect("warmup prompt should not error");
2713 tracing::callsite::rebuild_interest_cache();
2714 spans.clear();
2715
2716 let model = MockCompletionModel::text("blocking response secret");
2717 let recorded_model = model.clone();
2718 let builder = AgentBuilder::new(model).preamble("blocking system secret");
2719 let agent = if record_telemetry_content {
2720 builder.record_content_telemetry(true).build()
2721 } else {
2722 builder.build()
2723 };
2724
2725 agent
2726 .prompt("blocking prompt secret")
2727 .await
2728 .expect("prompt should not error");
2729
2730 let snapshot = spans.snapshot();
2731 let chat_span = snapshot
2732 .iter()
2733 .find(|span| span.name == "chat")
2734 .cloned()
2735 .expect("chat span should be captured");
2736 let agent_span = snapshot
2737 .into_iter()
2738 .find(|span| span.name == "invoke_agent")
2739 .expect("invoke_agent span should be captured");
2740 (chat_span, agent_span, recorded_model.requests())
2741 }
2742
2743 #[tokio::test]
2744 async fn stream_prompt_message_telemetry_is_opt_in() {
2745 let (default_span, default_requests) = capture_stream_message_telemetry(false).await;
2746 assert!(
2747 !default_span.fields.contains_key("gen_ai.input.messages"),
2748 "default streaming prompt should not record input message contents"
2749 );
2750 assert!(
2751 !default_span.fields.contains_key("gen_ai.output.messages"),
2752 "default streaming prompt should not record output message contents"
2753 );
2754
2755 assert_eq!(default_requests.len(), 1);
2756 assert!(
2757 !default_requests[0].record_telemetry_content,
2758 "default agent stream should keep provider request message telemetry disabled"
2759 );
2760
2761 let (opt_in_span, opt_in_requests) = capture_stream_message_telemetry(true).await;
2762 let input = opt_in_span
2763 .string_fields
2764 .get("gen_ai.input.messages")
2765 .expect("opt-in should record input messages");
2766 assert!(input.contains("stream prompt secret"));
2767 assert!(input.contains("static stream context secret"));
2768 let output = opt_in_span
2769 .string_fields
2770 .get("gen_ai.output.messages")
2771 .expect("opt-in should record output messages");
2772 assert!(output.contains("stream response secret"));
2773 assert_eq!(
2774 opt_in_span
2775 .record_counts
2776 .get("gen_ai.input.messages")
2777 .copied(),
2778 Some(1),
2779 "agent-owned input message telemetry should be recorded once"
2780 );
2781 assert_eq!(
2782 opt_in_span
2783 .record_counts
2784 .get("gen_ai.output.messages")
2785 .copied(),
2786 Some(1),
2787 "agent-owned output message telemetry should be recorded once"
2788 );
2789 assert_eq!(opt_in_requests.len(), 1);
2790 assert!(
2791 !opt_in_requests[0].record_telemetry_content,
2792 "agent-owned stream telemetry should clear the provider request flag"
2793 );
2794 }
2795
2796 #[tokio::test]
2797 async fn unary_prompt_message_telemetry_records_accepted_output_when_opted_in() {
2798 let (default_span, default_agent_span, default_requests) =
2799 capture_unary_message_telemetry(false).await;
2800 assert!(
2801 !default_span.fields.contains_key("gen_ai.input.messages"),
2802 "default blocking prompt should not record input message contents"
2803 );
2804 assert!(
2805 !default_span.fields.contains_key("gen_ai.output.messages"),
2806 "default blocking prompt should not record output message contents"
2807 );
2808 assert!(
2809 !default_span
2810 .string_fields
2811 .contains_key("gen_ai.system_instructions"),
2812 "default blocking prompt should not record system instructions"
2813 );
2814 assert!(
2815 !default_agent_span
2816 .string_fields
2817 .contains_key("gen_ai.prompt")
2818 );
2819 assert!(
2820 !default_agent_span
2821 .string_fields
2822 .contains_key("gen_ai.completion")
2823 );
2824 assert_eq!(default_requests.len(), 1);
2825 assert!(
2826 !default_requests[0].record_telemetry_content,
2827 "default blocking prompt should keep provider request message telemetry disabled"
2828 );
2829
2830 let (opt_in_span, opt_in_agent_span, opt_in_requests) =
2831 capture_unary_message_telemetry(true).await;
2832 let input = opt_in_span
2833 .string_fields
2834 .get("gen_ai.input.messages")
2835 .expect("opt-in should record blocking input messages");
2836 assert!(input.contains("blocking prompt secret"));
2837 let output = opt_in_span
2838 .string_fields
2839 .get("gen_ai.output.messages")
2840 .expect("opt-in should record blocking output messages");
2841 assert!(output.contains("blocking response secret"));
2842 assert_eq!(
2843 opt_in_span
2844 .string_fields
2845 .get("gen_ai.system_instructions")
2846 .map(String::as_str),
2847 Some(r#"[{"type":"text","content":"blocking system secret"}]"#)
2848 );
2849 assert_eq!(
2850 opt_in_agent_span
2851 .string_fields
2852 .get("gen_ai.prompt")
2853 .map(String::as_str),
2854 Some("blocking prompt secret")
2855 );
2856 assert_eq!(
2857 opt_in_agent_span
2858 .string_fields
2859 .get("gen_ai.completion")
2860 .map(String::as_str),
2861 Some("blocking response secret")
2862 );
2863 assert_eq!(opt_in_requests.len(), 1);
2864 assert!(
2865 !opt_in_requests[0].record_telemetry_content,
2866 "agent-owned blocking telemetry should clear the provider request flag"
2867 );
2868 }
2869
2870 async fn capture_tool_content_telemetry(record_telemetry_content: bool) -> CapturedSpan {
2871 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2872 let spans = CapturedSpans::default();
2873 let subscriber = Registry::default().with(SpanCaptureLayer {
2874 spans: spans.clone(),
2875 });
2876 let _default = tracing::subscriber::set_default(subscriber);
2877
2878 let warmup = AgentBuilder::new(MockCompletionModel::from_turns([
2879 MockTurn::tool_call("warmup", "add", serde_json::json!({"x": 1, "y": 2})),
2880 MockTurn::text("done"),
2881 ]))
2882 .tool(MockAddTool)
2883 .build();
2884 warmup
2885 .runner("warmup")
2886 .max_turns(2)
2887 .run()
2888 .await
2889 .expect("warmup tool run should succeed");
2890 tracing::callsite::rebuild_interest_cache();
2891 spans.clear();
2892
2893 let builder = AgentBuilder::new(MockCompletionModel::from_turns([
2894 MockTurn::tool_call(
2895 "secret-tool-call",
2896 "add",
2897 serde_json::json!({"x": 12345, "y": 67890}),
2898 ),
2899 MockTurn::text("done"),
2900 ]))
2901 .tool(MockAddTool);
2902 let agent = if record_telemetry_content {
2903 builder.record_content_telemetry(true).build()
2904 } else {
2905 builder.build()
2906 };
2907 agent
2908 .runner("use the tool")
2909 .max_turns(2)
2910 .run()
2911 .await
2912 .expect("tool run should succeed");
2913
2914 spans
2915 .snapshot()
2916 .into_iter()
2917 .find(|span| span.name == "execute_tool")
2918 .expect("execute_tool span should be captured")
2919 }
2920
2921 #[tokio::test]
2922 async fn tool_arguments_and_results_follow_content_telemetry_toggle() {
2923 let default_span = capture_tool_content_telemetry(false).await;
2924 assert!(
2925 !default_span
2926 .string_fields
2927 .contains_key("gen_ai.tool.call.arguments")
2928 );
2929 assert!(
2930 !default_span
2931 .string_fields
2932 .contains_key("gen_ai.tool.call.result")
2933 );
2934 assert_eq!(
2935 default_span
2936 .string_fields
2937 .get("gen_ai.tool.name")
2938 .map(String::as_str),
2939 Some("add"),
2940 "structural tool metadata should remain available"
2941 );
2942
2943 let opt_in_span = capture_tool_content_telemetry(true).await;
2944 assert!(
2945 opt_in_span
2946 .string_fields
2947 .get("gen_ai.tool.call.arguments")
2948 .is_some_and(|args| args.contains("12345") && args.contains("67890"))
2949 );
2950 assert!(
2951 opt_in_span
2952 .string_fields
2953 .get("gen_ai.tool.call.result")
2954 .is_some_and(|result| result.contains("80235"))
2955 );
2956 }
2957
2958 #[tokio::test]
2959 async fn streaming_rejected_message_telemetry_does_not_record_output() {
2960 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2961 let spans = CapturedSpans::default();
2962 let subscriber = Registry::default().with(SpanCaptureLayer {
2963 spans: spans.clone(),
2964 });
2965 let _default = tracing::subscriber::set_default(subscriber);
2966
2967 let warmup_model = MockCompletionModel::from_stream_turns([[
2968 MockStreamEvent::text("warmup"),
2969 MockStreamEvent::final_response(Usage::default()),
2970 ]]);
2971 let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2972 let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2973 while let Some(item) = warmup_stream
2974 .try_next()
2975 .await
2976 .expect("warmup stream should not error")
2977 {
2978 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2979 break;
2980 }
2981 }
2982 tracing::callsite::rebuild_interest_cache();
2983 spans.clear();
2984
2985 let model = MockCompletionModel::from_stream_turns([[
2986 MockStreamEvent::text("rejected stream output secret"),
2987 MockStreamEvent::tool_call(
2988 "tool_call_1",
2989 "default_api",
2990 serde_json::json!({"x": 2, "y": 3}),
2991 ),
2992 MockStreamEvent::final_response(Usage::default()),
2993 ]]);
2994 let agent = AgentBuilder::new(model)
2995 .record_content_telemetry(true)
2996 .build();
2997
2998 let mut stream = agent
2999 .stream_prompt("stream rejection prompt")
3000 .max_turns(1)
3001 .await;
3002 let err = loop {
3003 match stream.try_next().await {
3004 Ok(Some(_)) => continue,
3005 Ok(None) => panic!("rejected stream should error"),
3006 Err(err) => break err,
3007 }
3008 };
3009 assert!(
3010 err.to_string().contains("default_api"),
3011 "expected invalid tool error, got {err}"
3012 );
3013
3014 let chat_span = spans
3015 .snapshot()
3016 .into_iter()
3017 .find(|span| span.name == "chat_streaming")
3018 .expect("chat_streaming span should be captured");
3019 assert!(
3020 chat_span.fields.contains_key("gen_ai.input.messages"),
3021 "opt-in rejected stream should still record input messages"
3022 );
3023 assert!(
3024 !chat_span.fields.contains_key("gen_ai.output.messages"),
3025 "rejected streaming turn must not record output message contents"
3026 );
3027 }
3028
3029 #[tokio::test]
3030 async fn unary_repaired_message_telemetry_records_canonical_output() {
3031 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
3032 let spans = CapturedSpans::default();
3033 let subscriber = Registry::default().with(SpanCaptureLayer {
3034 spans: spans.clone(),
3035 });
3036 let _default = tracing::subscriber::set_default(subscriber);
3037
3038 let warmup_agent =
3039 crate::agent::AgentBuilder::new(MockCompletionModel::text("warmup")).build();
3040 warmup_agent
3041 .prompt("warmup")
3042 .await
3043 .expect("warmup prompt should not error");
3044 tracing::callsite::rebuild_interest_cache();
3045 spans.clear();
3046
3047 let model = MockCompletionModel::new([
3048 MockTurn::tool_call(
3049 "tool_call_1",
3050 "default_api",
3051 serde_json::json!({"x": 2, "y": 3}),
3052 ),
3053 MockTurn::text("done"),
3054 ]);
3055 let recorded_model = model.clone();
3056 let agent = AgentBuilder::new(model)
3057 .record_content_telemetry(true)
3058 .tool(MockAddTool)
3059 .build();
3060
3061 let output = agent
3062 .prompt("repair tool call")
3063 .add_hook(RepairDefaultApiHook)
3064 .max_turns(3)
3065 .await
3066 .expect("repaired tool call should complete");
3067 assert_eq!(output, "done");
3068
3069 let output_messages: Vec<String> = spans
3070 .snapshot()
3071 .into_iter()
3072 .filter(|span| span.name == "chat")
3073 .filter_map(|span| span.string_fields.get("gen_ai.output.messages").cloned())
3074 .collect();
3075 assert!(
3076 output_messages.iter().any(|output| output.contains("add")),
3077 "repaired accepted output should include canonical tool name: {output_messages:?}"
3078 );
3079 assert!(
3080 !output_messages
3081 .iter()
3082 .any(|output| output.contains("default_api")),
3083 "repaired output telemetry must not serialize stale raw tool name: {output_messages:?}"
3084 );
3085
3086 let requests = recorded_model.requests();
3087 assert_eq!(requests.len(), 2);
3088 assert!(
3089 requests
3090 .iter()
3091 .all(|request| !request.record_telemetry_content),
3092 "agent-owned repaired telemetry should clear provider request flags"
3093 );
3094 }
3095
3096 #[test]
3097 fn completion_calls_stream_item_serializes_and_deserializes_expected_shape() {
3098 let item: MultiTurnStreamItem =
3099 MultiTurnStreamItem::CompletionCall(CompletionCall::new(2, usage(3, 4)));
3100
3101 let value = serde_json::to_value(&item).expect("serialize completion call event");
3102
3103 assert_eq!(
3104 value,
3105 serde_json::json!({
3106 "type": "completionCall",
3107 "call_index": 2,
3108 "usage": {
3109 "input_tokens": 3,
3110 "output_tokens": 4,
3111 "total_tokens": 7,
3112 "cached_input_tokens": 0,
3113 "cache_creation_input_tokens": 0,
3114 "tool_use_prompt_tokens": 0,
3115 "reasoning_tokens": 0,
3116 }
3117 })
3118 );
3119
3120 let item: MultiTurnStreamItem =
3121 serde_json::from_value(value).expect("deserialize completion call event");
3122 match item {
3123 MultiTurnStreamItem::CompletionCall(call_usage) => {
3124 assert_eq!(call_usage, CompletionCall::new(2, usage(3, 4)));
3125 }
3126 other => panic!("expected completion call event, got {other:?}"),
3127 }
3128
3129 let item: MultiTurnStreamItem =
3130 MultiTurnStreamItem::CompletionCall(CompletionCall::new(3, Usage::new()));
3131 let value = serde_json::to_value(&item).expect("serialize missing usage event");
3132
3133 assert_eq!(
3136 value,
3137 serde_json::json!({
3138 "type": "completionCall",
3139 "call_index": 3,
3140 "usage": {
3141 "input_tokens": 0,
3142 "output_tokens": 0,
3143 "total_tokens": 0,
3144 "cached_input_tokens": 0,
3145 "cache_creation_input_tokens": 0,
3146 "tool_use_prompt_tokens": 0,
3147 "reasoning_tokens": 0,
3148 }
3149 })
3150 );
3151
3152 let legacy: MultiTurnStreamItem = serde_json::from_value(serde_json::json!({
3155 "type": "completionCall",
3156 "call_index": 3,
3157 "usage": null
3158 }))
3159 .expect("legacy null-usage event should deserialize");
3160 match legacy {
3161 MultiTurnStreamItem::CompletionCall(call) => {
3162 assert_eq!(call, CompletionCall::new(3, Usage::new()));
3163 }
3164 other => panic!("expected completion call event, got {other:?}"),
3165 }
3166 }
3167
3168 #[test]
3169 fn final_response_serializes_completion_calls_with_missing_usage() {
3170 let item: MultiTurnStreamItem = MultiTurnStreamItem::final_response_with_completion_calls(
3171 vec![AssistantContent::text("done")],
3172 usage(3, 4),
3173 vec![
3174 CompletionCall::new(0, Usage::new()),
3175 CompletionCall::new(1, usage(3, 4)),
3176 ],
3177 None,
3178 );
3179
3180 if let MultiTurnStreamItem::FinalResponse(response) = &item {
3181 assert_eq!(response.requests(), 2);
3182 }
3183
3184 let value = serde_json::to_value(&item).expect("serialize final response");
3185
3186 assert_eq!(
3187 value.get("completion_calls"),
3188 Some(&serde_json::json!([
3189 {
3190 "call_index": 0,
3191 "usage": {
3192 "input_tokens": 0,
3193 "output_tokens": 0,
3194 "total_tokens": 0,
3195 "cached_input_tokens": 0,
3196 "cache_creation_input_tokens": 0,
3197 "tool_use_prompt_tokens": 0,
3198 "reasoning_tokens": 0,
3199 }
3200 },
3201 {
3202 "call_index": 1,
3203 "usage": {
3204 "input_tokens": 3,
3205 "output_tokens": 4,
3206 "total_tokens": 7,
3207 "cached_input_tokens": 0,
3208 "cache_creation_input_tokens": 0,
3209 "tool_use_prompt_tokens": 0,
3210 "reasoning_tokens": 0,
3211 }
3212 }
3213 ]))
3214 );
3215 }
3216
3217 fn streaming_text_then_final_model() -> MockCompletionModel {
3218 MockCompletionModel::from_stream_turns([[
3219 MockStreamEvent::text("hello"),
3220 MockStreamEvent::text(" world"),
3221 MockStreamEvent::final_response_with_total_tokens(3),
3222 ]])
3223 }
3224
3225 fn citation_metadata() -> serde_json::Value {
3226 serde_json::json!({
3227 "citations": [{
3228 "type": "web_search_result_location",
3229 "cited_text": "Claude Shannon was born in 1916.",
3230 "url": "https://example.com/shannon",
3231 "title": "Claude Shannon",
3232 "encrypted_index": "encrypted-reference"
3233 }]
3234 })
3235 }
3236
3237 fn streaming_cited_text_then_final_model() -> MockCompletionModel {
3238 MockCompletionModel::from_stream_turns([[
3239 MockStreamEvent::text_start("block-0", Some(citation_metadata())),
3240 MockStreamEvent::text("cited "),
3241 MockStreamEvent::text_start("block-1", None),
3242 MockStreamEvent::text("answer"),
3243 MockStreamEvent::final_response_with_total_tokens(3),
3244 ]])
3245 }
3246
3247 fn streaming_cited_text_then_tool_model() -> MockCompletionModel {
3248 MockCompletionModel::from_stream_turns([
3249 vec![
3250 MockStreamEvent::text_start("block-0", Some(citation_metadata())),
3251 MockStreamEvent::text("I need a tool. "),
3252 MockStreamEvent::tool_call(
3253 "tool_call_1",
3254 "add",
3255 serde_json::json!({"x": 1, "y": 2}),
3256 )
3257 .with_call_id("call_1"),
3258 MockStreamEvent::final_response_with_total_tokens(4),
3259 ],
3260 vec![
3261 MockStreamEvent::text("done"),
3262 MockStreamEvent::final_response_with_total_tokens(6),
3263 ],
3264 ])
3265 }
3266
3267 fn streaming_final_only_model() -> MockCompletionModel {
3268 MockCompletionModel::from_stream_turns([[
3269 MockStreamEvent::final_response_with_total_tokens(1),
3270 ]])
3271 }
3272
3273 #[derive(Clone)]
3274 struct TerminateOnStreamFinish;
3275
3276 impl AgentHook for TerminateOnStreamFinish {
3277 async fn on_stream_response_finish(
3278 &self,
3279 _ctx: &HookContext,
3280 event: StreamResponseFinish<'_>,
3281 ) -> ObservationAction {
3282 match event {
3283 StreamResponseFinish { .. } => {
3284 ObservationAction::stop("stop after completion call")
3285 }
3286 _ => ObservationAction::continue_run(),
3287 }
3288 }
3289 }
3290
3291 type RecordedToolCallDelta = (String, Option<String>, String);
3292 type RecordedReasoningDelta = (String, Option<String>, String, String);
3293
3294 #[derive(Clone)]
3295 struct RepairDefaultApiHook;
3296
3297 impl AgentHook for RepairDefaultApiHook {
3298 async fn on_invalid_tool_call(
3299 &self,
3300 _ctx: &HookContext,
3301 event: &InvalidToolCallContext,
3302 ) -> Option<InvalidToolCallAction> {
3303 Some(match event {
3304 context => {
3305 assert_eq!(context.tool_name, "default_api");
3306 InvalidToolCallAction::repair("add")
3307 }
3308 _ => InvalidToolCallAction::fail(),
3309 })
3310 }
3311 }
3312
3313 #[derive(Clone)]
3314 struct RetryDefaultApiHook;
3315
3316 impl AgentHook for RetryDefaultApiHook {
3317 async fn on_invalid_tool_call(
3318 &self,
3319 _ctx: &HookContext,
3320 event: &InvalidToolCallContext,
3321 ) -> Option<InvalidToolCallAction> {
3322 Some(match event {
3323 context => {
3324 assert_eq!(context.tool_name, "default_api");
3325 if let Some(args) = context.args.as_deref() {
3326 assert!(!args.is_empty());
3327 }
3328 InvalidToolCallAction::retry("Use the add tool instead")
3329 }
3330 _ => InvalidToolCallAction::fail(),
3331 })
3332 }
3333 }
3334
3335 #[derive(Clone)]
3336 struct SkipDefaultApiHook;
3337
3338 impl AgentHook for SkipDefaultApiHook {
3339 async fn on_invalid_tool_call(
3340 &self,
3341 _ctx: &HookContext,
3342 event: &InvalidToolCallContext,
3343 ) -> Option<InvalidToolCallAction> {
3344 Some(match event {
3345 context => {
3346 assert_eq!(context.tool_name, "default_api");
3347 InvalidToolCallAction::skip("default_api was skipped")
3348 }
3349 _ => InvalidToolCallAction::fail(),
3350 })
3351 }
3352 }
3353
3354 #[derive(Clone, Default)]
3355 struct RecordingInvalidToolCallHook {
3356 contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
3357 }
3358
3359 impl RecordingInvalidToolCallHook {
3360 fn observed(&self) -> Vec<InvalidToolCallContext> {
3361 self.contexts
3362 .lock()
3363 .expect("invalid tool context records mutex was poisoned")
3364 .clone()
3365 }
3366 }
3367
3368 impl AgentHook for RecordingInvalidToolCallHook {
3369 async fn on_invalid_tool_call(
3370 &self,
3371 _ctx: &HookContext,
3372 event: &InvalidToolCallContext,
3373 ) -> Option<InvalidToolCallAction> {
3374 Some(match event {
3375 context => {
3376 self.contexts
3377 .lock()
3378 .expect("invalid tool context records mutex was poisoned")
3379 .push(context.clone());
3380 InvalidToolCallAction::fail()
3381 }
3382 _ => InvalidToolCallAction::fail(),
3383 })
3384 }
3385 }
3386
3387 #[derive(Clone, Default)]
3388 struct RecordingToolCallDeltaHook {
3389 deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
3390 }
3391
3392 impl RecordingToolCallDeltaHook {
3393 fn observed(&self) -> Vec<RecordedToolCallDelta> {
3394 self.deltas
3395 .lock()
3396 .expect("tool call delta hook records mutex was poisoned")
3397 .clone()
3398 }
3399 }
3400
3401 impl AgentHook for RecordingToolCallDeltaHook {
3402 async fn on_tool_call_delta(
3403 &self,
3404 _ctx: &HookContext,
3405 event: ToolCallDelta<'_>,
3406 ) -> ObservationAction {
3407 match event {
3408 ToolCallDelta {
3409 internal_call_id,
3410 tool_name,
3411 delta,
3412 } => {
3413 let record = (
3414 internal_call_id.to_string(),
3415 tool_name.map(str::to_string),
3416 delta.to_string(),
3417 );
3418 self.deltas
3419 .lock()
3420 .expect("tool call delta hook records mutex was poisoned")
3421 .push(record);
3422 ObservationAction::continue_run()
3423 }
3424 _ => ObservationAction::continue_run(),
3425 }
3426 }
3427 }
3428
3429 #[derive(Clone, Default)]
3430 struct RecordingTextDeltaHook {
3431 deltas: Arc<Mutex<Vec<(String, String)>>>,
3432 }
3433
3434 impl RecordingTextDeltaHook {
3435 fn observed(&self) -> Vec<(String, String)> {
3436 self.deltas
3437 .lock()
3438 .expect("text delta hook records mutex was poisoned")
3439 .clone()
3440 }
3441 }
3442
3443 impl AgentHook for RecordingTextDeltaHook {
3444 async fn on_text_delta(
3445 &self,
3446 _ctx: &HookContext,
3447 event: TextDelta<'_>,
3448 ) -> ObservationAction {
3449 match event {
3450 TextDelta { delta, aggregated } => {
3451 let record = (delta.to_string(), aggregated.to_string());
3452 self.deltas
3453 .lock()
3454 .expect("text delta hook records mutex was poisoned")
3455 .push(record);
3456 ObservationAction::continue_run()
3457 }
3458 _ => ObservationAction::continue_run(),
3459 }
3460 }
3461 }
3462
3463 #[derive(Clone, Default)]
3464 struct RecordingReasoningDeltaHook {
3465 deltas: Arc<Mutex<Vec<RecordedReasoningDelta>>>,
3466 }
3467
3468 impl RecordingReasoningDeltaHook {
3469 fn observed(&self) -> Vec<RecordedReasoningDelta> {
3470 self.deltas
3471 .lock()
3472 .expect("reasoning delta hook records mutex was poisoned")
3473 .clone()
3474 }
3475 }
3476
3477 impl AgentHook for RecordingReasoningDeltaHook {
3478 async fn on_reasoning_delta(
3479 &self,
3480 _ctx: &HookContext,
3481 event: ReasoningDelta<'_>,
3482 ) -> ObservationAction {
3483 let record = (
3484 event.id.to_string(),
3485 event.provider_id.map(str::to_string),
3486 event.delta.to_string(),
3487 event.aggregated.to_string(),
3488 );
3489 self.deltas
3490 .lock()
3491 .expect("reasoning delta hook records mutex was poisoned")
3492 .push(record);
3493 ObservationAction::continue_run()
3494 }
3495 }
3496
3497 #[derive(Clone, Default)]
3498 struct TerminatingReasoningDeltaHook {
3499 recorder: RecordingReasoningDeltaHook,
3500 }
3501
3502 impl TerminatingReasoningDeltaHook {
3503 fn observed(&self) -> Vec<RecordedReasoningDelta> {
3504 self.recorder.observed()
3505 }
3506 }
3507
3508 impl AgentHook for TerminatingReasoningDeltaHook {
3509 async fn on_reasoning_delta(
3510 &self,
3511 ctx: &HookContext,
3512 event: ReasoningDelta<'_>,
3513 ) -> ObservationAction {
3514 self.recorder.on_reasoning_delta(ctx, event).await;
3515 ObservationAction::stop("stop on reasoning delta")
3516 }
3517 }
3518
3519 #[derive(Clone, Default)]
3520 struct UninterestedReasoningDeltaHook {
3521 calls: Arc<AtomicU32>,
3522 }
3523
3524 impl AgentHook for UninterestedReasoningDeltaHook {
3525 async fn on_reasoning_delta(
3526 &self,
3527 _ctx: &HookContext,
3528 _event: ReasoningDelta<'_>,
3529 ) -> ObservationAction {
3530 self.calls.fetch_add(1, Ordering::SeqCst);
3531 ObservationAction::stop("uninterested reasoning hook was dispatched")
3532 }
3533
3534 fn observes(&self, kind: StepEventKind) -> bool {
3535 kind != StepEventKind::ReasoningDelta
3536 }
3537 }
3538
3539 #[derive(Clone, Default)]
3540 struct RetryFirstReasoningTurnHook {
3541 recorder: RecordingReasoningDeltaHook,
3542 retried: Arc<AtomicBool>,
3543 }
3544
3545 impl AgentHook for RetryFirstReasoningTurnHook {
3546 async fn on_reasoning_delta(
3547 &self,
3548 ctx: &HookContext,
3549 event: ReasoningDelta<'_>,
3550 ) -> ObservationAction {
3551 self.recorder.on_reasoning_delta(ctx, event).await
3552 }
3553
3554 async fn on_model_turn_finished(
3555 &self,
3556 _ctx: &HookContext,
3557 _event: ModelTurnFinished<'_>,
3558 ) -> ModelTurnAction {
3559 if self.retried.swap(true, Ordering::SeqCst) {
3560 ModelTurnAction::continue_run()
3561 } else {
3562 ModelTurnAction::repeat()
3563 }
3564 }
3565 }
3566
3567 #[derive(Clone)]
3568 struct RecordingTextAndSkipInvalidToolHook {
3569 text: RecordingTextDeltaHook,
3570 }
3571
3572 impl AgentHook for RecordingTextAndSkipInvalidToolHook {
3573 async fn on_text_delta(
3574 &self,
3575 ctx: &HookContext,
3576 event: TextDelta<'_>,
3577 ) -> ObservationAction {
3578 self.text.on_text_delta(ctx, event).await
3579 }
3580 async fn on_invalid_tool_call(
3581 &self,
3582 ctx: &HookContext,
3583 event: &InvalidToolCallContext,
3584 ) -> Option<InvalidToolCallAction> {
3585 SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
3586 }
3587 }
3588
3589 #[derive(Clone)]
3590 struct RecordingTextAndRetryInvalidToolHook {
3591 text: RecordingTextDeltaHook,
3592 }
3593
3594 impl AgentHook for RecordingTextAndRetryInvalidToolHook {
3595 async fn on_text_delta(
3596 &self,
3597 ctx: &HookContext,
3598 event: TextDelta<'_>,
3599 ) -> ObservationAction {
3600 self.text.on_text_delta(ctx, event).await
3601 }
3602 async fn on_invalid_tool_call(
3603 &self,
3604 ctx: &HookContext,
3605 event: &InvalidToolCallContext,
3606 ) -> Option<InvalidToolCallAction> {
3607 RetryDefaultApiHook.on_invalid_tool_call(ctx, event).await
3608 }
3609 }
3610
3611 #[derive(Clone)]
3612 struct RecordingDeltaAndRetryInvalidToolHook {
3613 delta: RecordingToolCallDeltaHook,
3614 }
3615
3616 impl AgentHook for RecordingDeltaAndRetryInvalidToolHook {
3617 async fn on_tool_call_delta(
3618 &self,
3619 ctx: &HookContext,
3620 event: ToolCallDelta<'_>,
3621 ) -> ObservationAction {
3622 self.delta.on_tool_call_delta(ctx, event).await
3623 }
3624 async fn on_invalid_tool_call(
3625 &self,
3626 ctx: &HookContext,
3627 event: &InvalidToolCallContext,
3628 ) -> Option<InvalidToolCallAction> {
3629 RetryDefaultApiHook.on_invalid_tool_call(ctx, event).await
3630 }
3631 }
3632
3633 #[derive(Clone)]
3634 struct RecordingDeltaAndSkipInvalidToolHook {
3635 delta: RecordingToolCallDeltaHook,
3636 }
3637
3638 impl AgentHook for RecordingDeltaAndSkipInvalidToolHook {
3639 async fn on_tool_call_delta(
3640 &self,
3641 ctx: &HookContext,
3642 event: ToolCallDelta<'_>,
3643 ) -> ObservationAction {
3644 self.delta.on_tool_call_delta(ctx, event).await
3645 }
3646 async fn on_invalid_tool_call(
3647 &self,
3648 ctx: &HookContext,
3649 event: &InvalidToolCallContext,
3650 ) -> Option<InvalidToolCallAction> {
3651 SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
3652 }
3653 }
3654
3655 #[derive(Clone, Default)]
3656 struct TerminatingToolCallDeltaHook {
3657 deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
3658 }
3659
3660 impl TerminatingToolCallDeltaHook {
3661 fn observed(&self) -> Vec<RecordedToolCallDelta> {
3662 self.deltas
3663 .lock()
3664 .expect("tool call delta hook records mutex was poisoned")
3665 .clone()
3666 }
3667 }
3668
3669 impl AgentHook for TerminatingToolCallDeltaHook {
3670 async fn on_tool_call_delta(
3671 &self,
3672 _ctx: &HookContext,
3673 event: ToolCallDelta<'_>,
3674 ) -> ObservationAction {
3675 match event {
3676 ToolCallDelta {
3677 internal_call_id,
3678 tool_name,
3679 delta,
3680 } => {
3681 let record = (
3682 internal_call_id.to_string(),
3683 tool_name.map(str::to_string),
3684 delta.to_string(),
3685 );
3686 self.deltas
3687 .lock()
3688 .expect("tool call delta hook records mutex was poisoned")
3689 .push(record);
3690 ObservationAction::stop("stop on tool call delta")
3691 }
3692 _ => ObservationAction::continue_run(),
3693 }
3694 }
3695 }
3696
3697 fn text_metadata(content: &[AssistantContent]) -> Option<&rig_core::message::AdditionalParams> {
3698 content.iter().find_map(|item| match item {
3699 AssistantContent::Text(text) => text.additional_params.as_ref(),
3700 _ => None,
3701 })
3702 }
3703
3704 #[tokio::test]
3705 async fn stream_prompt_continues_after_tool_call_turn() {
3706 let model = streaming_tool_then_text_model();
3707 let recorded = model.clone();
3708 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3709 let empty_history: &[Message] = &[];
3710
3711 let mut stream = agent
3712 .stream_prompt("do tool work")
3713 .history(empty_history)
3714 .max_turns(3)
3715 .await;
3716 let mut saw_tool_call = false;
3717 let mut saw_tool_result = false;
3718 let mut saw_final_response = false;
3719 let mut final_text = String::new();
3720 let mut final_response_text = None;
3721 let mut final_history = None;
3722
3723 while let Some(item) = stream.next().await {
3724 match item {
3725 Ok(MultiTurnStreamItem::StreamAssistantItem(
3726 StreamedAssistantContent::ToolCall { .. },
3727 )) => {
3728 saw_tool_call = true;
3729 }
3730 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3731 ..
3732 })) => {
3733 saw_tool_result = true;
3734 }
3735 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
3736 text,
3737 ))) => {
3738 final_text.push_str(&text.text);
3739 }
3740 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
3741 saw_final_response = true;
3742 final_response_text = Some(res.output().to_owned());
3743 final_history = res.messages().map(|history| history.to_vec());
3744 break;
3745 }
3746 Ok(_) => {}
3747 Err(err) => panic!("unexpected streaming error: {err:?}"),
3748 }
3749 }
3750
3751 assert!(saw_tool_call);
3752 assert!(saw_tool_result);
3753 assert!(saw_final_response);
3754 assert_eq!(final_text, "done");
3755 assert_eq!(final_response_text.as_deref(), Some("done"));
3756 let history = final_history.expect("expected final response history");
3757 assert!(history.iter().any(|message| matches!(
3758 message,
3759 Message::Assistant { content, .. }
3760 if content.iter().any(|item| matches!(
3761 item,
3762 AssistantContent::Text(text) if text.text == "done"
3763 ))
3764 )));
3765 let requests = recorded.requests();
3766 assert_eq!(requests.len(), 2);
3767 assert!(validate_follow_up_tool_history(&requests[1]).is_ok());
3768 }
3769
3770 #[tokio::test]
3776 async fn streaming_prompt_request_tool_concurrency_runs_tools_concurrently() {
3777 let barrier = Arc::new(tokio::sync::Barrier::new(2));
3778 let model = MockCompletionModel::from_stream_turns([
3779 vec![
3780 MockStreamEvent::tool_call("b1", "barrier_tool", serde_json::json!({})),
3781 MockStreamEvent::tool_call("b2", "barrier_tool", serde_json::json!({})),
3782 MockStreamEvent::final_response_with_total_tokens(0),
3783 ],
3784 vec![
3785 MockStreamEvent::text("done"),
3786 MockStreamEvent::final_response_with_total_tokens(0),
3787 ],
3788 ]);
3789 let agent = AgentBuilder::new(model)
3790 .tool(MockBarrierTool::new(barrier))
3791 .build();
3792
3793 let drive = async {
3794 let mut stream = agent
3795 .stream_prompt("hit the barrier twice")
3796 .max_turns(3)
3797 .tool_concurrency(2)
3798 .await;
3799 while let Some(item) = stream.next().await {
3800 item.unwrap_or_else(|err| panic!("unexpected streaming error: {err:?}"));
3801 }
3802 };
3803
3804 tokio::time::timeout(Duration::from_secs(5), drive)
3805 .await
3806 .expect("streamed tools must run concurrently, not deadlock at the barrier");
3807 }
3808
3809 #[tokio::test]
3812 async fn tool_context_reaches_tool_through_streaming_loop() {
3813 let model = MockCompletionModel::from_stream_turns([
3814 vec![
3815 MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
3816 .with_call_id("call_1"),
3817 MockStreamEvent::final_response_with_total_tokens(4),
3818 ],
3819 vec![
3820 MockStreamEvent::text("done"),
3821 MockStreamEvent::final_response_with_total_tokens(6),
3822 ],
3823 ]);
3824 let probe = MockContextProbeTool::default();
3825 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
3826 let empty_history: &[Message] = &[];
3827
3828 let mut tool_context = ToolContext::new();
3829 tool_context.insert(SessionId("xyz-789".to_string()));
3830
3831 let mut stream = agent
3832 .stream_prompt("do tool work")
3833 .tool_context(tool_context)
3834 .history(empty_history)
3835 .max_turns(3)
3836 .await;
3837
3838 while let Some(item) = stream.next().await {
3839 match item {
3840 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3841 Err(err) => panic!("unexpected streaming error: {err:?}"),
3842 Ok(_) => {}
3843 }
3844 }
3845
3846 assert_eq!(probe.observed().as_deref(), Some("session:xyz-789"));
3847 }
3848
3849 #[tokio::test]
3853 async fn streaming_tool_runs_with_empty_context_when_none_supplied() {
3854 let model = MockCompletionModel::from_stream_turns([
3855 vec![
3856 MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
3857 .with_call_id("call_1"),
3858 MockStreamEvent::final_response_with_total_tokens(4),
3859 ],
3860 vec![
3861 MockStreamEvent::text("done"),
3862 MockStreamEvent::final_response_with_total_tokens(6),
3863 ],
3864 ]);
3865 let probe = MockContextProbeTool::default();
3866 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
3867 let empty_history: &[Message] = &[];
3868
3869 let mut stream = agent
3870 .stream_prompt("do tool work")
3871 .history(empty_history)
3872 .max_turns(3)
3873 .await;
3874
3875 while let Some(item) = stream.next().await {
3876 match item {
3877 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3878 Err(err) => panic!("unexpected streaming error: {err:?}"),
3879 Ok(_) => {}
3880 }
3881 }
3882
3883 assert_eq!(probe.observed().as_deref(), Some("no-session"));
3884 }
3885
3886 #[tokio::test]
3887 async fn unknown_tool_call_fails_before_streaming_second_request() {
3888 let model = MockCompletionModel::from_stream_turns([
3889 vec![
3890 MockStreamEvent::tool_call(
3891 "tool_call_1",
3892 "default_api",
3893 serde_json::json!({"x": 1, "y": 2}),
3894 ),
3895 MockStreamEvent::final_response_with_total_tokens(4),
3896 ],
3897 vec![
3898 MockStreamEvent::text("should not be requested"),
3899 MockStreamEvent::final_response_with_total_tokens(6),
3900 ],
3901 ]);
3902 let recorded = model.clone();
3903 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3904
3905 let mut stream = agent
3906 .stream_prompt("use the tool")
3907 .add_hook(PanicOnUnknownToolHook)
3908 .max_turns(3)
3909 .await;
3910 let mut saw_tool_call = false;
3911 let mut error = None;
3912
3913 while let Some(item) = stream.next().await {
3914 match item {
3915 Ok(MultiTurnStreamItem::StreamAssistantItem(
3916 StreamedAssistantContent::ToolCall { .. },
3917 )) => {
3918 saw_tool_call = true;
3919 }
3920 Ok(_) => {}
3921 Err(err) => {
3922 error = Some(err);
3923 break;
3924 }
3925 }
3926 }
3927
3928 assert!(!saw_tool_call);
3929 let error = error.expect("unknown model-emitted tool should fail");
3930 match error {
3931 StreamingError::Prompt(err) => match *err {
3932 PromptError::UnknownToolCall {
3933 tool_name,
3934 available_tools,
3935 allowed_tools,
3936 chat_history,
3937 } => {
3938 assert_eq!(tool_name, "default_api");
3939 assert_eq!(available_tools, vec!["add".to_string()]);
3940 assert_eq!(allowed_tools, vec!["add".to_string()]);
3941 assert!(history_contains_tool_call(&chat_history, "default_api"));
3942 }
3943 other => panic!("expected UnknownToolCall, got {other:?}"),
3944 },
3945 other => panic!("expected prompt streaming error, got {other:?}"),
3946 }
3947 assert_eq!(recorded.request_count(), 1);
3948 }
3949
3950 #[tokio::test]
3951 async fn invalid_tool_call_hook_can_repair_streaming_tool_name() {
3952 let model = MockCompletionModel::from_stream_turns([
3953 vec![
3954 MockStreamEvent::tool_call(
3955 "tool_call_1",
3956 "default_api",
3957 serde_json::json!({"x": 2, "y": 3}),
3958 ),
3959 MockStreamEvent::final_response_with_total_tokens(4),
3960 ],
3961 vec![
3962 MockStreamEvent::text("done"),
3963 MockStreamEvent::final_response_with_total_tokens(6),
3964 ],
3965 ]);
3966 let recorded = model.clone();
3967 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3968
3969 let mut stream = agent
3970 .stream_prompt("use the tool")
3971 .add_hook(RepairDefaultApiHook)
3972 .max_turns(3)
3973 .history(Vec::<Message>::new())
3974 .await;
3975 let mut saw_repaired_tool_call = false;
3976 let mut saw_tool_result = false;
3977 let mut final_response_text = None;
3978
3979 while let Some(item) = stream.next().await {
3980 match item {
3981 Ok(MultiTurnStreamItem::StreamAssistantItem(
3982 StreamedAssistantContent::ToolCall { tool_call, .. },
3983 )) => {
3984 assert_eq!(tool_call.function.name, "add");
3985 saw_repaired_tool_call = true;
3986 }
3987 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3988 tool_result,
3989 ..
3990 })) => {
3991 assert!(tool_result.content.iter().any(|content| {
3992 matches!(
3993 content,
3994 ToolResultContent::Json { value }
3995 if value == &serde_json::json!(5)
3996 )
3997 }));
3998 saw_tool_result = true;
3999 }
4000 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4001 final_response_text = Some(response.output().to_string());
4002 break;
4003 }
4004 Ok(_) => {}
4005 Err(err) => panic!("unexpected streaming error: {err:?}"),
4006 }
4007 }
4008
4009 assert!(saw_repaired_tool_call);
4010 assert!(saw_tool_result);
4011 assert_eq!(final_response_text.as_deref(), Some("done"));
4012 assert_eq!(recorded.request_count(), 2);
4013 }
4014
4015 #[tokio::test]
4016 async fn invalid_tool_call_context_uses_completed_streaming_tool_call_provider_id() {
4017 let invalid_hook = RecordingInvalidToolCallHook::default();
4018 let model = MockCompletionModel::from_stream_turns([
4019 vec![
4020 MockStreamEvent::tool_call(
4021 "tool_call_1",
4022 "default_api",
4023 serde_json::json!({"x": 2, "y": 3}),
4024 )
4025 .with_call_id("provider_call_1"),
4026 MockStreamEvent::final_response_with_total_tokens(4),
4027 ],
4028 vec![
4029 MockStreamEvent::text("should not be requested"),
4030 MockStreamEvent::final_response_with_total_tokens(6),
4031 ],
4032 ]);
4033 let recorded = model.clone();
4034 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4035
4036 let mut stream = agent
4037 .stream_prompt("use the tool")
4038 .add_hook(invalid_hook.clone())
4039 .max_turns(3)
4040 .await;
4041 let mut error = None;
4042
4043 while let Some(item) = stream.next().await {
4044 if let Err(err) = item {
4045 error = Some(err);
4046 break;
4047 }
4048 }
4049
4050 assert!(error.is_some(), "invalid tool should fail");
4051 assert_eq!(recorded.request_count(), 1);
4052 let contexts = invalid_hook.observed();
4053 assert_eq!(contexts.len(), 1);
4054 let context = &contexts[0];
4055 assert_eq!(context.tool_name, "default_api");
4056 assert_eq!(context.tool_call_id.as_deref(), Some("provider_call_1"));
4060 assert!(context.internal_call_id.is_some());
4061 assert!(context.is_streaming);
4062 }
4063
4064 #[tokio::test]
4065 async fn invalid_tool_call_hook_skip_emits_streaming_tool_result() {
4066 let add_calls = Arc::new(AtomicU32::new(0));
4067 let model = MockCompletionModel::from_stream_turns([
4068 vec![
4069 MockStreamEvent::tool_call(
4070 "tool_call_1",
4071 "default_api",
4072 serde_json::json!({"x": 2, "y": 3}),
4073 )
4074 .with_call_id("call_1"),
4075 MockStreamEvent::final_response_with_total_tokens(4),
4076 ],
4077 vec![
4078 MockStreamEvent::text("continued"),
4079 MockStreamEvent::final_response_with_total_tokens(6),
4080 ],
4081 ]);
4082 let recorded = model.clone();
4083 let agent = AgentBuilder::new(model)
4084 .tool(CountingAddTool {
4085 calls: add_calls.clone(),
4086 })
4087 .build();
4088
4089 let mut stream = agent
4090 .stream_prompt("use the tool")
4091 .add_hook(SkipDefaultApiHook)
4092 .max_turns(3)
4093 .history(Vec::<Message>::new())
4094 .await;
4095 let mut skipped_tool_result = None;
4096 let mut final_response_text = None;
4097
4098 while let Some(item) = stream.next().await {
4099 match item {
4100 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4101 tool_result,
4102 internal_call_id,
4103 })) => {
4104 assert!(!internal_call_id.is_empty());
4105 skipped_tool_result = Some(tool_result);
4106 }
4107 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4108 final_response_text = Some(response.output().to_string());
4109 break;
4110 }
4111 Ok(_) => {}
4112 Err(err) => panic!("unexpected streaming error: {err:?}"),
4113 }
4114 }
4115
4116 let skipped_tool_result =
4117 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4118 assert_eq!(skipped_tool_result.call, "call_1");
4121 assert!(
4122 skipped_tool_result
4123 .provider
4124 .as_ref()
4125 .is_some_and(|provider| {
4126 provider.call_id == "call_1"
4127 && provider.item_id.as_deref() == Some("tool_call_1")
4128 })
4129 );
4130 assert!(skipped_tool_result.content.iter().any(|content| matches!(
4131 content,
4132 ToolResultContent::Text(text) if text.text == "default_api was skipped"
4133 )));
4134 assert_eq!(final_response_text.as_deref(), Some("continued"));
4135 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4136
4137 let requests = recorded.requests();
4138 assert_eq!(requests.len(), 2);
4139 let follow_up_history = requests[1].chat_history.clone();
4140 assert!(matches!(
4141 follow_up_history.get(2),
4142 Some(Message::User { content })
4143 if content.iter().any(|item| matches!(
4144 item,
4145 UserContent::ToolResult(result)
4146 if result.call == "call_1"
4147 && result.content.iter().any(|content| matches!(
4148 content,
4149 ToolResultContent::Text(text)
4150 if text.text == "default_api was skipped"
4151 ))
4152 ))
4153 ));
4154 }
4155
4156 #[tokio::test]
4157 async fn invalid_tool_call_hook_retries_mixed_streaming_turn_without_executing_valid_call() {
4158 let add_calls = Arc::new(AtomicU32::new(0));
4159 let model = MockCompletionModel::from_stream_turns([
4160 vec![
4161 MockStreamEvent::text("checking "),
4162 MockStreamEvent::tool_call(
4163 "tool_call_1",
4164 "add",
4165 serde_json::json!({"x": 2, "y": 3}),
4166 )
4167 .with_call_id("call_1"),
4168 MockStreamEvent::tool_call(
4169 "tool_call_2",
4170 "default_api",
4171 serde_json::json!({"x": 4, "y": 5}),
4172 )
4173 .with_call_id("call_2"),
4174 MockStreamEvent::final_response_with_total_tokens(4),
4175 ],
4176 vec![
4177 MockStreamEvent::text("retried"),
4178 MockStreamEvent::final_response_with_total_tokens(6),
4179 ],
4180 ]);
4181 let recorded = model.clone();
4182 let agent = AgentBuilder::new(model)
4183 .tool(CountingAddTool {
4184 calls: add_calls.clone(),
4185 })
4186 .build();
4187
4188 let mut stream = agent
4189 .stream_prompt("use the tool")
4190 .add_hook(RetryDefaultApiHook)
4191 .max_turns(3)
4192 .history(Vec::<Message>::new())
4193 .max_invalid_tool_call_retries(1)
4194 .await;
4195 let mut completion_call_events = Vec::new();
4196 let mut final_response_text = None;
4197 let mut final_response_usage = Usage::new();
4198 let mut final_completion_calls = Vec::new();
4199
4200 while let Some(item) = stream.next().await {
4201 match item {
4202 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
4203 completion_call_events.push(completion_call);
4204 }
4205 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4206 final_response_text = Some(response.output().to_string());
4207 final_response_usage = response.usage();
4208 final_completion_calls = response.completion_calls().to_vec();
4209 break;
4210 }
4211 Ok(_) => {}
4212 Err(err) => panic!("unexpected streaming error: {err:?}"),
4213 }
4214 }
4215
4216 assert_eq!(final_response_text.as_deref(), Some("retried"));
4217 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4218 let mut first_usage = Usage::new();
4219 first_usage.total_tokens = 4;
4220 let mut second_usage = Usage::new();
4221 second_usage.total_tokens = 6;
4222 let expected_completion_calls = vec![
4223 streamed_call(0, first_usage),
4224 streamed_call(1, second_usage),
4225 ];
4226 assert_eq!(completion_call_events, expected_completion_calls);
4227 assert_eq!(final_completion_calls, expected_completion_calls);
4228 assert_eq!(final_response_usage.total_tokens, 10);
4229
4230 let requests = recorded.requests();
4231 assert_eq!(requests.len(), 2);
4232 let retry_history = requests[1].chat_history.clone();
4233 assert_eq!(retry_history.len(), 3);
4234 assert!(matches!(
4235 retry_history.get(1),
4236 Some(Message::Assistant { content, .. })
4237 if content.iter().any(|item| matches!(
4238 item,
4239 AssistantContent::Text(text) if text.text == "checking "
4240 ))
4241 && content.iter().any(|item| matches!(
4242 item,
4243 AssistantContent::ToolCall(tool_call)
4244 if tool_call.id == "call_1"
4245 && tool_call.function.name == "add"
4246 ))
4247 && content.iter().any(|item| matches!(
4248 item,
4249 AssistantContent::ToolCall(tool_call)
4250 if tool_call.id == "call_2"
4251 && tool_call.function.name == "default_api"
4252 ))
4253 ));
4254 assert!(matches!(
4255 retry_history.get(2),
4256 Some(Message::User { content })
4257 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4258 && content.iter().any(|item| matches!(
4259 item,
4260 UserContent::ToolResult(result)
4261 if result.call == "call_1"
4262 && result.content.iter().any(|content| matches!(
4263 content,
4264 ToolResultContent::Text(text)
4265 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4266 ))
4267 ))
4268 && content.iter().any(|item| matches!(
4269 item,
4270 UserContent::ToolResult(result)
4271 if result.call == "call_2"
4272 && result.content.iter().any(|content| matches!(
4273 content,
4274 ToolResultContent::Text(text)
4275 if text.text == "Use the add tool instead"
4276 ))
4277 ))
4278 ));
4279 assert_retry_transcript_ids_pair(
4280 retry_history.get(1).expect("assistant tool-call turn"),
4281 retry_history.get(2).expect("retry-result turn"),
4282 );
4283 }
4284
4285 #[tokio::test]
4286 async fn invalid_tool_call_hook_skips_mixed_streaming_turn_without_executing_valid_call() {
4287 let add_calls = Arc::new(AtomicU32::new(0));
4288 let model = MockCompletionModel::from_stream_turns([
4289 vec![
4290 MockStreamEvent::text("checking "),
4291 MockStreamEvent::tool_call(
4292 "tool_call_1",
4293 "add",
4294 serde_json::json!({"x": 2, "y": 3}),
4295 )
4296 .with_call_id("call_1"),
4297 MockStreamEvent::tool_call(
4298 "tool_call_2",
4299 "default_api",
4300 serde_json::json!({"x": 4, "y": 5}),
4301 )
4302 .with_call_id("call_2"),
4303 MockStreamEvent::final_response_with_total_tokens(4),
4304 ],
4305 vec![
4306 MockStreamEvent::text("continued"),
4307 MockStreamEvent::final_response_with_total_tokens(6),
4308 ],
4309 ]);
4310 let recorded = model.clone();
4311 let agent = AgentBuilder::new(model)
4312 .tool(CountingAddTool {
4313 calls: add_calls.clone(),
4314 })
4315 .build();
4316
4317 let mut stream = agent
4318 .stream_prompt("use the tool")
4319 .add_hook(SkipDefaultApiHook)
4320 .max_turns(3)
4321 .history(Vec::<Message>::new())
4322 .await;
4323 let mut skipped_tool_result = None;
4324 let mut final_response_text = None;
4325
4326 while let Some(item) = stream.next().await {
4327 match item {
4328 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4329 tool_result,
4330 ..
4331 })) => {
4332 skipped_tool_result = Some(tool_result);
4333 }
4334 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4335 final_response_text = Some(response.output().to_string());
4336 break;
4337 }
4338 Ok(_) => {}
4339 Err(err) => panic!("unexpected streaming error: {err:?}"),
4340 }
4341 }
4342
4343 let skipped_tool_result =
4344 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4345 assert_eq!(skipped_tool_result.call, "call_2");
4348 assert!(
4349 skipped_tool_result
4350 .provider
4351 .as_ref()
4352 .is_some_and(|provider| {
4353 provider.call_id == "call_2"
4354 && provider.item_id.as_deref() == Some("tool_call_2")
4355 })
4356 );
4357 assert_eq!(final_response_text.as_deref(), Some("continued"));
4358 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4359
4360 let requests = recorded.requests();
4361 assert_eq!(requests.len(), 2);
4362 let follow_up_history = requests[1].chat_history.clone();
4363 assert_eq!(follow_up_history.len(), 3);
4364 assert!(matches!(
4365 follow_up_history.get(1),
4366 Some(Message::Assistant { content, .. })
4367 if content.iter().any(|item| matches!(
4368 item,
4369 AssistantContent::Text(text) if text.text == "checking "
4370 ))
4371 && content.iter().any(|item| matches!(
4372 item,
4373 AssistantContent::ToolCall(tool_call)
4374 if tool_call.id == "call_1"
4375 && tool_call.function.name == "add"
4376 ))
4377 && content.iter().any(|item| matches!(
4378 item,
4379 AssistantContent::ToolCall(tool_call)
4380 if tool_call.id == "call_2"
4381 && tool_call.function.name == "default_api"
4382 ))
4383 ));
4384 assert!(matches!(
4385 follow_up_history.get(2),
4386 Some(Message::User { content })
4387 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4388 && content.iter().any(|item| matches!(
4389 item,
4390 UserContent::ToolResult(result)
4391 if result.call == "call_1"
4392 && result.provider.as_ref().is_some_and(
4393 |provider| provider.call_id == "call_1"
4394 )
4395 && result.content.iter().any(|content| matches!(
4396 content,
4397 ToolResultContent::Text(text)
4398 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4399 ))
4400 ))
4401 && content.iter().any(|item| matches!(
4402 item,
4403 UserContent::ToolResult(result)
4404 if result.call == "call_2"
4405 && result.provider.as_ref().is_some_and(
4406 |provider| provider.call_id == "call_2"
4407 )
4408 && result.content.iter().any(|content| matches!(
4409 content,
4410 ToolResultContent::Text(text)
4411 if text.text == "default_api was skipped"
4412 ))
4413 ))
4414 ));
4415 assert_retry_transcript_ids_pair(
4416 follow_up_history.get(1).expect("assistant tool-call turn"),
4417 follow_up_history.get(2).expect("skip-result turn"),
4418 );
4419 }
4420
4421 #[tokio::test]
4422 async fn invalid_completed_tool_call_skip_preserves_streaming_reasoning_history() {
4423 let model = MockCompletionModel::from_stream_turns([
4424 vec![
4425 MockStreamEvent::text("checking "),
4426 MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
4427 MockStreamEvent::tool_call(
4428 "tool_call_1",
4429 "default_api",
4430 serde_json::json!({"x": 2, "y": 3}),
4431 ),
4432 MockStreamEvent::final_response_with_total_tokens(4),
4433 ],
4434 vec![
4435 MockStreamEvent::text("continued"),
4436 MockStreamEvent::final_response_with_total_tokens(6),
4437 ],
4438 ]);
4439 let recorded = model.clone();
4440 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4441
4442 let mut stream = agent
4443 .stream_prompt("use the tool")
4444 .add_hook(SkipDefaultApiHook)
4445 .max_turns(3)
4446 .history(Vec::<Message>::new())
4447 .await;
4448
4449 while let Some(item) = stream.next().await {
4450 match item {
4451 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4452 Ok(_) => {}
4453 Err(err) => panic!("unexpected streaming error: {err:?}"),
4454 }
4455 }
4456
4457 let requests = recorded.requests();
4458 assert_eq!(requests.len(), 2);
4459 let follow_up_history = requests[1].chat_history.clone();
4460 assert!(history_contains_text(&follow_up_history, "checking "));
4461 assert!(assistant_reasoning_precedes_tool_call(
4462 &follow_up_history,
4463 "reasoned step",
4464 "default_api"
4465 ));
4466 assert!(
4467 assistant_reasoning_precedes_text_and_tool_call(
4468 &follow_up_history,
4469 "reasoned step",
4470 "checking ",
4471 "default_api"
4472 ),
4473 "{follow_up_history:?}"
4474 );
4475 }
4476
4477 #[tokio::test]
4478 async fn invalid_name_delta_retry_preserves_streaming_reasoning_history() {
4479 let model = MockCompletionModel::from_stream_turns([
4480 vec![
4481 MockStreamEvent::reasoning_delta_with_id("rs_1", "delta reason"),
4482 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4483 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4484 MockStreamEvent::final_response_with_total_tokens(4),
4485 ],
4486 vec![
4487 MockStreamEvent::text("retried"),
4488 MockStreamEvent::final_response_with_total_tokens(6),
4489 ],
4490 ]);
4491 let recorded = model.clone();
4492 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4493
4494 let mut stream = agent
4495 .stream_prompt("use the tool")
4496 .add_hook(RetryDefaultApiHook)
4497 .max_turns(3)
4498 .history(Vec::<Message>::new())
4499 .max_invalid_tool_call_retries(1)
4500 .await;
4501
4502 while let Some(item) = stream.next().await {
4503 match item {
4504 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4505 Ok(_) => {}
4506 Err(err) => panic!("unexpected streaming error: {err:?}"),
4507 }
4508 }
4509
4510 let requests = recorded.requests();
4511 assert_eq!(requests.len(), 2);
4512 let retry_history = requests[1].chat_history.clone();
4513 assert!(assistant_reasoning_precedes_tool_call(
4514 &retry_history,
4515 "delta reason",
4516 "default_api"
4517 ));
4518 }
4519
4520 #[tokio::test]
4521 async fn invalid_tool_call_hook_skip_resets_streaming_text_delta_state() {
4522 let text_hook = RecordingTextDeltaHook::default();
4523 let model = MockCompletionModel::from_stream_turns([
4524 vec![
4525 MockStreamEvent::text("stale "),
4526 MockStreamEvent::tool_call(
4527 "tool_call_1",
4528 "default_api",
4529 serde_json::json!({"x": 2, "y": 3}),
4530 ),
4531 MockStreamEvent::final_response_with_total_tokens(4),
4532 ],
4533 vec![
4534 MockStreamEvent::text("fresh"),
4535 MockStreamEvent::final_response_with_total_tokens(6),
4536 ],
4537 ]);
4538 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4539
4540 let mut stream = agent
4541 .stream_prompt("use the tool")
4542 .add_hook(RecordingTextAndSkipInvalidToolHook {
4543 text: text_hook.clone(),
4544 })
4545 .max_turns(3)
4546 .history(Vec::<Message>::new())
4547 .await;
4548
4549 while let Some(item) = stream.next().await {
4550 match item {
4551 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4552 Ok(_) => {}
4553 Err(err) => panic!("unexpected streaming error: {err:?}"),
4554 }
4555 }
4556
4557 assert_eq!(
4558 text_hook.observed(),
4559 vec![
4560 ("stale ".to_string(), "stale ".to_string()),
4561 ("fresh".to_string(), "fresh".to_string()),
4562 ]
4563 );
4564 }
4565
4566 #[tokio::test]
4567 async fn invalid_tool_call_delta_retry_uses_structured_tool_feedback() {
4568 let delta_hook = RecordingToolCallDeltaHook::default();
4569 let add_calls = Arc::new(AtomicU32::new(0));
4570 let model = MockCompletionModel::from_stream_turns([
4571 vec![
4572 MockStreamEvent::text("checking "),
4573 MockStreamEvent::reasoning_delta_with_id("rs_1", "diagnostic reason"),
4574 MockStreamEvent::tool_call(
4575 "tool_call_0",
4576 "add",
4577 serde_json::json!({"x": 1, "y": 2}),
4578 )
4579 .with_call_id("call_0"),
4580 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4581 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4582 MockStreamEvent::final_response_with_total_tokens(4),
4583 ],
4584 vec![
4585 MockStreamEvent::text("retried"),
4586 MockStreamEvent::final_response_with_total_tokens(6),
4587 ],
4588 ]);
4589 let recorded = model.clone();
4590 let agent = AgentBuilder::new(model)
4591 .tool(CountingAddTool {
4592 calls: add_calls.clone(),
4593 })
4594 .build();
4595
4596 let mut stream = agent
4597 .stream_prompt("use the tool")
4598 .add_hook(RecordingDeltaAndRetryInvalidToolHook {
4599 delta: delta_hook.clone(),
4600 })
4601 .max_turns(3)
4602 .history(Vec::<Message>::new())
4603 .max_invalid_tool_call_retries(1)
4604 .await;
4605 let mut completion_call_events = Vec::new();
4606 let mut final_response_text = None;
4607 let mut final_response_usage = Usage::new();
4608 let mut final_completion_calls = Vec::new();
4609
4610 while let Some(item) = stream.next().await {
4611 match item {
4612 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
4613 completion_call_events.push(completion_call);
4614 }
4615 Ok(MultiTurnStreamItem::StreamAssistantItem(
4616 StreamedAssistantContent::ToolCallDelta { .. },
4617 )) => panic!("invalid tool-call delta should not be emitted"),
4618 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4619 final_response_text = Some(response.output().to_string());
4620 final_response_usage = response.usage();
4621 final_completion_calls = response.completion_calls().to_vec();
4622 break;
4623 }
4624 Ok(_) => {}
4625 Err(err) => panic!("unexpected streaming error: {err:?}"),
4626 }
4627 }
4628
4629 assert_eq!(final_response_text.as_deref(), Some("retried"));
4630 assert!(delta_hook.observed().is_empty());
4631 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4632 let mut first_usage = Usage::new();
4633 first_usage.total_tokens = 4;
4634 let mut second_usage = Usage::new();
4635 second_usage.total_tokens = 6;
4636 let expected_completion_calls = vec![
4637 streamed_call(0, first_usage),
4638 streamed_call(1, second_usage),
4639 ];
4640 assert_eq!(completion_call_events, expected_completion_calls);
4641 assert_eq!(final_completion_calls, expected_completion_calls);
4642 assert_eq!(final_response_usage.total_tokens, 10);
4643
4644 let requests = recorded.requests();
4645 assert_eq!(requests.len(), 2);
4646 let retry_history = requests[1].chat_history.clone();
4647 assert!(matches!(
4648 retry_history.get(1),
4649 Some(Message::Assistant { content, .. })
4650 if content.iter().any(|item| matches!(
4651 item,
4652 AssistantContent::Text(text) if text.text == "checking "
4653 ))
4654 && content.iter().any(|item| matches!(
4655 item,
4656 AssistantContent::ToolCall(tool_call)
4657 if tool_call.id == "call_0"
4658 && tool_call.function.name == "add"
4659 ))
4660 && content.iter().any(|item| matches!(
4661 item,
4662 AssistantContent::ToolCall(tool_call)
4667 if !tool_call.id.is_empty()
4668 && tool_call.provider.is_none()
4669 && tool_call.function.name == "default_api"
4670 && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
4671 ))
4672 ));
4673 assert!(matches!(
4674 retry_history.get(2),
4675 Some(Message::User { content })
4676 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4677 && content.iter().any(|item| matches!(
4678 item,
4679 UserContent::ToolResult(result)
4680 if result.call == "call_0"
4681 && result.provider.as_ref().is_some_and(
4682 |provider| provider.call_id == "call_0"
4683 )
4684 && result.content.iter().any(|content| matches!(
4685 content,
4686 ToolResultContent::Text(text)
4687 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4688 ))
4689 ))
4690 && content.iter().any(|item| matches!(
4691 item,
4692 UserContent::ToolResult(result)
4693 if !result.call.is_empty()
4694 && result.name == "default_api"
4695 && result.content.iter().any(|content| matches!(
4696 content,
4697 ToolResultContent::Text(text)
4698 if text.text == "Use the add tool instead"
4699 ))
4700 ))
4701 ));
4702 assert_retry_transcript_ids_pair(
4703 retry_history.get(1).expect("assistant tool-call turn"),
4704 retry_history.get(2).expect("retry-result turn"),
4705 );
4706 }
4707
4708 #[tokio::test]
4709 async fn invalid_tool_call_delta_context_includes_same_turn_history_and_tool_call_id() {
4710 let invalid_hook = RecordingInvalidToolCallHook::default();
4711 let model = MockCompletionModel::from_stream_turns([
4712 vec![
4713 MockStreamEvent::text("checking "),
4714 MockStreamEvent::reasoning_delta_with_id("rs_1", "diagnostic reason"),
4715 MockStreamEvent::tool_call(
4716 "tool_call_0",
4717 "add",
4718 serde_json::json!({"x": 1, "y": 2}),
4719 )
4720 .with_call_id("call_0"),
4721 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4722 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4723 MockStreamEvent::final_response_with_total_tokens(4),
4724 ],
4725 vec![
4726 MockStreamEvent::text("should not be requested"),
4727 MockStreamEvent::final_response_with_total_tokens(6),
4728 ],
4729 ]);
4730 let recorded = model.clone();
4731 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4732
4733 let mut stream = agent
4734 .stream_prompt("use the tool")
4735 .add_hook(invalid_hook.clone())
4736 .max_turns(3)
4737 .await;
4738 let mut error = None;
4739
4740 while let Some(item) = stream.next().await {
4741 if let Err(err) = item {
4742 error = Some(err);
4743 break;
4744 }
4745 }
4746
4747 assert!(error.is_some(), "invalid name delta should fail");
4748 assert_eq!(recorded.request_count(), 1);
4749 let contexts = invalid_hook.observed();
4750 assert_eq!(contexts.len(), 1);
4751 let context = &contexts[0];
4752 assert_eq!(context.tool_name, "default_api");
4753 assert!(
4758 context
4759 .tool_call_id
4760 .as_deref()
4761 .is_some_and(|id| !id.is_empty()),
4762 "an unfinished call still carries a non-empty minted durable id, got {:?}",
4763 context.tool_call_id
4764 );
4765 assert!(
4766 context
4767 .internal_call_id
4768 .as_deref()
4769 .is_some_and(|id| !id.is_empty()),
4770 "internal call id is minted by the shared accumulator"
4771 );
4772 assert!(context.is_streaming);
4773 assert!(history_contains_text(&context.chat_history, "checking "));
4774 assert!(
4775 assistant_reasoning_precedes_tool_call(
4776 &context.chat_history,
4777 "diagnostic reason",
4778 "add"
4779 ),
4780 "{:?}",
4781 context.chat_history
4782 );
4783 assert!(history_contains_tool_call(&context.chat_history, "add"));
4784 assert!(history_contains_tool_call(
4785 &context.chat_history,
4786 "default_api"
4787 ));
4788 }
4789
4790 #[tokio::test]
4791 async fn invalid_tool_call_delta_retry_resets_streaming_text_delta_state() {
4792 let text_hook = RecordingTextDeltaHook::default();
4793 let model = MockCompletionModel::from_stream_turns([
4794 vec![
4795 MockStreamEvent::text("stale "),
4796 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4797 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4798 MockStreamEvent::final_response_with_total_tokens(4),
4799 ],
4800 vec![
4801 MockStreamEvent::text("fresh"),
4802 MockStreamEvent::final_response_with_total_tokens(6),
4803 ],
4804 ]);
4805 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4806
4807 let mut stream = agent
4808 .stream_prompt("use the tool")
4809 .add_hook(RecordingTextAndRetryInvalidToolHook {
4810 text: text_hook.clone(),
4811 })
4812 .max_turns(3)
4813 .history(Vec::<Message>::new())
4814 .max_invalid_tool_call_retries(1)
4815 .await;
4816
4817 while let Some(item) = stream.next().await {
4818 match item {
4819 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4820 Ok(_) => {}
4821 Err(err) => panic!("unexpected streaming error: {err:?}"),
4822 }
4823 }
4824
4825 assert_eq!(
4826 text_hook.observed(),
4827 vec![
4828 ("stale ".to_string(), "stale ".to_string()),
4829 ("fresh".to_string(), "fresh".to_string()),
4830 ]
4831 );
4832 }
4833
4834 #[tokio::test]
4835 async fn invalid_tool_call_delta_skip_uses_structured_tool_feedback() {
4836 let delta_hook = RecordingToolCallDeltaHook::default();
4837 let add_calls = Arc::new(AtomicU32::new(0));
4838 let model = MockCompletionModel::from_stream_turns([
4839 vec![
4840 MockStreamEvent::text("checking "),
4841 MockStreamEvent::tool_call(
4842 "tool_call_0",
4843 "add",
4844 serde_json::json!({"x": 1, "y": 2}),
4845 )
4846 .with_call_id("call_0"),
4847 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
4848 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
4849 MockStreamEvent::final_response_with_total_tokens(4),
4850 ],
4851 vec![
4852 MockStreamEvent::text("continued"),
4853 MockStreamEvent::final_response_with_total_tokens(6),
4854 ],
4855 ]);
4856 let recorded = model.clone();
4857 let agent = AgentBuilder::new(model)
4858 .tool(CountingAddTool {
4859 calls: add_calls.clone(),
4860 })
4861 .build();
4862
4863 let mut stream = agent
4864 .stream_prompt("use the tool")
4865 .add_hook(RecordingDeltaAndSkipInvalidToolHook {
4866 delta: delta_hook.clone(),
4867 })
4868 .max_turns(3)
4869 .history(Vec::<Message>::new())
4870 .await;
4871 let mut skipped_tool_result = None;
4872 let mut final_response_text = None;
4873
4874 while let Some(item) = stream.next().await {
4875 match item {
4876 Ok(MultiTurnStreamItem::StreamAssistantItem(
4877 StreamedAssistantContent::ToolCallDelta { .. },
4878 )) => panic!("invalid tool-call delta should not be emitted"),
4879 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4880 tool_result,
4881 internal_call_id,
4882 })) => {
4883 assert!(
4884 !internal_call_id.is_empty(),
4885 "internal call id is minted by the shared accumulator"
4886 );
4887 skipped_tool_result = Some(tool_result);
4888 }
4889 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4890 final_response_text = Some(response.output().to_string());
4891 break;
4892 }
4893 Ok(_) => {}
4894 Err(err) => panic!("unexpected streaming error: {err:?}"),
4895 }
4896 }
4897
4898 let skipped_tool_result =
4899 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
4900 assert!(!skipped_tool_result.call.is_empty());
4905 assert_eq!(skipped_tool_result.name, "default_api");
4906 assert!(skipped_tool_result.provider.is_none());
4907 assert!(skipped_tool_result.content.iter().any(|content| matches!(
4908 content,
4909 ToolResultContent::Text(text) if text.text == "default_api was skipped"
4910 )));
4911 assert_eq!(final_response_text.as_deref(), Some("continued"));
4912 assert!(delta_hook.observed().is_empty());
4913 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4914
4915 let requests = recorded.requests();
4916 assert_eq!(requests.len(), 2);
4917 let follow_up_history = requests[1].chat_history.clone();
4918 assert!(matches!(
4919 follow_up_history.get(1),
4920 Some(Message::Assistant { content, .. })
4921 if content.iter().any(|item| matches!(
4922 item,
4923 AssistantContent::Text(text) if text.text == "checking "
4924 ))
4925 && content.iter().any(|item| matches!(
4926 item,
4927 AssistantContent::ToolCall(tool_call)
4928 if tool_call.id == "call_0"
4929 && tool_call.function.name == "add"
4930 ))
4931 && content.iter().any(|item| matches!(
4932 item,
4933 AssistantContent::ToolCall(tool_call)
4938 if !tool_call.id.is_empty()
4939 && tool_call.provider.is_none()
4940 && tool_call.function.name == "default_api"
4941 && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
4942 ))
4943 ));
4944 assert!(matches!(
4945 follow_up_history.get(2),
4946 Some(Message::User { content })
4947 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
4948 && content.iter().any(|item| matches!(
4949 item,
4950 UserContent::ToolResult(result)
4951 if result.call == "call_0"
4952 && result.provider.as_ref().is_some_and(
4953 |provider| provider.call_id == "call_0"
4954 )
4955 && result.content.iter().any(|content| matches!(
4956 content,
4957 ToolResultContent::Text(text)
4958 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
4959 ))
4960 ))
4961 && content.iter().any(|item| matches!(
4962 item,
4963 UserContent::ToolResult(result)
4964 if !result.call.is_empty()
4965 && result.name == "default_api"
4966 && result.content.iter().any(|content| matches!(
4967 content,
4968 ToolResultContent::Text(text)
4969 if text.text == "default_api was skipped"
4970 ))
4971 ))
4972 ));
4973 assert_retry_transcript_ids_pair(
4974 follow_up_history.get(1).expect("assistant tool-call turn"),
4975 follow_up_history.get(2).expect("skip-result turn"),
4976 );
4977 }
4978
4979 #[tokio::test]
4980 async fn streaming_retry_budget_exhaustion_history_contains_invalid_tool_call() {
4981 let model = MockCompletionModel::from_stream_turns([
4982 vec![
4983 MockStreamEvent::tool_call(
4984 "tool_call_1",
4985 "default_api",
4986 serde_json::json!({"x": 1, "y": 2}),
4987 ),
4988 MockStreamEvent::final_response_with_total_tokens(4),
4989 ],
4990 vec![
4991 MockStreamEvent::text("should not be requested"),
4992 MockStreamEvent::final_response_with_total_tokens(6),
4993 ],
4994 ]);
4995 let recorded = model.clone();
4996 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4997
4998 let mut stream = agent
4999 .stream_prompt("use the tool")
5000 .add_hook(RetryDefaultApiHook)
5001 .max_turns(3)
5002 .max_invalid_tool_call_retries(0)
5003 .await;
5004 let mut error = None;
5005
5006 while let Some(item) = stream.next().await {
5007 if let Err(err) = item {
5008 error = Some(err);
5009 break;
5010 }
5011 }
5012
5013 let error = error.expect("retry budget exhaustion should fail");
5014 match error {
5015 StreamingError::Prompt(err) => match *err {
5016 PromptError::UnknownToolCall {
5017 tool_name,
5018 chat_history,
5019 ..
5020 } => {
5021 assert_eq!(tool_name, "default_api");
5022 assert!(history_contains_tool_call(&chat_history, "default_api"));
5023 }
5024 other => panic!("expected UnknownToolCall, got {other:?}"),
5025 },
5026 other => panic!("expected prompt streaming error, got {other:?}"),
5027 }
5028 assert_eq!(recorded.request_count(), 1);
5029 }
5030
5031 #[tokio::test]
5032 async fn streaming_name_delta_retry_budget_exhaustion_history_includes_same_turn_context() {
5033 let model = MockCompletionModel::from_stream_turns([
5034 vec![
5035 MockStreamEvent::text("checking "),
5036 MockStreamEvent::tool_call(
5037 "tool_call_0",
5038 "add",
5039 serde_json::json!({"x": 1, "y": 2}),
5040 )
5041 .with_call_id("call_0"),
5042 MockStreamEvent::tool_call_arguments_delta("tool_call_1", r#"{"x":2,"y":3}"#),
5043 MockStreamEvent::tool_call_name_delta("tool_call_1", "default_api"),
5044 MockStreamEvent::final_response_with_total_tokens(4),
5045 ],
5046 vec![
5047 MockStreamEvent::text("should not be requested"),
5048 MockStreamEvent::final_response_with_total_tokens(6),
5049 ],
5050 ]);
5051 let recorded = model.clone();
5052 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5053
5054 let mut stream = agent
5055 .stream_prompt("use the tool")
5056 .add_hook(RetryDefaultApiHook)
5057 .max_turns(3)
5058 .max_invalid_tool_call_retries(0)
5059 .await;
5060 let mut error = None;
5061
5062 while let Some(item) = stream.next().await {
5063 if let Err(err) = item {
5064 error = Some(err);
5065 break;
5066 }
5067 }
5068
5069 let error = error.expect("retry budget exhaustion should fail");
5070 match error {
5071 StreamingError::Prompt(err) => match *err {
5072 PromptError::UnknownToolCall {
5073 tool_name,
5074 chat_history,
5075 ..
5076 } => {
5077 assert_eq!(tool_name, "default_api");
5078 assert!(history_contains_text(&chat_history, "checking "));
5079 assert!(history_contains_tool_call(&chat_history, "add"));
5080 assert!(history_contains_tool_call(&chat_history, "default_api"));
5081 }
5082 other => panic!("expected UnknownToolCall, got {other:?}"),
5083 },
5084 other => panic!("expected prompt streaming error, got {other:?}"),
5085 }
5086 assert_eq!(recorded.request_count(), 1);
5087 }
5088
5089 #[tokio::test]
5090 async fn completed_unknown_tool_call_after_text_fails_before_finish_hook_or_later_emit() {
5091 let add_calls = Arc::new(AtomicU32::new(0));
5092 let model = MockCompletionModel::from_stream_turns([
5093 vec![
5094 MockStreamEvent::text("thinking "),
5095 MockStreamEvent::tool_call(
5096 "tool_call_1",
5097 "default_api",
5098 serde_json::json!({"x": 1, "y": 2}),
5099 ),
5100 MockStreamEvent::final_response_with_total_tokens(4),
5101 ],
5102 vec![
5103 MockStreamEvent::text("should not be requested"),
5104 MockStreamEvent::final_response_with_total_tokens(6),
5105 ],
5106 ]);
5107 let recorded = model.clone();
5108 let agent = AgentBuilder::new(model)
5109 .tool(CountingAddTool {
5110 calls: add_calls.clone(),
5111 })
5112 .build();
5113
5114 let mut stream = agent
5115 .stream_prompt("use the tool")
5116 .add_hook(PanicOnUnknownToolHook)
5117 .max_turns(3)
5118 .await;
5119 let mut saw_text = false;
5120 let mut saw_completion_call = false;
5121 let mut saw_final_response = false;
5122 let mut saw_tool_call = false;
5123 let mut saw_tool_result = false;
5124 let mut error = None;
5125
5126 while let Some(item) = stream.next().await {
5127 match item {
5128 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(_))) => {
5129 saw_text = true;
5130 }
5131 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5132 saw_completion_call = true;
5133 }
5134 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Final(
5135 _,
5136 )))
5137 | Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5138 saw_final_response = true;
5139 }
5140 Ok(MultiTurnStreamItem::StreamAssistantItem(
5141 StreamedAssistantContent::ToolCall { .. },
5142 )) => {
5143 saw_tool_call = true;
5144 }
5145 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5146 ..
5147 })) => {
5148 saw_tool_result = true;
5149 }
5150 Ok(_) => {}
5151 Err(err) => {
5152 error = Some(err);
5153 break;
5154 }
5155 }
5156 }
5157
5158 assert!(saw_text);
5159 assert!(!saw_completion_call);
5160 assert!(!saw_final_response);
5161 assert!(!saw_tool_call);
5162 assert!(!saw_tool_result);
5163 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5164 let error = error.expect("completed unknown tool call should fail immediately");
5165 match error {
5166 StreamingError::Prompt(err) => match *err {
5167 PromptError::UnknownToolCall {
5168 tool_name,
5169 available_tools,
5170 allowed_tools,
5171 chat_history,
5172 } => {
5173 assert_eq!(tool_name, "default_api");
5174 assert_eq!(available_tools, vec!["add".to_string()]);
5175 assert_eq!(allowed_tools, vec!["add".to_string()]);
5176 assert!(history_contains_tool_call(&chat_history, "default_api"));
5177 }
5178 other => panic!("expected UnknownToolCall, got {other:?}"),
5179 },
5180 other => panic!("expected prompt streaming error, got {other:?}"),
5181 }
5182 assert_eq!(recorded.request_count(), 1);
5183 }
5184
5185 #[tokio::test]
5186 async fn mixed_streaming_tool_calls_fail_before_any_tool_execution() {
5187 let add_calls = Arc::new(AtomicU32::new(0));
5188 let model = MockCompletionModel::from_stream_turns([
5189 vec![
5190 MockStreamEvent::tool_call(
5191 "tool_call_1",
5192 "add",
5193 serde_json::json!({"x": 1, "y": 2}),
5194 )
5195 .with_call_id("call_1"),
5196 MockStreamEvent::tool_call(
5197 "tool_call_2",
5198 "default_api",
5199 serde_json::json!({"x": 3, "y": 4}),
5200 ),
5201 MockStreamEvent::final_response_with_total_tokens(4),
5202 ],
5203 vec![
5204 MockStreamEvent::text("should not be requested"),
5205 MockStreamEvent::final_response_with_total_tokens(6),
5206 ],
5207 ]);
5208 let recorded = model.clone();
5209 let agent = AgentBuilder::new(model)
5210 .tool(CountingAddTool {
5211 calls: add_calls.clone(),
5212 })
5213 .build();
5214
5215 let mut stream = agent
5216 .stream_prompt("use tools")
5217 .add_hook(PanicOnUnknownToolHook)
5218 .max_turns(3)
5219 .await;
5220 let mut saw_completion_call = false;
5221 let mut saw_tool_call = false;
5222 let mut saw_tool_result = false;
5223 let mut error = None;
5224
5225 while let Some(item) = stream.next().await {
5226 match item {
5227 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5228 saw_completion_call = true;
5229 }
5230 Ok(MultiTurnStreamItem::StreamAssistantItem(
5231 StreamedAssistantContent::ToolCall { .. },
5232 )) => {
5233 saw_tool_call = true;
5234 }
5235 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5236 ..
5237 })) => {
5238 saw_tool_result = true;
5239 }
5240 Ok(_) => {}
5241 Err(err) => {
5242 error = Some(err);
5243 break;
5244 }
5245 }
5246 }
5247
5248 assert!(!saw_completion_call);
5249 assert!(!saw_tool_call);
5250 assert!(!saw_tool_result);
5251 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5252 let error = error.expect("mixed unknown streamed tool call should fail");
5253 match error {
5254 StreamingError::Prompt(err) => match *err {
5255 PromptError::UnknownToolCall {
5256 tool_name,
5257 available_tools,
5258 allowed_tools,
5259 chat_history,
5260 } => {
5261 assert_eq!(tool_name, "default_api");
5262 assert_eq!(available_tools, vec!["add".to_string()]);
5263 assert_eq!(allowed_tools, vec!["add".to_string()]);
5264 assert!(history_contains_tool_call(&chat_history, "default_api"));
5265 }
5266 other => panic!("expected UnknownToolCall, got {other:?}"),
5267 },
5268 other => panic!("expected prompt streaming error, got {other:?}"),
5269 }
5270 assert_eq!(recorded.request_count(), 1);
5271 }
5272
5273 #[tokio::test]
5274 async fn multiple_valid_streaming_tool_calls_execute_after_batch_validation() {
5275 let add_calls = Arc::new(AtomicU32::new(0));
5276 let subtract_calls = Arc::new(AtomicU32::new(0));
5277 let model = MockCompletionModel::from_stream_turns([
5278 vec![
5279 MockStreamEvent::tool_call(
5280 "tool_call_1",
5281 "add",
5282 serde_json::json!({"x": 1, "y": 2}),
5283 )
5284 .with_call_id("call_1"),
5285 MockStreamEvent::tool_call(
5286 "tool_call_2",
5287 "subtract",
5288 serde_json::json!({"x": 8, "y": 3}),
5289 )
5290 .with_call_id("call_2"),
5291 MockStreamEvent::final_response_with_total_tokens(4),
5292 ],
5293 vec![
5294 MockStreamEvent::text("done"),
5295 MockStreamEvent::final_response_with_total_tokens(6),
5296 ],
5297 ]);
5298 let recorded = model.clone();
5299 let agent = AgentBuilder::new(model)
5300 .tool(CountingAddTool {
5301 calls: add_calls.clone(),
5302 })
5303 .tool(CountingSubtractTool {
5304 calls: subtract_calls.clone(),
5305 })
5306 .build();
5307
5308 let mut stream = agent.stream_prompt("use tools").max_turns(3).await;
5309 let mut tool_call_names = Vec::new();
5310 let mut tool_result_ids = Vec::new();
5311 let mut final_response_text = None;
5312
5313 while let Some(item) = stream.next().await {
5314 match item {
5315 Ok(MultiTurnStreamItem::StreamAssistantItem(
5316 StreamedAssistantContent::ToolCall { tool_call, .. },
5317 )) => {
5318 tool_call_names.push(tool_call.function.name);
5319 }
5320 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5321 tool_result,
5322 ..
5323 })) => {
5324 tool_result_ids.push(tool_result.call.into_string());
5325 }
5326 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5327 final_response_text = Some(response.output().to_owned());
5328 break;
5329 }
5330 Ok(_) => {}
5331 Err(err) => panic!("unexpected streaming error: {err:?}"),
5332 }
5333 }
5334
5335 assert_eq!(
5336 tool_call_names,
5337 vec!["add".to_string(), "subtract".to_string()]
5338 );
5339 assert_eq!(
5341 tool_result_ids,
5342 vec!["call_1".to_string(), "call_2".to_string()]
5343 );
5344 assert_eq!(add_calls.load(Ordering::SeqCst), 1);
5345 assert_eq!(subtract_calls.load(Ordering::SeqCst), 1);
5346 assert_eq!(final_response_text.as_deref(), Some("done"));
5347 assert_eq!(recorded.request_count(), 2);
5348 }
5349
5350 #[tokio::test]
5351 async fn disallowed_specific_tool_call_fails_before_streaming_second_request() {
5352 let model = MockCompletionModel::from_stream_turns([
5353 vec![
5354 MockStreamEvent::tool_call(
5355 "tool_call_1",
5356 "subtract",
5357 serde_json::json!({"x": 3, "y": 1}),
5358 ),
5359 MockStreamEvent::final_response_with_total_tokens(4),
5360 ],
5361 vec![
5362 MockStreamEvent::text("should not be requested"),
5363 MockStreamEvent::final_response_with_total_tokens(6),
5364 ],
5365 ]);
5366 let recorded = model.clone();
5367 let agent = AgentBuilder::new(model)
5368 .tool(MockAddTool)
5369 .tool(MockSubtractTool)
5370 .tool_choice(ToolChoice::Specific {
5371 function_names: vec!["add".to_string()],
5372 })
5373 .build();
5374
5375 let mut stream = agent
5376 .stream_prompt("use the allowed tool")
5377 .add_hook(PanicOnUnknownToolHook)
5378 .max_turns(3)
5379 .await;
5380 let mut saw_tool_call = false;
5381 let mut error = None;
5382
5383 while let Some(item) = stream.next().await {
5384 match item {
5385 Ok(MultiTurnStreamItem::StreamAssistantItem(
5386 StreamedAssistantContent::ToolCall { .. },
5387 )) => {
5388 saw_tool_call = true;
5389 }
5390 Ok(_) => {}
5391 Err(err) => {
5392 error = Some(err);
5393 break;
5394 }
5395 }
5396 }
5397
5398 assert!(!saw_tool_call);
5399 let error = error.expect("disallowed model-emitted tool should fail");
5400 match error {
5401 StreamingError::Prompt(err) => match *err {
5402 PromptError::UnknownToolCall {
5403 tool_name,
5404 available_tools,
5405 allowed_tools,
5406 chat_history,
5407 } => {
5408 assert_eq!(tool_name, "subtract");
5409 assert_eq!(
5410 available_tools,
5411 vec!["add".to_string(), "subtract".to_string()]
5412 );
5413 assert_eq!(allowed_tools, vec!["add".to_string()]);
5414 assert!(history_contains_tool_call(&chat_history, "subtract"));
5415 }
5416 other => panic!("expected UnknownToolCall, got {other:?}"),
5417 },
5418 other => panic!("expected prompt streaming error, got {other:?}"),
5419 }
5420 assert_eq!(recorded.request_count(), 1);
5421 }
5422
5423 #[tokio::test]
5424 async fn mixed_specific_tool_calls_fail_before_any_tool_execution() {
5425 let add_calls = Arc::new(AtomicU32::new(0));
5426 let model = MockCompletionModel::from_stream_turns([
5427 vec![
5428 MockStreamEvent::tool_call(
5429 "tool_call_1",
5430 "add",
5431 serde_json::json!({"x": 1, "y": 2}),
5432 ),
5433 MockStreamEvent::tool_call(
5434 "tool_call_2",
5435 "subtract",
5436 serde_json::json!({"x": 3, "y": 1}),
5437 ),
5438 MockStreamEvent::final_response_with_total_tokens(4),
5439 ],
5440 vec![
5441 MockStreamEvent::text("should not be requested"),
5442 MockStreamEvent::final_response_with_total_tokens(6),
5443 ],
5444 ]);
5445 let recorded = model.clone();
5446 let agent = AgentBuilder::new(model)
5447 .tool(CountingAddTool {
5448 calls: add_calls.clone(),
5449 })
5450 .tool(MockSubtractTool)
5451 .tool_choice(ToolChoice::Specific {
5452 function_names: vec!["add".to_string()],
5453 })
5454 .build();
5455
5456 let mut stream = agent
5457 .stream_prompt("use the allowed tool")
5458 .add_hook(PanicOnUnknownToolHook)
5459 .max_turns(3)
5460 .await;
5461 let mut saw_tool_call = false;
5462 let mut saw_tool_result = false;
5463 let mut error = None;
5464
5465 while let Some(item) = stream.next().await {
5466 match item {
5467 Ok(MultiTurnStreamItem::StreamAssistantItem(
5468 StreamedAssistantContent::ToolCall { .. },
5469 )) => {
5470 saw_tool_call = true;
5471 }
5472 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
5473 ..
5474 })) => {
5475 saw_tool_result = true;
5476 }
5477 Ok(_) => {}
5478 Err(err) => {
5479 error = Some(err);
5480 break;
5481 }
5482 }
5483 }
5484
5485 assert!(!saw_tool_call);
5486 assert!(!saw_tool_result);
5487 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
5488 let error = error.expect("mixed disallowed streamed tool call should fail");
5489 match error {
5490 StreamingError::Prompt(err) => match *err {
5491 PromptError::UnknownToolCall {
5492 tool_name,
5493 available_tools,
5494 allowed_tools,
5495 chat_history,
5496 } => {
5497 assert_eq!(tool_name, "subtract");
5498 assert_eq!(
5499 available_tools,
5500 vec!["add".to_string(), "subtract".to_string()]
5501 );
5502 assert_eq!(allowed_tools, vec!["add".to_string()]);
5503 assert!(history_contains_tool_call(&chat_history, "subtract"));
5504 }
5505 other => panic!("expected UnknownToolCall, got {other:?}"),
5506 },
5507 other => panic!("expected prompt streaming error, got {other:?}"),
5508 }
5509 assert_eq!(recorded.request_count(), 1);
5510 }
5511
5512 #[tokio::test]
5513 async fn tool_choice_none_rejects_streaming_tool_call() {
5514 let model = MockCompletionModel::from_stream_turns([
5515 vec![
5516 MockStreamEvent::tool_call(
5517 "tool_call_1",
5518 "add",
5519 serde_json::json!({"x": 1, "y": 2}),
5520 ),
5521 MockStreamEvent::final_response_with_total_tokens(4),
5522 ],
5523 vec![
5524 MockStreamEvent::text("should not be requested"),
5525 MockStreamEvent::final_response_with_total_tokens(6),
5526 ],
5527 ]);
5528 let recorded = model.clone();
5529 let agent = AgentBuilder::new(model)
5530 .tool(MockAddTool)
5531 .tool_choice(ToolChoice::None)
5532 .build();
5533
5534 let mut stream = agent
5535 .stream_prompt("do not use tools")
5536 .add_hook(PanicOnUnknownToolHook)
5537 .max_turns(3)
5538 .await;
5539 let mut saw_tool_call = false;
5540 let mut error = None;
5541
5542 while let Some(item) = stream.next().await {
5543 match item {
5544 Ok(MultiTurnStreamItem::StreamAssistantItem(
5545 StreamedAssistantContent::ToolCall { .. },
5546 )) => {
5547 saw_tool_call = true;
5548 }
5549 Ok(_) => {}
5550 Err(err) => {
5551 error = Some(err);
5552 break;
5553 }
5554 }
5555 }
5556
5557 assert!(!saw_tool_call);
5558 let error = error.expect("ToolChoice::None should reject returned tool calls");
5559 match error {
5560 StreamingError::Prompt(err) => match *err {
5561 PromptError::UnknownToolCall {
5562 tool_name,
5563 available_tools,
5564 allowed_tools,
5565 chat_history,
5566 } => {
5567 assert_eq!(tool_name, "add");
5568 assert_eq!(available_tools, vec!["add".to_string()]);
5569 assert!(allowed_tools.is_empty());
5570 assert!(history_contains_tool_call(&chat_history, "add"));
5571 }
5572 other => panic!("expected UnknownToolCall, got {other:?}"),
5573 },
5574 other => panic!("expected prompt streaming error, got {other:?}"),
5575 }
5576 assert_eq!(recorded.request_count(), 1);
5577 }
5578
5579 #[tokio::test]
5580 async fn tool_choice_none_rejects_streaming_tool_call_name_delta_before_hook_or_emit() {
5581 let model = MockCompletionModel::from_stream_turns([
5582 vec![
5583 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5584 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5585 MockStreamEvent::final_response_with_total_tokens(4),
5586 ],
5587 vec![
5588 MockStreamEvent::text("should not be requested"),
5589 MockStreamEvent::final_response_with_total_tokens(6),
5590 ],
5591 ]);
5592 let recorded = model.clone();
5593 let agent = AgentBuilder::new(model)
5594 .tool(MockAddTool)
5595 .tool_choice(ToolChoice::None)
5596 .build();
5597
5598 let mut stream = agent
5599 .stream_prompt("do not use tools")
5600 .add_hook(PanicOnUnknownToolHook)
5601 .max_turns(3)
5602 .await;
5603 let mut saw_delta = false;
5604 let mut error = None;
5605
5606 while let Some(item) = stream.next().await {
5607 match item {
5608 Ok(MultiTurnStreamItem::StreamAssistantItem(
5609 StreamedAssistantContent::ToolCallDelta { .. },
5610 )) => {
5611 saw_delta = true;
5612 }
5613 Ok(_) => {}
5614 Err(err) => {
5615 error = Some(err);
5616 break;
5617 }
5618 }
5619 }
5620
5621 assert!(!saw_delta);
5622 let error = error.expect("ToolChoice::None should reject returned tool-call deltas");
5623 match error {
5624 StreamingError::Prompt(err) => match *err {
5625 PromptError::UnknownToolCall {
5626 tool_name,
5627 available_tools,
5628 allowed_tools,
5629 chat_history,
5630 } => {
5631 assert_eq!(tool_name, "add");
5632 assert_eq!(available_tools, vec!["add".to_string()]);
5633 assert!(allowed_tools.is_empty());
5634 assert!(history_contains_tool_call(&chat_history, "add"));
5635 }
5636 other => panic!("expected UnknownToolCall, got {other:?}"),
5637 },
5638 other => panic!("expected prompt streaming error, got {other:?}"),
5639 }
5640 assert_eq!(recorded.request_count(), 1);
5641 }
5642
5643 #[tokio::test]
5644 async fn unknown_tool_call_name_delta_fails_before_streaming_delta_hook_or_emit() {
5645 let model = MockCompletionModel::from_stream_turns([
5646 vec![
5647 MockStreamEvent::tool_call_name_delta("tool_1", "default_api"),
5648 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5649 MockStreamEvent::final_response_with_total_tokens(4),
5650 ],
5651 vec![
5652 MockStreamEvent::text("should not be requested"),
5653 MockStreamEvent::final_response_with_total_tokens(6),
5654 ],
5655 ]);
5656 let recorded = model.clone();
5657 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5658
5659 let mut stream = agent
5660 .stream_prompt("stream a bad tool call")
5661 .add_hook(PanicOnUnknownToolHook)
5662 .max_turns(3)
5663 .await;
5664 let mut saw_delta = false;
5665 let mut error = None;
5666
5667 while let Some(item) = stream.next().await {
5668 match item {
5669 Ok(MultiTurnStreamItem::StreamAssistantItem(
5670 StreamedAssistantContent::ToolCallDelta { .. },
5671 )) => {
5672 saw_delta = true;
5673 }
5674 Ok(_) => {}
5675 Err(err) => {
5676 error = Some(err);
5677 break;
5678 }
5679 }
5680 }
5681
5682 assert!(!saw_delta);
5683 let error = error.expect("unknown tool-call name delta should fail");
5684 match error {
5685 StreamingError::Prompt(err) => match *err {
5686 PromptError::UnknownToolCall {
5687 tool_name,
5688 available_tools,
5689 allowed_tools,
5690 chat_history,
5691 } => {
5692 assert_eq!(tool_name, "default_api");
5693 assert_eq!(available_tools, vec!["add".to_string()]);
5694 assert_eq!(allowed_tools, vec!["add".to_string()]);
5695 assert!(history_contains_tool_call(&chat_history, "default_api"));
5696 }
5697 other => panic!("expected UnknownToolCall, got {other:?}"),
5698 },
5699 other => panic!("expected prompt streaming error, got {other:?}"),
5700 }
5701 assert_eq!(recorded.request_count(), 1);
5702 }
5703
5704 #[tokio::test]
5705 async fn tool_call_args_delta_before_unknown_name_fails_before_hook_or_emit() {
5706 let model = MockCompletionModel::from_stream_turns([
5707 vec![
5708 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5709 MockStreamEvent::tool_call_name_delta("tool_1", "default_api"),
5710 MockStreamEvent::final_response_with_total_tokens(4),
5711 ],
5712 vec![
5713 MockStreamEvent::text("should not be requested"),
5714 MockStreamEvent::final_response_with_total_tokens(6),
5715 ],
5716 ]);
5717 let recorded = model.clone();
5718 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5719
5720 let mut stream = agent
5721 .stream_prompt("stream a bad tool call")
5722 .add_hook(PanicOnUnknownToolHook)
5723 .max_turns(3)
5724 .await;
5725 let mut saw_delta = false;
5726 let mut error = None;
5727
5728 while let Some(item) = stream.next().await {
5729 match item {
5730 Ok(MultiTurnStreamItem::StreamAssistantItem(
5731 StreamedAssistantContent::ToolCallDelta { .. },
5732 )) => {
5733 saw_delta = true;
5734 }
5735 Ok(_) => {}
5736 Err(err) => {
5737 error = Some(err);
5738 break;
5739 }
5740 }
5741 }
5742
5743 assert!(!saw_delta);
5744 let error = error.expect("unknown tool-call name should reject buffered args");
5745 match error {
5746 StreamingError::Prompt(err) => match *err {
5747 PromptError::UnknownToolCall {
5748 tool_name,
5749 available_tools,
5750 allowed_tools,
5751 chat_history,
5752 } => {
5753 assert_eq!(tool_name, "default_api");
5754 assert_eq!(available_tools, vec!["add".to_string()]);
5755 assert_eq!(allowed_tools, vec!["add".to_string()]);
5756 assert!(history_contains_tool_call(&chat_history, "default_api"));
5757 }
5758 other => panic!("expected UnknownToolCall, got {other:?}"),
5759 },
5760 other => panic!("expected prompt streaming error, got {other:?}"),
5761 }
5762 assert_eq!(recorded.request_count(), 1);
5763 }
5764
5765 #[tokio::test]
5766 async fn tool_call_args_delta_before_valid_name_buffers_then_emits_in_safe_order() {
5767 let model = MockCompletionModel::from_stream_turns([[
5768 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
5769 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5770 MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
5771 MockStreamEvent::final_response_with_total_tokens(3),
5772 ]]);
5773 let hook = RecordingToolCallDeltaHook::default();
5774 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5775
5776 let mut stream = agent
5777 .stream_prompt("stream a tool call")
5778 .add_hook(hook.clone())
5779 .await;
5780 let mut stream_deltas = Vec::new();
5781
5782 while let Some(item) = stream.next().await {
5783 match item {
5784 Ok(MultiTurnStreamItem::StreamAssistantItem(
5785 StreamedAssistantContent::ToolCallDelta {
5786 internal_call_id,
5787 content,
5788 },
5789 )) => {
5790 stream_deltas.push((internal_call_id, content));
5791 }
5792 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5793 Ok(_) => {}
5794 Err(err) => panic!("unexpected streaming error: {err:?}"),
5795 }
5796 }
5797
5798 let internal = stream_deltas
5802 .first()
5803 .map(|delta| delta.0.clone())
5804 .expect("at least one delta");
5805 assert!(!internal.is_empty());
5806 assert_eq!(
5807 hook.observed(),
5808 vec![
5809 (internal.clone(), Some("add".to_string()), String::new()),
5810 (internal.clone(), None, "{\"x\":".to_string()),
5811 (internal.clone(), None, "1}".to_string()),
5812 ]
5813 );
5814 assert_eq!(
5815 stream_deltas,
5816 vec![
5817 (
5818 internal.clone(),
5819 ToolCallDeltaContent::Name("add".to_string())
5820 ),
5821 (
5822 internal.clone(),
5823 ToolCallDeltaContent::Delta("{\"x\":".to_string())
5824 ),
5825 (
5826 internal.clone(),
5827 ToolCallDeltaContent::Delta("1}".to_string())
5828 ),
5829 ]
5830 );
5831 }
5832
5833 #[tokio::test]
5834 async fn tool_call_args_delta_without_name_errors_at_stream_end() {
5835 let model = MockCompletionModel::from_stream_turns([
5836 vec![
5837 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5838 MockStreamEvent::final_response_with_total_tokens(4),
5839 ],
5840 vec![
5841 MockStreamEvent::text("should not be requested"),
5842 MockStreamEvent::final_response_with_total_tokens(6),
5843 ],
5844 ]);
5845 let recorded = model.clone();
5846 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5847
5848 let mut stream = agent
5849 .stream_prompt("stream an incomplete tool call")
5850 .add_hook(PanicOnUnknownToolHook)
5851 .max_turns(3)
5852 .await;
5853 let mut saw_delta = false;
5854 let mut saw_completion_call = false;
5855 let mut saw_final_response = false;
5856 let mut error = None;
5857
5858 while let Some(item) = stream.next().await {
5859 match item {
5860 Ok(MultiTurnStreamItem::StreamAssistantItem(
5861 StreamedAssistantContent::ToolCallDelta { .. },
5862 )) => {
5863 saw_delta = true;
5864 }
5865 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
5866 saw_completion_call = true;
5867 }
5868 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5869 saw_final_response = true;
5870 }
5871 Ok(_) => {}
5872 Err(err) => {
5873 error = Some(err);
5874 break;
5875 }
5876 }
5877 }
5878
5879 assert!(!saw_delta);
5880 assert!(!saw_completion_call);
5881 assert!(!saw_final_response);
5882 let error = error.expect("unterminated tool-call args delta should fail");
5883 match error {
5884 StreamingError::Completion(CompletionError::ResponseError(message)) => {
5885 assert!(
5886 message.contains("streamed tool call arguments"),
5887 "{message}"
5888 );
5889 assert!(message.contains("internal_call_id"), "{message}");
5892 }
5893 other => panic!("expected completion response error, got {other:?}"),
5894 }
5895 assert_eq!(recorded.request_count(), 1);
5896 }
5897
5898 #[tokio::test]
5899 async fn tool_choice_none_buffers_args_then_rejects_name_without_emit() {
5900 let model = MockCompletionModel::from_stream_turns([
5901 vec![
5902 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":1}"),
5903 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
5904 MockStreamEvent::final_response_with_total_tokens(4),
5905 ],
5906 vec![
5907 MockStreamEvent::text("should not be requested"),
5908 MockStreamEvent::final_response_with_total_tokens(6),
5909 ],
5910 ]);
5911 let recorded = model.clone();
5912 let agent = AgentBuilder::new(model)
5913 .tool(MockAddTool)
5914 .tool_choice(ToolChoice::None)
5915 .build();
5916
5917 let mut stream = agent
5918 .stream_prompt("do not use tools")
5919 .add_hook(PanicOnUnknownToolHook)
5920 .max_turns(3)
5921 .await;
5922 let mut saw_delta = false;
5923 let mut error = None;
5924
5925 while let Some(item) = stream.next().await {
5926 match item {
5927 Ok(MultiTurnStreamItem::StreamAssistantItem(
5928 StreamedAssistantContent::ToolCallDelta { .. },
5929 )) => {
5930 saw_delta = true;
5931 }
5932 Ok(_) => {}
5933 Err(err) => {
5934 error = Some(err);
5935 break;
5936 }
5937 }
5938 }
5939
5940 assert!(!saw_delta);
5941 let error = error.expect("ToolChoice::None should reject buffered tool-call deltas");
5942 match error {
5943 StreamingError::Prompt(err) => match *err {
5944 PromptError::UnknownToolCall {
5945 tool_name,
5946 available_tools,
5947 allowed_tools,
5948 chat_history,
5949 } => {
5950 assert_eq!(tool_name, "add");
5951 assert_eq!(available_tools, vec!["add".to_string()]);
5952 assert!(allowed_tools.is_empty());
5953 assert!(history_contains_tool_call(&chat_history, "add"));
5954 }
5955 other => panic!("expected UnknownToolCall, got {other:?}"),
5956 },
5957 other => panic!("expected prompt streaming error, got {other:?}"),
5958 }
5959 assert_eq!(recorded.request_count(), 1);
5960 }
5961
5962 #[tokio::test]
5963 async fn stream_prompt_observes_interleaved_reasoning_deltas_before_unchanged_emit() {
5964 let model = MockCompletionModel::from_stream_turns([[
5965 MockStreamEvent::reasoning_delta("first "),
5966 MockStreamEvent::reasoning_delta_with_id("rs_b", "beta"),
5967 MockStreamEvent::reasoning_delta("second"),
5968 MockStreamEvent::reasoning("first second"),
5969 MockStreamEvent::final_response_with_total_tokens(3),
5970 ]]);
5971 let hook = RecordingReasoningDeltaHook::default();
5972 let agent = AgentBuilder::new(model).build();
5973
5974 let mut stream = agent
5975 .stream_prompt("reason about this")
5976 .add_hook(hook.clone())
5977 .await;
5978 let mut stream_deltas = Vec::new();
5979 let mut completed_reasoning = 0;
5980
5981 while let Some(item) = stream.next().await {
5982 match item {
5983 Ok(MultiTurnStreamItem::StreamAssistantItem(
5984 StreamedAssistantContent::ReasoningDelta {
5985 id,
5986 provider_id,
5987 reasoning,
5988 },
5989 )) => stream_deltas.push((id, provider_id, reasoning)),
5990 Ok(MultiTurnStreamItem::StreamAssistantItem(
5991 StreamedAssistantContent::Reasoning { .. },
5992 )) => completed_reasoning += 1,
5993 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5994 Ok(_) => {}
5995 Err(err) => panic!("unexpected streaming error: {err:?}"),
5996 }
5997 }
5998
5999 assert_eq!(stream_deltas.len(), 3);
6000 let first_id = stream_deltas[0].0.clone();
6001 let second_id = stream_deltas[1].0.clone();
6002 assert!(!first_id.is_empty());
6003 assert!(!second_id.is_empty());
6004 assert_ne!(first_id, second_id);
6005 assert_eq!(stream_deltas[2].0, first_id);
6006 assert_eq!(stream_deltas[0].1, None);
6007 assert_eq!(stream_deltas[1].1.as_deref(), Some("rs_b"));
6008 assert_eq!(stream_deltas[2].1, None);
6009 assert_eq!(
6010 stream_deltas
6011 .iter()
6012 .map(|(_, _, delta)| delta.as_str())
6013 .collect::<Vec<_>>(),
6014 vec!["first ", "beta", "second"]
6015 );
6016 assert_eq!(completed_reasoning, 1);
6017 assert_eq!(
6018 hook.observed(),
6019 vec![
6020 (
6021 first_id.clone(),
6022 None,
6023 "first ".to_string(),
6024 "first ".to_string(),
6025 ),
6026 (
6027 second_id,
6028 Some("rs_b".to_string()),
6029 "beta".to_string(),
6030 "beta".to_string(),
6031 ),
6032 (
6033 first_id,
6034 None,
6035 "second".to_string(),
6036 "first second".to_string(),
6037 ),
6038 ]
6039 );
6040 }
6041
6042 #[tokio::test]
6043 async fn stream_prompt_reasoning_delta_stop_prevents_emit_and_later_hook_dispatch() {
6044 let model = MockCompletionModel::from_stream_turns([[
6045 MockStreamEvent::reasoning_delta_with_id("rs_1", "blocked"),
6046 MockStreamEvent::reasoning_delta_with_id("rs_1", "later"),
6047 MockStreamEvent::final_response_with_total_tokens(2),
6048 ]]);
6049 let stopping = TerminatingReasoningDeltaHook::default();
6050 let later = RecordingReasoningDeltaHook::default();
6051 let agent = AgentBuilder::new(model).build();
6052
6053 let mut stream = agent
6054 .stream_prompt("reason about this")
6055 .add_hook(stopping.clone())
6056 .add_hook(later.clone())
6057 .await;
6058 let mut saw_delta = false;
6059 let mut saw_final_response = false;
6060 let mut error_message = None;
6061
6062 while let Some(item) = stream.next().await {
6063 match item {
6064 Ok(MultiTurnStreamItem::StreamAssistantItem(
6065 StreamedAssistantContent::ReasoningDelta { .. },
6066 )) => saw_delta = true,
6067 Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final_response = true,
6068 Ok(_) => {}
6069 Err(err) => {
6070 error_message = Some(err.to_string());
6071 break;
6072 }
6073 }
6074 }
6075
6076 let observed = stopping.observed();
6077 assert_eq!(observed.len(), 1);
6078 assert_eq!(observed[0].1.as_deref(), Some("rs_1"));
6079 assert_eq!(observed[0].2, "blocked");
6080 assert_eq!(observed[0].3, "blocked");
6081 assert!(later.observed().is_empty());
6082 assert!(!saw_delta);
6083 assert!(!saw_final_response);
6084 assert!(
6085 error_message.as_deref().is_some_and(|message| message
6086 .contains("PromptCancelled: stop on reasoning delta")),
6087 "expected hook termination error, got {error_message:?}"
6088 );
6089 }
6090
6091 #[tokio::test]
6092 async fn stream_prompt_skips_reasoning_delta_hook_without_observation_interest() {
6093 let model = MockCompletionModel::from_stream_turns([[
6094 MockStreamEvent::reasoning_delta("visible"),
6095 MockStreamEvent::final_response_with_total_tokens(1),
6096 ]]);
6097 let hook = UninterestedReasoningDeltaHook::default();
6098 let agent = AgentBuilder::new(model).build();
6099
6100 let mut stream = agent
6101 .stream_prompt("reason about this")
6102 .add_hook(hook.clone())
6103 .await;
6104 let mut emitted = Vec::new();
6105
6106 while let Some(item) = stream.next().await {
6107 match item {
6108 Ok(MultiTurnStreamItem::StreamAssistantItem(
6109 StreamedAssistantContent::ReasoningDelta { reasoning, .. },
6110 )) => emitted.push(reasoning),
6111 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6112 Ok(_) => {}
6113 Err(err) => panic!("unexpected streaming error: {err:?}"),
6114 }
6115 }
6116
6117 assert_eq!(emitted, vec!["visible"]);
6118 assert_eq!(hook.calls.load(Ordering::SeqCst), 0);
6119 }
6120
6121 #[tokio::test]
6122 async fn stream_prompt_reasoning_delta_hook_observes_retried_turns_as_provisional() {
6123 let model = MockCompletionModel::from_stream_turns([
6124 [
6125 MockStreamEvent::reasoning_delta("rejected"),
6126 MockStreamEvent::final_response_with_total_tokens(1),
6127 ],
6128 [
6129 MockStreamEvent::reasoning_delta("accepted"),
6130 MockStreamEvent::final_response_with_total_tokens(1),
6131 ],
6132 ]);
6133 let hook = RetryFirstReasoningTurnHook::default();
6134 let agent = AgentBuilder::new(model).build();
6135
6136 let mut stream = agent
6137 .stream_prompt("reason about this")
6138 .add_hook(hook.clone())
6139 .max_turns(2)
6140 .await;
6141 let mut order = Vec::new();
6142
6143 while let Some(item) = stream.next().await {
6144 match item {
6145 Ok(MultiTurnStreamItem::StreamAssistantItem(
6146 StreamedAssistantContent::ReasoningDelta { reasoning, .. },
6147 )) => order.push(reasoning),
6148 Ok(MultiTurnStreamItem::ModelTurnRetried { turn }) => {
6149 order.push(format!("retry:{turn}"));
6150 }
6151 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6152 Ok(_) => {}
6153 Err(err) => panic!("unexpected streaming error: {err:?}"),
6154 }
6155 }
6156
6157 assert_eq!(order, vec!["rejected", "retry:1", "accepted"]);
6158 let observed = hook.recorder.observed();
6159 assert_eq!(observed.len(), 2);
6160 assert_eq!(observed[0].2, "rejected");
6161 assert_eq!(observed[0].3, "rejected");
6162 assert_eq!(observed[1].2, "accepted");
6163 assert_eq!(observed[1].3, "accepted");
6164 }
6165
6166 #[tokio::test]
6167 async fn stream_prompt_emits_tool_call_deltas_without_hook() {
6168 let model = MockCompletionModel::from_stream_turns([[
6169 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6170 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6171 MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
6172 MockStreamEvent::final_response_with_total_tokens(3),
6173 ]]);
6174 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6175
6176 let mut stream = agent.stream_prompt("stream a tool call").await;
6177 let mut deltas = Vec::new();
6178
6179 while let Some(item) = stream.next().await {
6180 match item {
6181 Ok(MultiTurnStreamItem::StreamAssistantItem(
6182 StreamedAssistantContent::ToolCallDelta {
6183 internal_call_id,
6184 content,
6185 },
6186 )) => {
6187 deltas.push((internal_call_id, content));
6188 }
6189 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6190 Ok(_) => {}
6191 Err(err) => panic!("unexpected streaming error: {err:?}"),
6192 }
6193 }
6194
6195 let internal = deltas
6199 .first()
6200 .map(|delta| delta.0.clone())
6201 .expect("at least one delta");
6202 assert!(!internal.is_empty());
6203 assert_eq!(
6204 deltas,
6205 vec![
6206 (
6207 internal.clone(),
6208 ToolCallDeltaContent::Name("add".to_string())
6209 ),
6210 (
6211 internal.clone(),
6212 ToolCallDeltaContent::Delta("{\"x\":".to_string())
6213 ),
6214 (
6215 internal.clone(),
6216 ToolCallDeltaContent::Delta("1}".to_string())
6217 ),
6218 ]
6219 );
6220 }
6221
6222 #[tokio::test]
6223 async fn stream_prompt_emits_tool_call_deltas_after_hook_continue() {
6224 let model = MockCompletionModel::from_stream_turns([[
6225 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6226 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6227 MockStreamEvent::tool_call_arguments_delta("tool_1", "1}"),
6228 MockStreamEvent::final_response_with_total_tokens(3),
6229 ]]);
6230 let hook = RecordingToolCallDeltaHook::default();
6231 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6232
6233 let mut stream = agent
6234 .stream_prompt("stream a tool call")
6235 .add_hook(hook.clone())
6236 .await;
6237 let mut stream_deltas = Vec::new();
6238
6239 while let Some(item) = stream.next().await {
6240 match item {
6241 Ok(MultiTurnStreamItem::StreamAssistantItem(
6242 StreamedAssistantContent::ToolCallDelta {
6243 internal_call_id,
6244 content,
6245 },
6246 )) => {
6247 stream_deltas.push((internal_call_id, content));
6248 }
6249 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6250 Ok(_) => {}
6251 Err(err) => panic!("unexpected streaming error: {err:?}"),
6252 }
6253 }
6254
6255 let internal = stream_deltas
6259 .first()
6260 .map(|delta| delta.0.clone())
6261 .expect("at least one delta");
6262 assert!(!internal.is_empty());
6263 assert_eq!(
6264 hook.observed(),
6265 vec![
6266 (internal.clone(), Some("add".to_string()), String::new()),
6267 (internal.clone(), None, "{\"x\":".to_string()),
6268 (internal.clone(), None, "1}".to_string()),
6269 ]
6270 );
6271 assert_eq!(
6272 stream_deltas,
6273 vec![
6274 (
6275 internal.clone(),
6276 ToolCallDeltaContent::Name("add".to_string())
6277 ),
6278 (
6279 internal.clone(),
6280 ToolCallDeltaContent::Delta("{\"x\":".to_string())
6281 ),
6282 (
6283 internal.clone(),
6284 ToolCallDeltaContent::Delta("1}".to_string())
6285 ),
6286 ]
6287 );
6288 }
6289
6290 #[tokio::test]
6291 async fn stream_prompt_tool_call_deltas_hook_termination_prevents_delta_emit() {
6292 let model = MockCompletionModel::from_stream_turns([[
6293 MockStreamEvent::tool_call_name_delta("tool_1", "add"),
6294 MockStreamEvent::tool_call_arguments_delta("tool_1", "{\"x\":"),
6295 MockStreamEvent::final_response_with_total_tokens(3),
6296 ]]);
6297 let hook = TerminatingToolCallDeltaHook::default();
6298 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6299
6300 let mut stream = agent
6301 .stream_prompt("stream a tool call")
6302 .add_hook(hook.clone())
6303 .await;
6304 let mut saw_delta = false;
6305 let mut saw_final_response = false;
6306 let mut error_message = None;
6307
6308 while let Some(item) = stream.next().await {
6309 match item {
6310 Ok(MultiTurnStreamItem::StreamAssistantItem(
6311 StreamedAssistantContent::ToolCallDelta { .. },
6312 )) => {
6313 saw_delta = true;
6314 }
6315 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
6316 saw_final_response = true;
6317 }
6318 Ok(_) => {}
6319 Err(err) => {
6320 error_message = Some(err.to_string());
6321 break;
6322 }
6323 }
6324 }
6325
6326 let observed = hook.observed();
6329 assert_eq!(observed.len(), 1);
6330 let first = observed.first().expect("one observed delta");
6331 assert!(!first.0.is_empty());
6332 assert_eq!(first.1, Some("add".to_string()));
6333 assert_eq!(first.2, String::new());
6334 assert!(!saw_delta);
6335 assert!(!saw_final_response);
6336 assert!(
6337 error_message
6338 .as_deref()
6339 .is_some_and(|message| message.contains("PromptCancelled: stop on tool call delta")),
6340 "expected hook termination error, got {error_message:?}"
6341 );
6342 }
6343
6344 #[tokio::test]
6345 async fn stream_prompt_exposes_completion_calls() {
6346 let first_call_usage = usage(10, 2);
6347 let second_call_usage = usage(25, 5);
6348 let model = MockCompletionModel::from_stream_turns([
6349 vec![
6350 MockStreamEvent::tool_call(
6351 "tool_call_1",
6352 "add",
6353 serde_json::json!({"x": 1, "y": 2}),
6354 )
6355 .with_call_id("call_1"),
6356 MockStreamEvent::final_response(first_call_usage),
6357 ],
6358 vec![
6359 MockStreamEvent::text("done"),
6360 MockStreamEvent::final_response(second_call_usage),
6361 ],
6362 ]);
6363 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6364 let empty_history: &[Message] = &[];
6365
6366 let mut stream = agent
6367 .stream_prompt("do tool work")
6368 .history(empty_history)
6369 .max_turns(3)
6370 .await;
6371 let mut completion_calls_events = Vec::new();
6372 let mut final_response = None;
6373
6374 while let Some(item) = stream.next().await {
6375 match item {
6376 Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
6377 completion_calls_events.push(call_usage);
6378 }
6379 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6380 final_response = Some(response);
6381 break;
6382 }
6383 Ok(_) => {}
6384 Err(err) => panic!("unexpected streaming error: {err:?}"),
6385 }
6386 }
6387
6388 assert_eq!(
6389 completion_calls_events,
6390 vec![
6391 streamed_call(0, first_call_usage),
6392 streamed_call(1, second_call_usage)
6393 ]
6394 );
6395
6396 let final_response = final_response.expect("expected final response");
6397 assert_eq!(
6398 final_response.usage(),
6399 Usage {
6400 input_tokens: 35,
6401 output_tokens: 7,
6402 total_tokens: 42,
6403 cached_input_tokens: 0,
6404 cache_creation_input_tokens: 0,
6405 tool_use_prompt_tokens: 0,
6406 reasoning_tokens: 0,
6407 }
6408 );
6409 assert_eq!(
6410 final_response.completion_calls(),
6411 &[
6412 streamed_call(0, first_call_usage),
6413 streamed_call(1, second_call_usage)
6414 ]
6415 );
6416 }
6417
6418 #[tokio::test(flavor = "current_thread")]
6419 async fn stream_prompt_records_single_call_usage_on_chat_span_under_outer_span() {
6420 let call_usage = usage(10, 2);
6421 let model = MockCompletionModel::from_stream_turns([[
6422 MockStreamEvent::text("done"),
6423 MockStreamEvent::final_response(call_usage),
6424 ]]);
6425 let agent = AgentBuilder::new(model).build();
6426
6427 assert_stream_usage_recorded_on_chat_spans(agent, "say done", 1, &[call_usage]).await;
6428 }
6429
6430 #[tokio::test(flavor = "current_thread")]
6431 async fn stream_prompt_records_multi_turn_usage_on_chat_spans_under_outer_span() {
6432 let first_call_usage = usage(10, 2);
6433 let second_call_usage = usage(25, 5);
6434 let model = MockCompletionModel::from_stream_turns([
6435 vec![
6436 MockStreamEvent::tool_call(
6437 "tool_call_1",
6438 "add",
6439 serde_json::json!({"x": 1, "y": 2}),
6440 )
6441 .with_call_id("call_1"),
6442 MockStreamEvent::final_response(first_call_usage),
6443 ],
6444 vec![
6445 MockStreamEvent::text("done"),
6446 MockStreamEvent::final_response(second_call_usage),
6447 ],
6448 ]);
6449 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6450
6451 assert_stream_usage_recorded_on_chat_spans(
6452 agent,
6453 "do tool work",
6454 3,
6455 &[first_call_usage, second_call_usage],
6456 )
6457 .await;
6458 }
6459
6460 #[tokio::test]
6461 async fn stream_prompt_emits_completion_call_before_finish_hook_termination() {
6462 let call_usage = usage(10, 2);
6463 let model = MockCompletionModel::from_stream_turns([[
6464 MockStreamEvent::text("done"),
6465 MockStreamEvent::final_response(call_usage),
6466 ]]);
6467 let agent = AgentBuilder::new(model).build();
6468
6469 let mut stream = agent
6470 .stream_prompt("say done")
6471 .add_hook(TerminateOnStreamFinish)
6472 .await;
6473 let mut completion_calls = Vec::new();
6474 let mut saw_error = false;
6475
6476 while let Some(item) = stream.next().await {
6477 match item {
6478 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
6479 completion_calls.push(completion_call);
6480 }
6481 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6482 panic!("unexpected final response after hook termination: {response:?}");
6483 }
6484 Ok(_) => {}
6485 Err(_) => {
6486 saw_error = true;
6487 break;
6488 }
6489 }
6490 }
6491
6492 assert_eq!(completion_calls, vec![streamed_call(0, call_usage)]);
6493 assert!(saw_error);
6494 }
6495
6496 #[tokio::test]
6497 async fn stream_prompt_completion_calls_records_unreported_usage() {
6498 let second_call_usage = usage(25, 5);
6499 let model = MockCompletionModel::from_stream_turns([
6500 vec![
6501 MockStreamEvent::tool_call(
6502 "tool_call_1",
6503 "add",
6504 serde_json::json!({"x": 1, "y": 2}),
6505 )
6506 .with_call_id("call_1"),
6507 MockStreamEvent::final_response(Usage::new()),
6511 ],
6512 vec![
6513 MockStreamEvent::text("done"),
6514 MockStreamEvent::final_response(second_call_usage),
6515 ],
6516 ]);
6517 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6518 let empty_history: &[Message] = &[];
6519
6520 let mut stream = agent
6521 .stream_prompt("do tool work")
6522 .history(empty_history)
6523 .max_turns(3)
6524 .await;
6525 let mut completion_calls_events = Vec::new();
6526 let mut final_response = None;
6527
6528 while let Some(item) = stream.next().await {
6529 match item {
6530 Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
6531 completion_calls_events.push(call_usage);
6532 }
6533 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
6534 final_response = Some(response);
6535 break;
6536 }
6537 Ok(_) => {}
6538 Err(err) => panic!("unexpected streaming error: {err:?}"),
6539 }
6540 }
6541
6542 let expected_usage = vec![
6543 streamed_call(0, Usage::new()),
6544 streamed_call(1, second_call_usage),
6545 ];
6546 assert_eq!(completion_calls_events, expected_usage);
6547
6548 let final_response = final_response.expect("expected final response");
6549 assert_eq!(final_response.completion_calls(), expected_usage.as_slice());
6550 }
6551
6552 #[tokio::test]
6553 async fn final_response_matches_streamed_text_when_provider_final_is_textless() {
6554 let agent = AgentBuilder::new(streaming_text_then_final_model()).build();
6555
6556 let mut stream = agent.stream_prompt("say hello").await;
6557 let mut streamed_text = String::new();
6558 let mut final_response_text = None;
6559
6560 while let Some(item) = stream.next().await {
6561 match item {
6562 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
6563 text,
6564 ))) => streamed_text.push_str(&text.text),
6565 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6566 final_response_text = Some(res.output().to_owned());
6567 break;
6568 }
6569 Ok(_) => {}
6570 Err(err) => panic!("unexpected streaming error: {err:?}"),
6571 }
6572 }
6573
6574 assert_eq!(streamed_text, "hello world");
6575 assert_eq!(final_response_text.as_deref(), Some("hello world"));
6576 }
6577
6578 #[tokio::test]
6579 async fn final_response_preserves_structured_text_metadata() {
6580 let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
6581
6582 let mut stream = agent.stream_prompt("answer with citations").await;
6583 let mut final_response = None;
6584
6585 while let Some(item) = stream.next().await {
6586 match item {
6587 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6588 final_response = Some(res);
6589 break;
6590 }
6591 Ok(_) => {}
6592 Err(err) => panic!("unexpected streaming error: {err:?}"),
6593 }
6594 }
6595
6596 let final_response = final_response.expect("expected final response");
6597 assert_eq!(final_response.output(), "cited answer");
6598 let metadata = text_metadata(final_response.content())
6599 .expect("expected text metadata in final content");
6600 assert_eq!(
6601 metadata["citations"][0]["encrypted_index"],
6602 "encrypted-reference"
6603 );
6604 }
6605
6606 #[tokio::test]
6607 async fn final_response_history_preserves_structured_text_metadata() {
6608 let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
6609
6610 let empty_history: &[Message] = &[];
6611 let mut stream = agent
6612 .stream_prompt("answer with citations")
6613 .history(empty_history)
6614 .await;
6615 let mut final_response = None;
6616
6617 while let Some(item) = stream.next().await {
6618 match item {
6619 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6620 final_response = Some(res);
6621 break;
6622 }
6623 Ok(_) => {}
6624 Err(err) => panic!("unexpected streaming error: {err:?}"),
6625 }
6626 }
6627
6628 let final_response = final_response.expect("expected final response");
6629 let history = final_response
6630 .messages()
6631 .expect("with_history should include final history");
6632 let assistant_content = history
6633 .iter()
6634 .find_map(|message| match message {
6635 Message::Assistant { content, .. } => Some(content),
6636 _ => None,
6637 })
6638 .expect("expected assistant message in history");
6639 let metadata =
6640 text_metadata(assistant_content).expect("expected text metadata in assistant history");
6641 assert_eq!(
6642 metadata["citations"][0]["encrypted_index"],
6643 "encrypted-reference"
6644 );
6645 }
6646
6647 #[tokio::test]
6648 async fn tool_follow_up_history_preserves_structured_text_metadata() {
6649 let model = streaming_cited_text_then_tool_model();
6650 let recorded = model.clone();
6651 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
6652 let empty_history: &[Message] = &[];
6653
6654 let mut stream = agent
6655 .stream_prompt("use a tool with citations")
6656 .history(empty_history)
6657 .max_turns(3)
6658 .await;
6659
6660 while let Some(item) = stream.next().await {
6661 match item {
6662 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
6663 Ok(_) => {}
6664 Err(err) => panic!("unexpected streaming error: {err:?}"),
6665 }
6666 }
6667
6668 let requests = recorded.requests();
6669 assert_eq!(requests.len(), 2);
6670 let follow_up_history = requests[1].chat_history.iter().collect::<Vec<_>>();
6671 let assistant_content = follow_up_history
6672 .iter()
6673 .find_map(|message| match message {
6674 Message::Assistant { content, .. } => Some(content),
6675 _ => None,
6676 })
6677 .expect("expected assistant message in follow-up history");
6678 let metadata = text_metadata(assistant_content)
6679 .expect("expected citation metadata in follow-up assistant history");
6680 assert_eq!(
6681 metadata["citations"][0]["encrypted_index"],
6682 "encrypted-reference"
6683 );
6684 }
6685
6686 #[tokio::test]
6687 async fn final_response_can_remain_empty_for_truly_textless_turns() {
6688 let agent = AgentBuilder::new(streaming_final_only_model()).build();
6689
6690 let mut stream = agent.stream_prompt("say nothing").await;
6691 let mut streamed_text = String::new();
6692 let mut final_response_text = None;
6693
6694 while let Some(item) = stream.next().await {
6695 match item {
6696 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
6697 text,
6698 ))) => streamed_text.push_str(&text.text),
6699 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6700 final_response_text = Some(res.output().to_owned());
6701 break;
6702 }
6703 Ok(_) => {}
6704 Err(err) => panic!("unexpected streaming error: {err:?}"),
6705 }
6706 }
6707
6708 assert!(streamed_text.is_empty());
6709 assert_eq!(final_response_text.as_deref(), Some(""));
6710 }
6711
6712 #[tokio::test]
6724 async fn empty_turn_truncated_at_max_tokens_is_an_error_not_an_empty_answer() {
6725 let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6726 mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6727 )]]);
6728 let agent = AgentBuilder::new(model).build();
6729
6730 let mut stream = agent.stream_prompt("write a long essay").await;
6731 let mut error = None;
6732 let mut final_response_text = None;
6733
6734 while let Some(item) = stream.next().await {
6735 match item {
6736 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6737 final_response_text = Some(res.output().to_owned());
6738 break;
6739 }
6740 Ok(_) => {}
6741 Err(err) => {
6742 error = Some(err);
6743 break;
6744 }
6745 }
6746 }
6747
6748 assert!(
6749 final_response_text.is_none(),
6750 "a truncated, content-less turn must not finalize as a successful \
6751 answer — it did, yielding {final_response_text:?}"
6752 );
6753 let error = error.expect("the truncated turn should surface an error");
6754 let rendered = format!("{error:?}");
6755 assert!(
6756 rendered.contains("Length"),
6757 "the error must name the terminal reason so the cause is diagnosable, got: {rendered}"
6758 );
6759 assert!(
6760 rendered.contains("max_tokens"),
6761 "a budget truncation must point at the setting that fixes it: {rendered}"
6762 );
6763 }
6764
6765 #[tokio::test]
6772 async fn partial_output_truncated_at_max_tokens_stays_a_valid_answer() {
6773 let model = MockCompletionModel::from_stream_turns([[
6774 MockStreamEvent::Text("a partial ans".to_string()),
6775 MockStreamEvent::FinalResponse(
6776 mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6777 ),
6778 ]]);
6779 let agent = AgentBuilder::new(model).build();
6780
6781 let mut stream = agent.stream_prompt("write a long essay").await;
6782 let mut final_response = None;
6783
6784 while let Some(item) = stream.next().await {
6785 match item {
6786 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6787 final_response = Some(res);
6788 break;
6789 }
6790 Ok(_) => {}
6791 Err(err) => panic!("a truncated turn that produced text must not error: {err:?}"),
6792 }
6793 }
6794
6795 let final_response =
6796 final_response.expect("a turn with partial output should still finalize");
6797 assert_eq!(final_response.output(), "a partial ans");
6798
6799 let truncated = final_response
6802 .completion_calls
6803 .iter()
6804 .any(|call| call.finish_reason == Some(FinishReason::Length));
6805 assert!(
6806 truncated,
6807 "the terminal reason must reach the caller on completion_calls; \
6808 without it a truncated answer is indistinguishable from a complete \
6809 one — calls: {:?}",
6810 final_response.completion_calls
6811 );
6812 }
6813
6814 #[tokio::test]
6820 async fn empty_content_filtered_turn_is_an_error_not_an_empty_answer() {
6821 let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6822 mock_final(Usage::new()).with_finish_reason(FinishReason::ContentFilter),
6823 )]]);
6824 let agent = AgentBuilder::new(model).build();
6825
6826 let mut stream = agent.stream_prompt("something the filter rejects").await;
6827 let mut errored = None;
6828
6829 while let Some(item) = stream.next().await {
6830 match item {
6831 Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6832 "a content-filtered, content-less turn must not finalize as a \
6833 successful answer, got {:?}",
6834 res.output()
6835 ),
6836 Ok(_) => {}
6837 Err(err) => {
6838 errored = Some(err);
6839 break;
6840 }
6841 }
6842 }
6843
6844 let rendered = format!("{:?}", errored.expect("the filtered turn should error"));
6845 assert!(
6846 rendered.contains("ContentFilter"),
6847 "the error must name the terminal reason, got: {rendered}"
6848 );
6849 assert!(
6850 !rendered.contains("max_tokens"),
6851 "a safety block must not advise raising max_tokens — that setting \
6852 cannot fix a filtered response: {rendered}"
6853 );
6854 }
6855
6856 #[tokio::test]
6862 async fn empty_turn_with_unmodeled_finish_reason_still_finalizes() {
6863 let model = MockCompletionModel::from_stream_turns([[MockStreamEvent::FinalResponse(
6864 mock_final(Usage::new())
6865 .with_finish_reason(FinishReason::Other("PROVIDER_SPECIFIC".to_string())),
6866 )]]);
6867 let agent = AgentBuilder::new(model).build();
6868
6869 let mut stream = agent.stream_prompt("say nothing").await;
6870 let mut final_response_text = None;
6871
6872 while let Some(item) = stream.next().await {
6873 match item {
6874 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
6875 final_response_text = Some(res.output().to_owned());
6876 break;
6877 }
6878 Ok(_) => {}
6879 Err(err) => panic!("an unmodeled finish reason must not error: {err:?}"),
6880 }
6881 }
6882
6883 assert_eq!(final_response_text.as_deref(), Some(""));
6884 }
6885
6886 #[tokio::test]
6901 async fn reasoning_only_turn_truncated_at_max_tokens_is_an_error() {
6902 let model = MockCompletionModel::from_stream_turns([[
6903 MockStreamEvent::reasoning("thinking hard and never reaching an answer"),
6904 MockStreamEvent::FinalResponse(
6905 mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6906 ),
6907 ]]);
6908 let agent = AgentBuilder::new(model).build();
6909
6910 let mut stream = agent.stream_prompt("solve this carefully").await;
6911 let mut error = None;
6912
6913 while let Some(item) = stream.next().await {
6914 match item {
6915 Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6916 "a turn that only produced reasoning before being truncated must \
6917 not finalize as a successful answer, got {:?}",
6918 res.output()
6919 ),
6920 Ok(_) => {}
6921 Err(err) => {
6922 error = Some(err);
6923 break;
6924 }
6925 }
6926 }
6927
6928 let rendered = format!("{:?}", error.expect("the truncated turn should error"));
6929 assert!(
6930 rendered.contains("Length"),
6931 "the error must name the terminal reason, got: {rendered}"
6932 );
6933 }
6934
6935 #[tokio::test]
6940 async fn reasoning_only_turn_content_filtered_is_an_error() {
6941 let model = MockCompletionModel::from_stream_turns([[
6942 MockStreamEvent::reasoning("considering something the filter rejects"),
6943 MockStreamEvent::FinalResponse(
6944 mock_final(Usage::new()).with_finish_reason(FinishReason::ContentFilter),
6945 ),
6946 ]]);
6947 let agent = AgentBuilder::new(model).build();
6948
6949 let mut stream = agent.stream_prompt("something borderline").await;
6950 let mut errored = None;
6951
6952 while let Some(item) = stream.next().await {
6953 match item {
6954 Ok(MultiTurnStreamItem::FinalResponse(res)) => panic!(
6955 "a reasoning-only filtered turn must not finalize successfully, \
6956 got {:?}",
6957 res.output()
6958 ),
6959 Ok(_) => {}
6960 Err(err) => {
6961 errored = Some(err);
6962 break;
6963 }
6964 }
6965 }
6966
6967 let rendered = format!(
6968 "{:?}",
6969 errored.expect("the filtered reasoning-only turn should error")
6970 );
6971 assert!(
6972 rendered.contains("ContentFilter") && !rendered.contains("max_tokens"),
6973 "a filtered turn must name its reason and must not advise raising \
6974 max_tokens: {rendered}"
6975 );
6976 }
6977
6978 #[tokio::test]
6984 async fn reasoning_then_text_truncated_stays_a_valid_answer() {
6985 let model = MockCompletionModel::from_stream_turns([[
6986 MockStreamEvent::reasoning("weighing the options"),
6987 MockStreamEvent::Text("the answer so f".to_string()),
6988 MockStreamEvent::FinalResponse(
6989 mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
6990 ),
6991 ]]);
6992 let agent = AgentBuilder::new(model).build();
6993
6994 let mut stream = agent.stream_prompt("solve this").await;
6995 let mut final_response = None;
6996
6997 while let Some(item) = stream.next().await {
6998 match item {
6999 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7000 final_response = Some(res);
7001 break;
7002 }
7003 Ok(_) => {}
7004 Err(err) => panic!("a truncated turn that produced text must not error: {err:?}"),
7005 }
7006 }
7007
7008 let final_response = final_response.expect("a turn with text should finalize");
7009 assert_eq!(final_response.output(), "the answer so f");
7010 assert!(
7011 final_response
7012 .completion_calls
7013 .iter()
7014 .any(|call| call.finish_reason == Some(FinishReason::Length)),
7015 "the terminal reason must still reach the caller on a valid truncated turn"
7016 );
7017 }
7018
7019 #[tokio::test]
7025 async fn reasoning_only_turn_that_stopped_naturally_still_finalizes() {
7026 let model = MockCompletionModel::from_stream_turns([[
7027 MockStreamEvent::reasoning("thought about it, nothing to add"),
7028 MockStreamEvent::FinalResponse(
7029 mock_final(Usage::new()).with_finish_reason(FinishReason::Stop),
7030 ),
7031 ]]);
7032 let agent = AgentBuilder::new(model).build();
7033
7034 let mut stream = agent.stream_prompt("say nothing").await;
7035 let mut final_response_text = None;
7036
7037 while let Some(item) = stream.next().await {
7038 match item {
7039 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7040 final_response_text = Some(res.output().to_owned());
7041 break;
7042 }
7043 Ok(_) => {}
7044 Err(err) => panic!("a naturally-stopped reasoning turn must not error: {err:?}"),
7045 }
7046 }
7047
7048 assert_eq!(final_response_text.as_deref(), Some(""));
7049 }
7050
7051 #[tokio::test]
7060 async fn reasoning_survives_into_history_when_the_truncated_turn_errors() {
7061 let model = MockCompletionModel::from_stream_turns([[
7062 MockStreamEvent::reasoning("partial thinking worth keeping"),
7063 MockStreamEvent::FinalResponse(
7064 mock_final(Usage::new()).with_finish_reason(FinishReason::Length),
7065 ),
7066 ]]);
7067 let agent = AgentBuilder::new(model).build();
7068
7069 let mut stream = agent.stream_prompt("solve this").await;
7070 let mut streamed_reasoning = String::new();
7071
7072 while let Some(item) = stream.next().await {
7073 match item {
7074 Ok(MultiTurnStreamItem::StreamAssistantItem(
7075 StreamedAssistantContent::Reasoning { reasoning, .. },
7076 )) => {
7077 streamed_reasoning.push_str(&reasoning.display_text());
7078 }
7079 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
7080 panic!("the truncated reasoning-only turn should error")
7081 }
7082 Ok(_) => {}
7083 Err(_) => break,
7084 }
7085 }
7086
7087 assert!(
7088 streamed_reasoning.contains("partial thinking worth keeping"),
7089 "the partial reasoning must reach the consumer before the error, so a \
7090 truncated thinking turn is debuggable — got {streamed_reasoning:?}"
7091 );
7092 }
7093
7094 async fn background_logger(stop: Arc<AtomicBool>, leak_count: Arc<AtomicU32>) {
7097 let mut interval = tokio::time::interval(Duration::from_millis(50));
7098 let mut count = 0u32;
7099
7100 while !stop.load(Ordering::Relaxed) {
7101 interval.tick().await;
7102 count += 1;
7103
7104 tracing::event!(
7105 target: "background_logger",
7106 tracing::Level::INFO,
7107 count = count,
7108 "Background tick"
7109 );
7110
7111 let current = tracing::Span::current();
7113 if !current.is_disabled() && !current.is_none() {
7114 leak_count.fetch_add(1, Ordering::Relaxed);
7115 }
7116 }
7117
7118 tracing::info!(target: "background_logger", total_ticks = count, "Background logger stopped");
7119 }
7120
7121 #[tokio::test(flavor = "current_thread")]
7129 #[ignore = "This requires an API key"]
7130 async fn test_span_context_isolation() -> anyhow::Result<()> {
7131 let stop = Arc::new(AtomicBool::new(false));
7132 let leak_count = Arc::new(AtomicU32::new(0));
7133
7134 let bg_stop = stop.clone();
7136 let bg_leak = leak_count.clone();
7137 let bg_handle = tokio::spawn(async move {
7138 background_logger(bg_stop, bg_leak).await;
7139 });
7140
7141 tokio::time::sleep(Duration::from_millis(100)).await;
7143
7144 let client = anthropic::Client::from_env()?;
7147 let agent = client
7148 .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
7149 .preamble("You are a helpful assistant.")
7150 .temperature(0.1)
7151 .max_tokens(100)
7152 .build();
7153
7154 let mut stream = agent
7155 .stream_prompt("Say 'hello world' and nothing else.")
7156 .await;
7157
7158 let mut full_content = String::new();
7159 while let Some(item) = stream.next().await {
7160 match item {
7161 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
7162 text,
7163 ))) => {
7164 full_content.push_str(&text.text);
7165 }
7166 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
7167 break;
7168 }
7169 Err(e) => {
7170 tracing::warn!("Error: {:?}", e);
7171 break;
7172 }
7173 _ => {}
7174 }
7175 }
7176
7177 tracing::info!("Got response: {:?}", full_content);
7178
7179 stop.store(true, Ordering::Relaxed);
7181 bg_handle.await?;
7182
7183 let leaks = leak_count.load(Ordering::Relaxed);
7184 anyhow::ensure!(
7185 leaks == 0,
7186 "SPAN LEAK DETECTED: Background logger was inside unexpected spans {leaks} times. \
7187 This indicates that span.enter() is being used inside async_stream instead of .instrument()"
7188 );
7189
7190 Ok(())
7191 }
7192
7193 #[tokio::test]
7200 #[ignore = "This requires an API key"]
7201 async fn test_chat_history_in_final_response() -> anyhow::Result<()> {
7202 use rig_core::message::Message;
7203
7204 let client = anthropic::Client::from_env()?;
7205 let agent = client
7206 .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
7207 .preamble("You are a helpful assistant. Keep responses brief.")
7208 .temperature(0.1)
7209 .max_tokens(50)
7210 .build();
7211
7212 let empty_history: &[Message] = &[];
7214 let mut stream = agent
7215 .stream_prompt("Say 'hello' and nothing else.")
7216 .history(empty_history)
7217 .await;
7218
7219 let mut response_text = String::new();
7221 let mut final_history = None;
7222 while let Some(item) = stream.next().await {
7223 match item {
7224 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
7225 text,
7226 ))) => {
7227 response_text.push_str(&text.text);
7228 }
7229 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7230 final_history = res.messages().map(|h| h.to_vec());
7231 break;
7232 }
7233 Err(e) => {
7234 return Err(e.into());
7235 }
7236 _ => {}
7237 }
7238 }
7239
7240 let history = final_history
7241 .ok_or_else(|| anyhow::anyhow!("final response should include history"))?;
7242
7243 anyhow::ensure!(
7245 history.iter().any(|m| matches!(m, Message::User { .. })),
7246 "History should contain the user message"
7247 );
7248
7249 anyhow::ensure!(
7251 history
7252 .iter()
7253 .any(|m| matches!(m, Message::Assistant { .. })),
7254 "History should contain the assistant response"
7255 );
7256
7257 tracing::info!(
7258 "History after streaming: {} messages, response: {:?}",
7259 history.len(),
7260 response_text
7261 );
7262
7263 Ok(())
7264 }
7265
7266 #[tokio::test]
7267 async fn streaming_appends_to_memory_after_final_response() {
7268 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7269
7270 let memory = InMemoryConversationMemory::new();
7271 let agent = AgentBuilder::new(streaming_text_then_final_model())
7272 .memory(memory.clone())
7273 .build();
7274
7275 let mut stream = agent
7276 .stream_prompt("hi there")
7277 .conversation("stream-thread")
7278 .await;
7279
7280 let mut history_in_final = None;
7281 while let Some(item) = stream.next().await {
7282 match item {
7283 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7284 history_in_final = res.messages().map(|h| h.to_vec());
7285 break;
7286 }
7287 Ok(_) => {}
7288 Err(err) => panic!("unexpected streaming error: {err:?}"),
7289 }
7290 }
7291
7292 let final_history = history_in_final
7293 .expect("PromptResponse.messages should be populated when memory is configured");
7294 assert_eq!(
7295 final_history.len(),
7296 2,
7297 "user prompt + assistant response in final history: {final_history:?}"
7298 );
7299
7300 let stored = memory.load("stream-thread").await.unwrap();
7301 assert_eq!(stored.len(), 2, "memory should contain user + assistant");
7302 }
7303
7304 #[tokio::test]
7305 async fn streaming_reasoning_without_tools_does_not_duplicate_final_history() {
7306 let agent = AgentBuilder::new(MockCompletionModel::from_stream_turns([[
7307 MockStreamEvent::text("final answer"),
7308 MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
7309 MockStreamEvent::final_response_with_total_tokens(3),
7310 ]]))
7311 .build();
7312
7313 let mut stream = agent
7314 .stream_prompt("think before answering")
7315 .history(Vec::<Message>::new())
7316 .await;
7317
7318 let mut history_in_final = None;
7319 while let Some(item) = stream.next().await {
7320 match item {
7321 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
7322 history_in_final = res.messages().map(|h| h.to_vec());
7323 break;
7324 }
7325 Ok(_) => {}
7326 Err(err) => panic!("unexpected streaming error: {err:?}"),
7327 }
7328 }
7329
7330 let final_history = history_in_final
7331 .expect("PromptResponse.messages should be populated when with_history is used");
7332 assert_eq!(
7333 final_history.len(),
7334 2,
7335 "user prompt + one assistant response in final history: {final_history:?}"
7336 );
7337
7338 assert!(matches!(
7339 final_history.first(),
7340 Some(Message::User { content })
7341 if matches!(
7342 content.first(),
7343 Some(UserContent::Text(text)) if text.text == "think before answering"
7344 )
7345 ));
7346
7347 let assistant_messages = final_history
7348 .iter()
7349 .filter_map(|message| match message {
7350 Message::Assistant { content, .. } => Some(content),
7351 _ => None,
7352 })
7353 .collect::<Vec<_>>();
7354 assert_eq!(
7355 assistant_messages.len(),
7356 1,
7357 "reasoning turn should produce exactly one assistant history message: {final_history:?}"
7358 );
7359 let assistant_content = assistant_messages
7360 .first()
7361 .expect("expected assistant history message");
7362 assert!(assistant_content.iter().any(|item| matches!(
7363 item,
7364 AssistantContent::Text(text) if text.text == "final answer"
7365 )));
7366 assert!(assistant_content.iter().any(|item| matches!(
7367 item,
7368 AssistantContent::Reasoning(reasoning)
7369 if reasoning.id.as_deref() == Some("rs_1")
7370 && reasoning.content.iter().any(|content| matches!(
7371 content,
7372 ReasoningContent::Text { text, .. } if text == "reasoned step"
7373 ))
7374 )));
7375 let reasoning_index = assistant_content
7376 .iter()
7377 .position(|item| matches!(item, AssistantContent::Reasoning(_)))
7378 .expect("assistant history should contain reasoning");
7379 let text_index = assistant_content
7380 .iter()
7381 .position(|item| matches!(item, AssistantContent::Text(_)))
7382 .expect("assistant history should contain text");
7383 assert!(
7384 reasoning_index < text_index,
7385 "assistant reasoning must be stored before assistant text: {assistant_content:?}"
7386 );
7387 }
7388
7389 #[tokio::test]
7390 async fn streaming_with_history_overrides_memory() {
7391 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7392
7393 let memory = InMemoryConversationMemory::new();
7394 memory
7395 .append("t1", vec![Message::user("from-memory")])
7396 .await
7397 .unwrap();
7398
7399 let agent = AgentBuilder::new(streaming_text_then_final_model())
7400 .memory(memory.clone())
7401 .build();
7402
7403 let mut stream = agent
7404 .stream_prompt("hi")
7405 .conversation("t1")
7406 .history(vec![Message::user("from-caller")])
7407 .await;
7408
7409 while let Some(item) = stream.next().await {
7410 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7411 break;
7412 }
7413 }
7414
7415 let stored = memory.load("t1").await.unwrap();
7416 assert_eq!(
7417 stored.len(),
7418 1,
7419 "with_history bypasses memory; only the pre-seeded entry remains: {stored:?}"
7420 );
7421 }
7422
7423 #[tokio::test]
7424 async fn streaming_without_memory_disables_for_request() {
7425 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7426
7427 let memory = InMemoryConversationMemory::new();
7428 let agent = AgentBuilder::new(streaming_text_then_final_model())
7429 .memory(memory.clone())
7430 .conversation("default")
7431 .build();
7432
7433 let mut stream = agent.stream_prompt("hi").without_memory().await;
7434
7435 while let Some(item) = stream.next().await {
7436 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7437 break;
7438 }
7439 }
7440
7441 let stored = memory.load("default").await.unwrap();
7442 assert!(stored.is_empty(), "without_memory disables save");
7443 }
7444
7445 #[tokio::test]
7446 async fn streaming_load_error_yields_memory_error() {
7447 let agent = AgentBuilder::new(streaming_text_then_final_model())
7448 .memory(FailingMemory::default())
7449 .build();
7450
7451 let mut stream = agent.stream_prompt("hi").conversation("t1").await;
7452
7453 let first = stream.next().await.expect("at least one item");
7454 match first {
7455 Err(StreamingError::Prompt(err)) => match *err {
7456 PromptError::MemoryError(err) => {
7457 assert!(err.to_string().contains("load boom"));
7458 }
7459 other => panic!("expected PromptError::MemoryError, got {other:?}"),
7460 },
7461 other => panic!("expected StreamingError::Prompt, got {other:?}"),
7462 }
7463 }
7464
7465 #[tokio::test]
7466 async fn streaming_with_filter_shapes_loaded_history() {
7467 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
7468
7469 let memory = InMemoryConversationMemory::new()
7470 .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
7471 memory
7472 .append(
7473 "t1",
7474 vec![
7475 Message::user("1"),
7476 Message::assistant("2"),
7477 Message::user("3"),
7478 Message::assistant("4"),
7479 ],
7480 )
7481 .await
7482 .unwrap();
7483
7484 let model = MockCompletionModel::from_stream_turns([[
7485 MockStreamEvent::text("ok"),
7486 MockStreamEvent::final_response_with_total_tokens(1),
7487 ]]);
7488 let recorded = model.clone();
7489 let agent = AgentBuilder::new(model).memory(memory).build();
7490
7491 let mut stream = agent.stream_prompt("ping").conversation("t1").await;
7492 while let Some(item) = stream.next().await {
7493 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7494 break;
7495 }
7496 }
7497
7498 let received = recorded.requests()[0].chat_history.clone();
7499 assert_eq!(
7500 received.len(),
7501 3,
7502 "window-truncated history (2) + current prompt: {received:?}"
7503 );
7504 }
7505
7506 #[tokio::test]
7507 async fn streaming_append_error_does_not_suppress_final_response() {
7508 let agent = AgentBuilder::new(streaming_text_then_final_model())
7509 .memory(AppendFailingMemory::default())
7510 .build();
7511
7512 let mut stream = agent.stream_prompt("hi").conversation("t1").await;
7513
7514 let mut saw_final = false;
7515 while let Some(item) = stream.next().await {
7516 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
7517 saw_final = true;
7518 break;
7519 }
7520 }
7521 assert!(
7522 saw_final,
7523 "FinalResponse must be yielded even when memory.append fails"
7524 );
7525 }
7526}