1use crate::{
2 OneOrMany,
3 agent::completion::{PreparedCompletionRequest, build_prepared_completion_request},
4 agent::hook::{
5 AgentHook, HookContext, HookStack, InvalidToolCallHookAction, StepEvent, StepEventKind,
6 },
7 agent::prompt_request::{assistant_text_from_choice, is_empty_assistant_turn},
8 agent::run::{
9 AgentRun, AgentRunStep, PendingToolCall,
10 streamed::{StreamedResolution, StreamedTurnAssembler, StreamedTurnEvent},
11 },
12 agent::runner::{
13 AgentRunner, CompletionCallOutcome, InvalidDecision, ToolExecution, acquire_agent_span,
14 append_run_messages, build_chat_span, flow_into_invalid, new_execute_tool_span,
15 observe_flow, resolve_completion_call, run_single_tool,
16 },
17 completion::GetTokenUsage,
18 message::{AssistantContent, UserContent},
19 streaming::{StreamedAssistantContent, StreamedUserContent, ToolCallDeltaContent},
20 tool::ToolCallExtensions,
21 wasm_compat::{WasmBoxedFuture, WasmCompatSend},
22};
23use futures::{Stream, StreamExt, stream};
24use serde::{Deserialize, Serialize};
25use std::{collections::VecDeque, pin::Pin, sync::Arc};
26use tracing_futures::Instrument;
27
28use super::{CompletionCall, PromptResponse, forward_prompt_setters};
29use crate::{
30 agent::Agent,
31 completion::{CompletionError, CompletionModel, PromptError},
32 message::{Message, Text},
33 tool::ToolSetError,
34};
35
36#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
37pub type StreamingResult<R> =
38 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send>>;
39
40#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
41pub type StreamingResult<R> =
42 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>>>>;
43
44#[derive(Deserialize, Serialize, Debug, Clone)]
45#[serde(tag = "type", rename_all = "camelCase")]
46#[non_exhaustive]
47pub enum MultiTurnStreamItem<R> {
48 StreamAssistantItem(StreamedAssistantContent<R>),
64 ToolExecutionStart {
75 tool_call: crate::message::ToolCall,
81 internal_call_id: String,
85 },
86 StreamUserItem(StreamedUserContent),
92 CompletionCall(CompletionCall),
110 FinalResponse(PromptResponse),
113}
114
115fn final_response_from_content(
118 content: OneOrMany<AssistantContent>,
119 aggregated_usage: crate::completion::Usage,
120 completion_calls: Vec<CompletionCall>,
121 history: Option<Vec<Message>>,
122) -> PromptResponse {
123 let mut response = PromptResponse::new(assistant_text_from_choice(&content), aggregated_usage)
124 .with_content(content)
125 .with_completion_calls(completion_calls);
126 response.messages = history;
127 response
128}
129
130impl<R> MultiTurnStreamItem<R> {
131 pub(crate) fn stream_item(item: StreamedAssistantContent<R>) -> Self {
132 Self::StreamAssistantItem(item)
133 }
134
135 pub fn final_response(
136 content: OneOrMany<AssistantContent>,
137 aggregated_usage: crate::completion::Usage,
138 ) -> Self {
139 Self::FinalResponse(final_response_from_content(
140 content,
141 aggregated_usage,
142 Vec::new(),
143 None,
144 ))
145 }
146
147 pub fn final_response_with_history(
148 content: OneOrMany<AssistantContent>,
149 aggregated_usage: crate::completion::Usage,
150 history: Option<Vec<Message>>,
151 ) -> Self {
152 Self::FinalResponse(final_response_from_content(
153 content,
154 aggregated_usage,
155 Vec::new(),
156 history,
157 ))
158 }
159
160 pub(crate) fn final_response_with_completion_calls(
161 content: OneOrMany<AssistantContent>,
162 aggregated_usage: crate::completion::Usage,
163 completion_calls: Vec<CompletionCall>,
164 history: Option<Vec<Message>>,
165 ) -> Self {
166 Self::FinalResponse(final_response_from_content(
167 content,
168 aggregated_usage,
169 completion_calls,
170 history,
171 ))
172 }
173}
174
175async fn drain_stream_usage<R>(
178 stream: &mut crate::streaming::StreamingCompletionResponse<R>,
179) -> Result<crate::completion::Usage, StreamingError>
180where
181 R: Clone + Unpin + GetTokenUsage,
182{
183 while let Some(content) = stream.next().await {
184 match content {
185 Ok(StreamedAssistantContent::Final(final_resp)) => {
186 return Ok(final_resp.token_usage());
187 }
188 Ok(_) => {}
189 Err(err) => return Err(err.into()),
190 }
191 }
192
193 Ok(crate::completion::Usage::new())
194}
195
196pub(crate) fn record_usage_on_span(span: &tracing::Span, usage: crate::completion::Usage) {
197 span.record("gen_ai.usage.input_tokens", usage.input_tokens);
198 span.record("gen_ai.usage.output_tokens", usage.output_tokens);
199 span.record(
200 "gen_ai.usage.cache_read.input_tokens",
201 usage.cached_input_tokens,
202 );
203 span.record(
204 "gen_ai.usage.cache_creation.input_tokens",
205 usage.cache_creation_input_tokens,
206 );
207 span.record(
208 "gen_ai.usage.tool_use_prompt_tokens",
209 usage.tool_use_prompt_tokens,
210 );
211 span.record("gen_ai.usage.reasoning_tokens", usage.reasoning_tokens);
212}
213
214fn finalize_streamed_choice(
228 last_final_choice: &OneOrMany<AssistantContent>,
229 output: &str,
230) -> Option<OneOrMany<AssistantContent>> {
231 let finalized_via_output_tool = last_final_choice
232 .iter()
233 .any(|item| matches!(item, AssistantContent::ToolCall(_)));
234 if !finalized_via_output_tool {
235 return None;
236 }
237 let mut items: Vec<AssistantContent> = last_final_choice
238 .iter()
239 .filter(|item| {
240 !matches!(
241 item,
242 AssistantContent::ToolCall(_) | AssistantContent::Text(_)
243 )
244 })
245 .cloned()
246 .collect();
247 items.push(AssistantContent::text(output.to_string()));
248 Some(
249 OneOrMany::from_iter_optional(items)
250 .unwrap_or_else(|| OneOrMany::one(AssistantContent::text(output.to_string()))),
251 )
252}
253
254#[derive(Debug, thiserror::Error)]
255pub enum StreamingError {
256 #[error("CompletionError: {0}")]
257 Completion(#[from] CompletionError),
258 #[error("PromptError: {0}")]
259 Prompt(#[from] Box<PromptError>),
260 #[error("ToolSetError: {0}")]
261 Tool(#[from] ToolSetError),
262}
263
264impl From<crate::memory::MemoryError> for StreamingError {
268 fn from(err: crate::memory::MemoryError) -> Self {
269 Self::Completion(CompletionError::RequestError(Box::new(err)))
270 }
271}
272
273pub struct StreamingPromptRequest<M>
281where
282 M: CompletionModel,
283{
284 runner: AgentRunner<M>,
286}
287
288impl<M> StreamingPromptRequest<M>
289where
290 M: CompletionModel + 'static,
291 <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
292{
293 pub fn new(agent: Arc<Agent<M>>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
296 let mut runner = AgentRunner::from_agent(agent.as_ref(), prompt);
297 runner.hooks = HookStack::new();
298 StreamingPromptRequest { runner }
299 }
300
301 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
304 StreamingPromptRequest {
305 runner: AgentRunner::from_agent(agent, prompt),
306 }
307 }
308
309 pub fn max_turns(mut self, turns: usize) -> Self {
318 self.runner = self.runner.max_turns(turns);
319 self
320 }
321
322 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
330 self.runner = self.runner.tool_concurrency(concurrency);
331 self
332 }
333
334 pub fn add_hook<H>(mut self, hook: H) -> Self
341 where
342 H: AgentHook<M> + 'static,
343 {
344 self.runner = self.runner.add_hook(hook);
345 self
346 }
347
348 forward_prompt_setters!(runner);
349
350 async fn send(self) -> StreamingResult<M::StreamingResponse> {
351 self.runner.stream().await
352 }
353}
354
355#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
359pub(crate) type DriveStream<'a, R> =
360 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send + 'a>>;
361
362#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
363pub(crate) type DriveStream<'a, R> =
364 Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + 'a>>;
365
366#[allow(clippy::large_enum_variant)]
377pub(crate) enum DriveItem<R> {
378 Item(MultiTurnStreamItem<R>),
382 Done(Box<PromptResponse>),
386}
387
388pub(crate) trait TurnSource<M>: WasmCompatSend
396where
397 M: CompletionModel,
398{
399 type Raw: WasmCompatSend;
401
402 fn open_chat_span(
405 &self,
406 runner: &AgentRunner<M>,
407 effective_preamble: Option<&str>,
408 ) -> tracing::Span;
409
410 #[allow(clippy::too_many_arguments)]
414 fn run_model_turn<'a>(
415 &'a mut self,
416 runner: &'a AgentRunner<M>,
417 hook_ctx: &'a HookContext,
418 run: &'a mut AgentRun,
419 prepared: PreparedCompletionRequest<M>,
420 chat_span: tracing::Span,
421 agent_span: &'a tracing::Span,
422 prompt: Message,
423 ) -> DriveStream<'a, Self::Raw>;
424
425 fn run_tool_calls<'a>(
428 &'a self,
429 runner: &'a AgentRunner<M>,
430 hook_ctx: &'a HookContext,
431 run: &'a mut AgentRun,
432 calls: Vec<PendingToolCall>,
433 ) -> DriveStream<'a, Self::Raw>;
434
435 fn record_run_level_telemetry(
438 &self,
439 agent_span: &tracing::Span,
440 response: &PromptResponse,
441 created_agent_span: bool,
442 );
443
444 fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem<Self::Raw>>;
447}
448
449pub(crate) fn streaming_error_into_prompt(err: StreamingError) -> PromptError {
453 match err {
454 StreamingError::Completion(err) => PromptError::CompletionError(err),
455 StreamingError::Prompt(err) => *err,
456 StreamingError::Tool(err) => PromptError::ToolError(err),
457 }
458}
459
460pub(crate) fn drive_agent<M, S>(
468 runner: AgentRunner<M>,
469 mut source: S,
470 mut run: AgentRun,
471 agent_span: tracing::Span,
472 created_agent_span: bool,
473 memory_handle: Option<(Arc<dyn crate::memory::ConversationMemory>, String)>,
474 is_streaming: bool,
475) -> impl Stream<Item = Result<DriveItem<S::Raw>, StreamingError>>
476where
477 M: CompletionModel,
478 S: TurnSource<M>,
479{
480 async_stream::stream! {
481 let hook_ctx = HookContext::new(is_streaming, runner.agent_name.clone());
485
486 'outer: loop {
487 let step = match run.next_step() {
488 Ok(step) => step,
489 Err(err) => {
490 yield Err(Box::new(err).into());
491 break 'outer;
492 }
493 };
494
495 match step {
496 AgentRunStep::CallModel { prompt, history, turn } => {
497 if runner.max_turns > 1 {
498 tracing::info!("Current conversation Turns: {}/{}", turn, runner.max_turns);
499 }
500 hook_ctx.set_turn(turn);
501
502 let request_patch =
503 match resolve_completion_call(&runner.hooks, &hook_ctx, &prompt, &history, turn).await {
504 CompletionCallOutcome::Terminate(reason) => {
505 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
506 break 'outer;
507 }
508 CompletionCallOutcome::Proceed(request_patch) => request_patch,
509 };
510
511 let effective_preamble = request_patch
516 .as_ref()
517 .and_then(|o| o.preamble.as_deref())
518 .or(runner.preamble.as_deref());
519
520 let chat_span = source.open_chat_span(&runner, effective_preamble);
521
522 let committed_output_tool = run.output_tool_name().map(str::to_owned);
525 let prepared = match build_prepared_completion_request(
526 &runner.model,
527 prompt.clone(),
528 &history,
529 runner.preamble.as_deref(),
530 &runner.static_context,
531 runner.temperature,
532 runner.max_tokens,
533 runner.additional_params.as_ref(),
534 runner.tool_choice.as_ref(),
535 &runner.tool_server_handle,
536 &runner.dynamic_context,
537 runner.output_schema.as_ref(),
538 &runner.output_mode,
539 committed_output_tool.as_deref(),
540 request_patch.as_ref(),
541 )
542 .await
543 {
544 Ok(prepared) => prepared,
545 Err(err) => {
546 yield Err(err.into());
547 break 'outer;
548 }
549 };
550 run.set_output_tool_name(prepared.output_tool_name.clone());
551
552 let mut turn_stream = source.run_model_turn(
553 &runner,
554 &hook_ctx,
555 &mut run,
556 prepared,
557 chat_span,
558 &agent_span,
559 prompt,
560 );
561 let mut errored = false;
562 while let Some(item) = turn_stream.next().await {
563 match item {
564 Ok(item) => yield Ok(DriveItem::Item(item)),
565 Err(err) => {
566 errored = true;
567 yield Err(err);
568 break;
569 }
570 }
571 }
572 drop(turn_stream);
573 if errored {
574 break 'outer;
575 }
576 }
577 AgentRunStep::CallTools { calls } => {
578 let mut tool_stream = source.run_tool_calls(&runner, &hook_ctx, &mut run, calls);
579 let mut errored = false;
580 while let Some(item) = tool_stream.next().await {
581 match item {
582 Ok(item) => yield Ok(DriveItem::Item(item)),
583 Err(err) => {
584 errored = true;
585 yield Err(err);
586 break;
587 }
588 }
589 }
590 drop(tool_stream);
591 if errored {
592 break 'outer;
593 }
594 }
595 AgentRunStep::Done(response) => {
596 tracing::info!(
599 turn = run.turn(),
600 max_turns = runner.max_turns,
601 "Agent run finished"
602 );
603 source.record_run_level_telemetry(&agent_span, &response, created_agent_span);
604 append_run_messages(
605 memory_handle.as_ref(),
606 response.messages.as_deref().unwrap_or_default(),
607 )
608 .await;
609 if let Some(final_item) = source.final_item(&response) {
613 yield Ok(DriveItem::Item(final_item));
614 }
615 yield Ok(DriveItem::Done(Box::new(response)));
616 break 'outer;
617 }
618 }
619 }
620 }
621}
622
623pub(crate) fn drive_tool_calls<'a, M, R, F>(
647 runner: &'a AgentRunner<M>,
648 hook_ctx: &'a HookContext,
649 run: &'a mut AgentRun,
650 calls: Vec<PendingToolCall>,
651 chain_tool_span: F,
652 forward_items: bool,
653) -> DriveStream<'a, R>
654where
655 M: CompletionModel,
656 R: WasmCompatSend + 'a,
657 F: Fn(tracing::Span) -> tracing::Span + WasmCompatSend + 'a,
658{
659 struct PreparedToolCall {
663 tool_call: crate::message::ToolCall,
664 preresolved_result: Option<UserContent>,
665 internal_call_id: String,
666 span: tracing::Span,
667 }
668 enum ToolSurface {
676 Executed(Box<crate::message::ToolCall>),
678 Skipped,
679 Preresolved,
680 }
681 struct CollectedToolResult {
684 content: UserContent,
685 internal_call_id: String,
686 surface: ToolSurface,
687 }
688
689 Box::pin(async_stream::stream! {
690 let full_history_for_errors = run.full_history();
691 let call_count = calls.len();
692
693 let mut prepared: Vec<PreparedToolCall> = Vec::with_capacity(call_count);
700 for pending in calls {
701 let internal_call_id = pending.internal_call_id.unwrap_or_else(crate::id::generate);
702 let (span, preresolved_result) = match pending.preresolved_result {
703 Some(result) => (tracing::Span::none(), Some(result)),
704 None => {
705 if forward_items {
706 yield Ok(MultiTurnStreamItem::stream_item(
707 StreamedAssistantContent::ToolCall {
708 tool_call: pending.tool_call.clone(),
709 internal_call_id: internal_call_id.clone(),
710 },
711 ));
712 }
713 (chain_tool_span(new_execute_tool_span()), None)
714 }
715 };
716 prepared.push(PreparedToolCall {
717 tool_call: pending.tool_call,
718 preresolved_result,
719 internal_call_id,
720 span,
721 });
722 }
723
724 let mut collected: Vec<Option<CollectedToolResult>> =
730 (0..call_count).map(|_| None).collect();
731 let mut first_error: Option<(usize, PromptError)> = None;
732
733 if runner.concurrency <= 1 {
734 for (index, call) in prepared.into_iter().enumerate() {
737 let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
738 if let Some(result) = preresolved_result {
739 if let Some(slot) = collected.get_mut(index) {
740 *slot = Some(CollectedToolResult {
741 content: result,
742 internal_call_id,
743 surface: ToolSurface::Preresolved,
744 });
745 }
746 continue;
747 }
748 let outcome = run_single_tool(
749 &runner.hooks,
750 hook_ctx,
751 &runner.tool_server_handle,
752 &runner.tool_extensions,
753 &tool_call,
754 &internal_call_id,
755 &full_history_for_errors,
756 )
757 .instrument(span)
758 .await;
759 match outcome {
760 Ok(outcome) => {
761 let surface = match outcome.execution {
762 ToolExecution::Executed(effective) => ToolSurface::Executed(effective),
763 ToolExecution::Skipped => ToolSurface::Skipped,
764 };
765 if let Some(slot) = collected.get_mut(index) {
766 *slot = Some(CollectedToolResult {
767 content: outcome.content,
768 internal_call_id,
769 surface,
770 });
771 }
772 }
773 Err(err) => {
774 first_error = Some((index, err));
775 break;
776 }
777 }
778 }
779 } else {
780 let terminating = Arc::new(std::sync::atomic::AtomicBool::new(false));
786 let unordered = stream::iter(prepared.into_iter().enumerate())
787 .map(|(index, call)| {
788 let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
789 let hooks = &runner.hooks;
790 let tool_server_handle = &runner.tool_server_handle;
791 let tool_extensions = &runner.tool_extensions;
792 let full_history_for_errors = &full_history_for_errors;
793 let terminating = terminating.clone();
794 async move {
795 if let Some(result) = preresolved_result {
796 return (
797 index,
798 Some(Ok(CollectedToolResult {
799 content: result,
800 internal_call_id,
801 surface: ToolSurface::Preresolved,
802 })),
803 );
804 }
805 if terminating.load(std::sync::atomic::Ordering::SeqCst) {
807 return (index, None);
808 }
809 let outcome = run_single_tool(
810 hooks,
811 hook_ctx,
812 tool_server_handle,
813 tool_extensions,
814 &tool_call,
815 &internal_call_id,
816 full_history_for_errors,
817 )
818 .await;
819 let mapped = outcome.map(|o| {
820 let surface = match o.execution {
821 ToolExecution::Executed(effective) => {
822 ToolSurface::Executed(effective)
823 }
824 ToolExecution::Skipped => ToolSurface::Skipped,
825 };
826 CollectedToolResult {
827 content: o.content,
828 internal_call_id,
829 surface,
830 }
831 });
832 (index, Some(mapped))
833 }
834 .instrument(span)
835 })
836 .buffer_unordered(runner.concurrency);
837 futures::pin_mut!(unordered);
838
839 while let Some((index, outcome)) = unordered.next().await {
840 let result = match outcome {
842 Some(result) => result,
843 None => continue,
844 };
845 match result {
846 Ok(collected_result) => {
847 if let Some(slot) = collected.get_mut(index) {
848 *slot = Some(collected_result);
849 }
850 }
851 Err(err) => {
852 terminating.store(true, std::sync::atomic::Ordering::SeqCst);
855 if first_error.as_ref().is_none_or(|(i, _)| index < *i) {
856 first_error = Some((index, err));
857 }
858 }
859 }
860 }
861 }
862
863 if let Some((_, err)) = first_error {
866 yield Err(StreamingError::Prompt(Box::new(err)));
867 return;
868 }
869
870 let mut committed: Vec<UserContent> = Vec::with_capacity(call_count);
878 for slot in collected {
879 let CollectedToolResult { content, internal_call_id, surface } = match slot {
880 Some(collected_result) => collected_result,
881 None => {
882 yield Err(StreamingError::Prompt(Box::new(PromptError::CompletionError(
883 CompletionError::ResponseError(
884 "tool execution finished without producing every result".to_string(),
885 ),
886 ))));
887 return;
888 }
889 };
890 if forward_items {
891 let surface_result = match surface {
895 ToolSurface::Executed(tool_call) => {
896 yield Ok(MultiTurnStreamItem::ToolExecutionStart {
897 tool_call: *tool_call,
898 internal_call_id: internal_call_id.clone(),
899 });
900 true
901 }
902 ToolSurface::Skipped => true,
903 ToolSurface::Preresolved => false,
904 };
905 if surface_result
906 && let UserContent::ToolResult(tool_result) = &content
907 {
908 yield Ok(MultiTurnStreamItem::StreamUserItem(
909 StreamedUserContent::ToolResult {
910 tool_result: tool_result.clone(),
911 internal_call_id,
912 },
913 ));
914 }
915 }
916 committed.push(content);
917 }
918
919 if let Err(err) = run.tool_results(committed) {
920 yield Err(Box::new(err).into());
921 return;
922 }
923 })
924}
925
926pub(crate) struct StreamingTurnSource {
929 last_final_choice: OneOrMany<AssistantContent>,
932 last_message_id: Option<String>,
933 agent_name: String,
935 created_agent_span: bool,
939 observes_text_delta: bool,
942 observes_tool_call_delta: bool,
943 has_hooks: bool,
946}
947
948impl StreamingTurnSource {
949 pub(crate) fn new<M: CompletionModel>(
950 hooks: &HookStack<M>,
951 agent_name: String,
952 created_agent_span: bool,
953 ) -> Self {
954 Self {
955 last_final_choice: OneOrMany::one(AssistantContent::text("")),
956 last_message_id: None,
957 agent_name,
958 created_agent_span,
959 observes_text_delta: hooks.observes(StepEventKind::TextDelta),
960 observes_tool_call_delta: hooks.observes(StepEventKind::ToolCallDelta),
961 has_hooks: !hooks.is_empty(),
962 }
963 }
964}
965
966impl<M> TurnSource<M> for StreamingTurnSource
967where
968 M: CompletionModel,
969 <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
970{
971 type Raw = M::StreamingResponse;
972
973 fn open_chat_span(
974 &self,
975 runner: &AgentRunner<M>,
976 effective_preamble: Option<&str>,
977 ) -> tracing::Span {
978 build_chat_span!(runner, effective_preamble, "chat_streaming")
979 }
980
981 fn run_model_turn<'a>(
982 &'a mut self,
983 runner: &'a AgentRunner<M>,
984 hook_ctx: &'a HookContext,
985 run: &'a mut AgentRun,
986 prepared: PreparedCompletionRequest<M>,
987 chat_span: tracing::Span,
988 agent_span: &'a tracing::Span,
989 current_prompt: Message,
990 ) -> DriveStream<'a, M::StreamingResponse> {
991 Box::pin(async_stream::stream! {
992 let mut stream = match prepared
993 .builder
994 .stream()
995 .instrument(chat_span.clone())
996 .await
997 {
998 Ok(stream) => stream,
999 Err(err) => {
1000 yield Err(err.into());
1001 return;
1002 }
1003 };
1004 let mut last_usage = crate::completion::Usage::new();
1007
1008 let mut assembler = StreamedTurnAssembler::new(
1009 prepared.executable_tool_names.clone(),
1010 prepared.allowed_tool_names.clone(),
1011 );
1012 let mut completion_call_emitted = false;
1013 let mut turn_abandoned = false;
1014 let mut turn_recovered = false;
1018
1019 macro_rules! emit_completion_call {
1027 ($usage:expr) => {{
1028 let usage = $usage;
1029 last_usage = usage;
1030 if !completion_call_emitted {
1031 if usage.has_values() {
1032 record_usage_on_span(&chat_span, usage);
1033 }
1034 match run.record_streamed_completion_call(usage) {
1035 Ok(call) => {
1036 completion_call_emitted = true;
1037 Ok(Some(MultiTurnStreamItem::CompletionCall(call)))
1038 }
1039 Err(err) => Err(Box::new(err).into()),
1040 }
1041 } else {
1042 Ok(None)
1043 }
1044 }};
1045 }
1046
1047 'turn: while let Some(item) = stream.next().await {
1048 let item = match item {
1049 Ok(item) => item,
1050 Err(err) => {
1051 yield Err(err.into());
1052 return;
1053 }
1054 };
1055 let mut events: VecDeque<StreamedTurnEvent> = match assembler.ingest(&item) {
1056 Ok(events) => events.into(),
1057 Err(err) => {
1058 yield Err(err.into());
1059 return;
1060 }
1061 };
1062 let mut item_slot = Some(item);
1065 while let Some(event) = events.pop_front() {
1066 match event {
1067 StreamedTurnEvent::EmitIngested => {
1068 if self.observes_text_delta
1069 && let Some(StreamedAssistantContent::Text(text)) =
1070 item_slot.as_ref()
1071 && let Some(reason) = observe_flow(
1072 runner
1073 .hooks
1074 .on_event(hook_ctx, StepEvent::TextDelta {
1075 delta: &text.text,
1076 aggregated: assembler.aggregated_text(),
1077 })
1078 .await,
1079 )
1080 {
1081 yield Err(StreamingError::Prompt(Box::new(
1082 run.cancel_error(reason),
1083 )));
1084 return;
1085 }
1086 if let Some(item) = item_slot.take() {
1087 yield Ok(MultiTurnStreamItem::stream_item(item));
1088 }
1089 }
1090 StreamedTurnEvent::EmitToolCallDelta {
1091 id,
1092 internal_call_id,
1093 content,
1094 } => {
1095 if self.observes_tool_call_delta {
1096 let (delta_name, delta_text) = match &content {
1097 ToolCallDeltaContent::Name(name) => (Some(name.as_str()), ""),
1098 ToolCallDeltaContent::Delta(delta) => (None, delta.as_str()),
1099 };
1100 if let Some(reason) = observe_flow(
1101 runner
1102 .hooks
1103 .on_event(hook_ctx, StepEvent::ToolCallDelta {
1104 tool_call_id: &id,
1105 internal_call_id: &internal_call_id,
1106 tool_name: delta_name,
1107 delta: delta_text,
1108 })
1109 .await,
1110 ) {
1111 yield Err(StreamingError::Prompt(Box::new(
1112 run.cancel_error(reason),
1113 )));
1114 return;
1115 }
1116 }
1117
1118 yield Ok(MultiTurnStreamItem::StreamAssistantItem(
1119 StreamedAssistantContent::ToolCallDelta {
1120 id,
1121 internal_call_id,
1122 content,
1123 },
1124 ));
1125 }
1126 StreamedTurnEvent::Completed { usage, emit_final } => {
1127 match emit_completion_call!(usage) {
1128 Ok(Some(item)) => yield Ok(item),
1129 Ok(None) => {}
1130 Err(err) => {
1131 yield Err(err);
1132 return;
1133 }
1134 }
1135
1136 if emit_final
1137 && let Some(StreamedAssistantContent::Final(final_resp)) =
1138 item_slot.as_ref()
1139 {
1140 if !turn_recovered
1141 && let Some(reason) = observe_flow(
1142 runner
1143 .hooks
1144 .on_event(hook_ctx, StepEvent::StreamResponseFinish {
1145 prompt: ¤t_prompt,
1146 response: final_resp,
1147 })
1148 .await,
1149 )
1150 {
1151 yield Err(StreamingError::Prompt(Box::new(
1152 run.cancel_error(reason),
1153 )));
1154 return;
1155 }
1156 if let Some(item) = item_slot.take() {
1157 yield Ok(MultiTurnStreamItem::stream_item(item));
1158 }
1159 }
1160 }
1161 StreamedTurnEvent::InvalidToolCall(invalid) => {
1162 let partial = assembler.partial_turn(stream.message_id.clone());
1163 let action = if self.has_hooks {
1167 let context =
1168 run.streamed_invalid_tool_call_context(&partial, &invalid);
1169 match flow_into_invalid(
1170 runner
1171 .hooks
1172 .on_event(hook_ctx, StepEvent::InvalidToolCall(&context))
1173 .await,
1174 ) {
1175 InvalidDecision::Action(action) => action,
1176 InvalidDecision::Terminate(reason) => {
1177 yield Err(StreamingError::Prompt(Box::new(
1178 run.cancel_error(reason),
1179 )));
1180 return;
1181 }
1182 }
1183 } else {
1184 InvalidToolCallHookAction::fail()
1185 };
1186
1187 let resolution =
1188 match run.resolve_streamed_invalid_tool_call(&partial, &invalid, action) {
1189 Ok(resolution) => resolution,
1190 Err(err) => {
1191 yield Err(Box::new(err).into());
1192 return;
1193 }
1194 };
1195
1196 match resolution {
1197 StreamedResolution::Repaired { .. } => {
1198 turn_recovered = true;
1202 events.extend(assembler.resolve_pending_invalid(&resolution));
1203 }
1204 StreamedResolution::TurnAbandoned {
1205 ref skipped_tool_result,
1206 } => {
1207 let skipped_tool_result = skipped_tool_result.clone();
1208 assembler.resolve_pending_invalid(&resolution);
1209
1210 if let Some(err) = assembler.pending_delta_error() {
1211 yield Err(err.into());
1212 return;
1213 }
1214 let drained_usage = match drain_stream_usage(&mut stream).await {
1215 Ok(usage) => usage,
1216 Err(err) => {
1217 yield Err(err);
1218 return;
1219 }
1220 };
1221 match emit_completion_call!(drained_usage) {
1222 Ok(Some(item)) => yield Ok(item),
1223 Ok(None) => {}
1224 Err(err) => {
1225 yield Err(err);
1226 return;
1227 }
1228 }
1229 if let Some(tool_result) = skipped_tool_result {
1230 yield Ok(MultiTurnStreamItem::StreamUserItem(
1231 StreamedUserContent::ToolResult {
1232 tool_result,
1233 internal_call_id: invalid.internal_call_id.clone(),
1234 },
1235 ));
1236 }
1237 turn_abandoned = true;
1238 break 'turn;
1239 }
1240 }
1241 }
1242 }
1243 }
1244 }
1245
1246 if turn_abandoned {
1247 return;
1248 }
1249
1250 if let Some(err) = assembler.pending_delta_error() {
1251 yield Err(err.into());
1252 return;
1253 }
1254
1255 if !completion_call_emitted {
1260 match run.record_streamed_completion_call(crate::completion::Usage::new()) {
1261 Ok(call) => yield Ok(MultiTurnStreamItem::CompletionCall(call)),
1262 Err(err) => {
1263 yield Err(Box::new(err).into());
1264 return;
1265 }
1266 }
1267 }
1268
1269 let final_turn_content = stream.choice.clone();
1270 if self.created_agent_span {
1273 agent_span.record(
1274 "gen_ai.completion",
1275 assistant_text_from_choice(&final_turn_content),
1276 );
1277 }
1278
1279 self.last_message_id = stream.message_id.clone();
1280 let streamed_turn = assembler.finish(stream.message_id.clone(), &final_turn_content);
1281 let canonical_choice = streamed_turn.choice.clone();
1288 if let Err(err) = run.streamed_turn(streamed_turn) {
1289 yield Err(Box::new(err).into());
1290 return;
1291 }
1292
1293 if !turn_recovered
1298 && let Some(reason) = observe_flow(
1299 runner
1300 .hooks
1301 .on_event(hook_ctx, StepEvent::ModelTurnFinished {
1302 turn: hook_ctx.turn(),
1303 content: &canonical_choice,
1304 usage: last_usage,
1305 })
1306 .await,
1307 )
1308 {
1309 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
1310 return;
1311 }
1312
1313 self.last_final_choice = final_turn_content;
1314 })
1315 }
1316
1317 fn run_tool_calls<'a>(
1318 &'a self,
1319 runner: &'a AgentRunner<M>,
1320 hook_ctx: &'a HookContext,
1321 run: &'a mut AgentRun,
1322 calls: Vec<PendingToolCall>,
1323 ) -> DriveStream<'a, M::StreamingResponse> {
1324 drive_tool_calls(runner, hook_ctx, run, calls, |span| span, true)
1327 }
1328
1329 fn record_run_level_telemetry(
1330 &self,
1331 agent_span: &tracing::Span,
1332 response: &PromptResponse,
1333 created_agent_span: bool,
1334 ) {
1335 if created_agent_span {
1336 record_usage_on_span(agent_span, response.usage);
1337 }
1338 }
1339
1340 fn final_item(
1341 &self,
1342 response: &PromptResponse,
1343 ) -> Option<MultiTurnStreamItem<M::StreamingResponse>> {
1344 let final_choice = finalize_streamed_choice(&self.last_final_choice, &response.output)
1347 .unwrap_or_else(|| {
1348 if is_empty_assistant_turn(&self.last_final_choice) {
1349 tracing::warn!(
1350 agent_name = self.agent_name.as_str(),
1351 message_id = ?self.last_message_id,
1352 "Streaming turn completed without assistant text; final response will be empty"
1353 );
1354 }
1355 self.last_final_choice.clone()
1356 });
1357 let final_messages: Option<Vec<Message>> =
1360 Some(response.messages.clone().unwrap_or_default());
1361 Some(MultiTurnStreamItem::final_response_with_completion_calls(
1362 final_choice,
1363 response.usage,
1364 response.completion_calls.clone(),
1365 final_messages,
1366 ))
1367 }
1368}
1369
1370impl<M> AgentRunner<M>
1371where
1372 M: CompletionModel + 'static,
1373 <M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
1374{
1375 pub async fn stream(self) -> StreamingResult<M::StreamingResponse> {
1385 let (agent_span, created_agent_span) =
1386 acquire_agent_span(self.agent_name_or_default(), self.preamble.as_deref());
1387
1388 if let Some(text) = self.prompt.rag_text() {
1389 agent_span.record("gen_ai.prompt", text);
1390 }
1391
1392 let (history_override, memory_handle) = match &self.chat_history {
1396 Some(_) => (None, None),
1397 None => match (&self.memory, &self.conversation_id) {
1398 (Some(memory), Some(id)) => match memory.load(id).await {
1399 Ok(loaded) => (Some(loaded), Some((memory.clone(), id.clone()))),
1400 Err(err) => {
1401 let stream = async_stream::stream! {
1402 yield Err(StreamingError::from(err));
1403 };
1404 return Box::pin(stream.instrument(agent_span));
1407 }
1408 },
1409 _ => (None, None),
1410 },
1411 };
1412
1413 let run = self.build_run(history_override);
1414 let source = StreamingTurnSource::new(
1415 &self.hooks,
1416 self.agent_name_or_default().to_string(),
1417 created_agent_span,
1418 );
1419
1420 let driver = drive_agent(
1424 self,
1425 source,
1426 run,
1427 agent_span.clone(),
1428 created_agent_span,
1429 memory_handle,
1430 true,
1431 )
1432 .filter_map(|item| {
1433 std::future::ready(match item {
1434 Ok(DriveItem::Item(item)) => Some(Ok(item)),
1435 Ok(DriveItem::Done(_)) => None,
1436 Err(err) => Some(Err(err)),
1437 })
1438 });
1439
1440 Box::pin(driver.instrument(agent_span))
1441 }
1442}
1443
1444impl<M> IntoFuture for StreamingPromptRequest<M>
1445where
1446 M: CompletionModel + 'static,
1447 <M as CompletionModel>::StreamingResponse: WasmCompatSend,
1448{
1449 type Output = StreamingResult<M::StreamingResponse>; type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
1451
1452 fn into_future(self) -> Self::IntoFuture {
1453 Box::pin(async move { self.send().await })
1455 }
1456}
1457
1458pub async fn stream_to_stdout<R>(
1465 stream: &mut StreamingResult<R>,
1466) -> Result<PromptResponse, std::io::Error> {
1467 let mut final_res = PromptResponse::empty();
1468 print!("Response: ");
1469 while let Some(content) = stream.next().await {
1470 match content {
1471 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
1472 Text { text, .. },
1473 ))) => {
1474 print!("{text}");
1475 std::io::Write::flush(&mut std::io::stdout())?;
1476 }
1477 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(
1478 reasoning,
1479 ))) => {
1480 let reasoning = reasoning.display_text();
1481 print!("{reasoning}");
1482 std::io::Write::flush(&mut std::io::stdout())?;
1483 }
1484 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
1485 final_res = res;
1486 }
1487 Err(err) => {
1488 eprintln!("Error: {err}");
1489 }
1490 _ => {}
1491 }
1492 }
1493
1494 Ok(final_res)
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use super::*;
1500 use crate::agent::AgentBuilder;
1501 use crate::agent::hook::{AgentHook, Flow, HookContext, InvalidToolCallContext, StepEvent};
1502 use crate::agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_output};
1503 use crate::agent::run::streamed::merge_reasoning_blocks;
1504 use crate::client::ProviderClient;
1505 use crate::client::completion::CompletionClient;
1506 use crate::completion::{CompletionRequest, PromptError, ToolDefinition, Usage};
1507 use crate::message::{
1508 AssistantContent, DocumentSourceKind, ImageMediaType, Message, ReasoningContent,
1509 ToolChoice, ToolResultContent, UserContent,
1510 };
1511 use crate::providers::anthropic;
1512 use crate::streaming::{StreamingPrompt, ToolCallDeltaContent};
1513 use crate::test_utils::{
1514 AppendFailingMemory, FailingMemory, MockAddTool, MockBarrierTool, MockCompletionModel,
1515 MockExtensionsProbeTool, MockResponse, MockStreamEvent, MockSubtractTool, MockToolError,
1516 SessionId,
1517 };
1518 use crate::tool::{Tool, ToolCallExtensions};
1519 use futures::{StreamExt, TryStreamExt};
1520 use serde::Deserialize;
1521 use std::collections::HashMap;
1522 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1523 use std::sync::{Arc, Mutex};
1524 use std::time::Duration;
1525 use tracing::field::{Field, Visit};
1526 use tracing::{Id, Subscriber};
1527 use tracing_subscriber::layer::{Context, SubscriberExt};
1528 use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
1529
1530 #[test]
1531 fn finalize_streamed_choice_surfaces_output_over_tool_call_and_prose() {
1532 use crate::message::{ToolCall, ToolFunction};
1533
1534 let output_call = AssistantContent::ToolCall(ToolCall::new(
1535 "c1".to_string(),
1536 ToolFunction::new(
1537 "final_result".to_string(),
1538 serde_json::json!({"city": "Tokyo"}),
1539 ),
1540 ));
1541
1542 let with_prose = OneOrMany::many(vec![
1545 AssistantContent::text("Sure, here is the weather:"),
1546 output_call.clone(),
1547 ])
1548 .expect("two items");
1549 let final_choice = finalize_streamed_choice(&with_prose, r#"{"city":"Tokyo"}"#)
1550 .expect("a turn with the output-tool call is finalized via it");
1551 assert_eq!(
1552 assistant_text_from_choice(&final_choice),
1553 r#"{"city":"Tokyo"}"#
1554 );
1555 assert!(
1556 !final_choice
1557 .iter()
1558 .any(|item| matches!(item, AssistantContent::ToolCall(_))),
1559 "no unanswered tool_use should remain in the final content"
1560 );
1561
1562 let only_call = OneOrMany::one(output_call);
1564 let final_choice = finalize_streamed_choice(&only_call, r#"{"city":"Tokyo"}"#)
1565 .expect("finalized via output tool");
1566 assert_eq!(
1567 assistant_text_from_choice(&final_choice),
1568 r#"{"city":"Tokyo"}"#
1569 );
1570
1571 let text_only = OneOrMany::one(AssistantContent::text(r#"{"city":"Tokyo"}"#));
1573 assert!(finalize_streamed_choice(&text_only, r#"{"city":"Tokyo"}"#).is_none());
1574 }
1575
1576 #[test]
1577 fn merge_reasoning_blocks_preserves_order_and_signatures() {
1578 let mut accumulated = Vec::new();
1579 let first = crate::message::Reasoning {
1580 id: Some("rs_1".to_string()),
1581 content: vec![ReasoningContent::Text {
1582 text: "step-1".to_string(),
1583 signature: Some("sig-1".to_string()),
1584 }],
1585 };
1586 let second = crate::message::Reasoning {
1587 id: Some("rs_1".to_string()),
1588 content: vec![
1589 ReasoningContent::Text {
1590 text: "step-2".to_string(),
1591 signature: Some("sig-2".to_string()),
1592 },
1593 ReasoningContent::Summary("summary".to_string()),
1594 ],
1595 };
1596
1597 merge_reasoning_blocks(&mut accumulated, &first);
1598 merge_reasoning_blocks(&mut accumulated, &second);
1599
1600 assert_eq!(accumulated.len(), 1);
1601 let merged = accumulated.first().expect("expected accumulated reasoning");
1602 assert_eq!(merged.id.as_deref(), Some("rs_1"));
1603 assert_eq!(merged.content.len(), 3);
1604 assert!(matches!(
1605 merged.content.first(),
1606 Some(ReasoningContent::Text { text, signature: Some(sig) })
1607 if text == "step-1" && sig == "sig-1"
1608 ));
1609 assert!(matches!(
1610 merged.content.get(1),
1611 Some(ReasoningContent::Text { text, signature: Some(sig) })
1612 if text == "step-2" && sig == "sig-2"
1613 ));
1614 }
1615
1616 #[test]
1617 fn merge_reasoning_blocks_keeps_distinct_ids_as_separate_items() {
1618 let mut accumulated = vec![crate::message::Reasoning {
1619 id: Some("rs_a".to_string()),
1620 content: vec![ReasoningContent::Text {
1621 text: "step-1".to_string(),
1622 signature: None,
1623 }],
1624 }];
1625 let incoming = crate::message::Reasoning {
1626 id: Some("rs_b".to_string()),
1627 content: vec![ReasoningContent::Text {
1628 text: "step-2".to_string(),
1629 signature: None,
1630 }],
1631 };
1632
1633 merge_reasoning_blocks(&mut accumulated, &incoming);
1634 assert_eq!(accumulated.len(), 2);
1635 assert_eq!(
1636 accumulated.first().and_then(|r| r.id.as_deref()),
1637 Some("rs_a")
1638 );
1639 assert_eq!(
1640 accumulated.get(1).and_then(|r| r.id.as_deref()),
1641 Some("rs_b")
1642 );
1643 }
1644
1645 #[test]
1646 fn merge_reasoning_blocks_keeps_none_ids_separate_items() {
1647 let mut accumulated = vec![crate::message::Reasoning {
1648 id: None,
1649 content: vec![ReasoningContent::Text {
1650 text: "first".to_string(),
1651 signature: None,
1652 }],
1653 }];
1654 let incoming = crate::message::Reasoning {
1655 id: None,
1656 content: vec![ReasoningContent::Text {
1657 text: "second".to_string(),
1658 signature: None,
1659 }],
1660 };
1661
1662 merge_reasoning_blocks(&mut accumulated, &incoming);
1663 assert_eq!(accumulated.len(), 2);
1664 assert!(matches!(
1665 accumulated.first(),
1666 Some(crate::message::Reasoning {
1667 id: None,
1668 content
1669 }) if matches!(
1670 content.first(),
1671 Some(ReasoningContent::Text { text, .. }) if text == "first"
1672 )
1673 ));
1674 assert!(matches!(
1675 accumulated.get(1),
1676 Some(crate::message::Reasoning {
1677 id: None,
1678 content
1679 }) if matches!(
1680 content.first(),
1681 Some(ReasoningContent::Text { text, .. }) if text == "second"
1682 )
1683 ));
1684 }
1685
1686 #[test]
1687 fn tool_result_output_preserves_multimodal_tool_output() {
1688 let user_content = tool_result_output(
1689 "tool_call_1".to_string(),
1690 Some("call_1".to_string()),
1691 serde_json::json!({
1692 "response": {
1693 "instruction": "Use the image part to answer."
1694 },
1695 "parts": [
1696 {
1697 "type": "image",
1698 "data": "base64data==",
1699 "mimeType": "image/png"
1700 }
1701 ]
1702 })
1703 .to_string(),
1704 );
1705
1706 let tool_result = match user_content {
1707 UserContent::ToolResult(tool_result) => tool_result,
1708 other => panic!("expected tool result content, got {other:?}"),
1709 };
1710
1711 assert_eq!(tool_result.id, "tool_call_1");
1712 assert_eq!(tool_result.call_id.as_deref(), Some("call_1"));
1713 assert_eq!(tool_result.content.len(), 2);
1714
1715 let mut items = tool_result.content.iter();
1716 match items.next() {
1717 Some(ToolResultContent::Text(text)) => {
1718 assert!(text.text.contains("Use the image part to answer."));
1719 }
1720 other => panic!("expected structured text payload first, got {other:?}"),
1721 }
1722
1723 match items.next() {
1724 Some(ToolResultContent::Image(image)) => {
1725 assert_eq!(image.media_type, Some(ImageMediaType::PNG));
1726 assert!(matches!(
1727 image.data,
1728 DocumentSourceKind::Base64(ref data) if data == "base64data=="
1729 ));
1730 }
1731 other => panic!("expected image payload second, got {other:?}"),
1732 }
1733 }
1734
1735 fn validate_follow_up_tool_history(request: &CompletionRequest) -> Result<(), String> {
1736 let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
1737 if history.len() != 3 {
1738 return Err(format!(
1739 "follow-up request should contain [original user prompt, assistant tool call, user tool result]: {history:?}"
1740 ));
1741 }
1742
1743 if !matches!(
1744 history.first(),
1745 Some(Message::User { content })
1746 if matches!(
1747 content.first(),
1748 UserContent::Text(text) if text.text == "do tool work"
1749 )
1750 ) {
1751 return Err(format!(
1752 "follow-up request should begin with the original user prompt: {history:?}"
1753 ));
1754 }
1755
1756 if !matches!(
1757 history.get(1),
1758 Some(Message::Assistant { content, .. })
1759 if matches!(
1760 content.first(),
1761 AssistantContent::ToolCall(tool_call)
1762 if tool_call.id == "tool_call_1"
1763 && tool_call.call_id.as_deref() == Some("call_1")
1764 )
1765 ) {
1766 return Err(format!(
1767 "follow-up request is missing the assistant tool call in position 2: {history:?}"
1768 ));
1769 }
1770
1771 if !matches!(
1772 history.get(2),
1773 Some(Message::User { content })
1774 if matches!(
1775 content.first(),
1776 UserContent::ToolResult(tool_result)
1777 if tool_result.id == "tool_call_1"
1778 && tool_result.call_id.as_deref() == Some("call_1")
1779 )
1780 ) {
1781 return Err(format!(
1782 "follow-up request should end with the user tool result: {history:?}"
1783 ));
1784 }
1785
1786 Ok(())
1787 }
1788
1789 fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1790 history.iter().any(|message| {
1791 matches!(
1792 message,
1793 Message::Assistant { content, .. }
1794 if content.iter().any(|item| matches!(
1795 item,
1796 AssistantContent::ToolCall(tool_call)
1797 if tool_call.function.name == tool_name
1798 ))
1799 )
1800 })
1801 }
1802
1803 fn history_contains_text(history: &[Message], expected: &str) -> bool {
1804 history.iter().any(|message| {
1805 matches!(
1806 message,
1807 Message::Assistant { content, .. }
1808 if content.iter().any(|item| matches!(
1809 item,
1810 AssistantContent::Text(text) if text.text == expected
1811 ))
1812 )
1813 })
1814 }
1815
1816 fn assistant_reasoning_precedes_tool_call(
1817 history: &[Message],
1818 expected_reasoning: &str,
1819 tool_name: &str,
1820 ) -> bool {
1821 history.iter().any(|message| {
1822 let Message::Assistant { content, .. } = message else {
1823 return false;
1824 };
1825
1826 let reasoning_index = content.iter().position(|item| {
1827 matches!(
1828 item,
1829 AssistantContent::Reasoning(reasoning)
1830 if reasoning.content.iter().any(|content| matches!(
1831 content,
1832 ReasoningContent::Text { text, .. }
1833 if text == expected_reasoning
1834 ))
1835 )
1836 });
1837 let tool_index = content.iter().position(|item| {
1838 matches!(
1839 item,
1840 AssistantContent::ToolCall(tool_call)
1841 if tool_call.function.name == tool_name
1842 )
1843 });
1844
1845 matches!((reasoning_index, tool_index), (Some(reasoning), Some(tool)) if reasoning < tool)
1846 })
1847 }
1848
1849 fn assistant_reasoning_precedes_text_and_tool_call(
1850 history: &[Message],
1851 expected_reasoning: &str,
1852 expected_text: &str,
1853 tool_name: &str,
1854 ) -> bool {
1855 history.iter().any(|message| {
1856 let Message::Assistant { content, .. } = message else {
1857 return false;
1858 };
1859
1860 let reasoning_index = content.iter().position(|item| {
1861 matches!(
1862 item,
1863 AssistantContent::Reasoning(reasoning)
1864 if reasoning.content.iter().any(|content| matches!(
1865 content,
1866 ReasoningContent::Text { text, .. }
1867 if text == expected_reasoning
1868 ))
1869 )
1870 });
1871 let text_index = content.iter().position(|item| {
1872 matches!(
1873 item,
1874 AssistantContent::Text(text) if text.text == expected_text
1875 )
1876 });
1877 let tool_index = content.iter().position(|item| {
1878 matches!(
1879 item,
1880 AssistantContent::ToolCall(tool_call)
1881 if tool_call.function.name == tool_name
1882 )
1883 });
1884
1885 matches!(
1886 (reasoning_index, text_index, tool_index),
1887 (Some(reasoning), Some(text), Some(tool))
1888 if reasoning < text && text < tool
1889 )
1890 })
1891 }
1892
1893 #[derive(Clone)]
1894 struct PanicOnUnknownToolHook;
1895
1896 impl AgentHook<MockCompletionModel> for PanicOnUnknownToolHook {
1897 async fn on_event(
1898 &self,
1899 _ctx: &HookContext,
1900 event: StepEvent<'_, MockCompletionModel>,
1901 ) -> Flow {
1902 match event {
1903 StepEvent::ToolCallDelta { .. } => {
1904 panic!("unknown tool call delta should fail before delta hooks run")
1905 }
1906 StepEvent::ToolCall { .. } => {
1907 panic!("unknown tool call should fail before tool hooks run")
1908 }
1909 StepEvent::StreamResponseFinish { .. } => {
1910 panic!("unknown tool call should fail before stream finish hooks run")
1911 }
1912 _ => Flow::cont(),
1913 }
1914 }
1915 }
1916
1917 #[derive(Clone)]
1918 struct CountingAddTool {
1919 calls: Arc<AtomicU32>,
1920 }
1921
1922 #[derive(Clone)]
1923 struct CountingSubtractTool {
1924 calls: Arc<AtomicU32>,
1925 }
1926
1927 #[derive(Deserialize)]
1928 struct CountingOperationArgs {
1929 x: i32,
1930 y: i32,
1931 }
1932
1933 fn arithmetic_tool_definition(name: &str, description: &str) -> ToolDefinition {
1934 ToolDefinition {
1935 name: name.to_string(),
1936 description: description.to_string(),
1937 parameters: serde_json::json!({
1938 "type": "object",
1939 "properties": {
1940 "x": {
1941 "type": "number",
1942 "description": "The first operand"
1943 },
1944 "y": {
1945 "type": "number",
1946 "description": "The second operand"
1947 }
1948 },
1949 "required": ["x", "y"],
1950 }),
1951 }
1952 }
1953
1954 impl Tool for CountingAddTool {
1955 const NAME: &'static str = "add";
1956 type Error = MockToolError;
1957 type Args = CountingOperationArgs;
1958 type Output = i32;
1959
1960 fn description(&self) -> String {
1961 "Add x and y together".to_string()
1962 }
1963
1964 fn parameters(&self) -> serde_json::Value {
1965 arithmetic_tool_definition(Self::NAME, "Add x and y together").parameters
1966 }
1967
1968 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1969 self.calls.fetch_add(1, Ordering::SeqCst);
1970 Ok(args.x + args.y)
1971 }
1972 }
1973
1974 impl Tool for CountingSubtractTool {
1975 const NAME: &'static str = "subtract";
1976 type Error = MockToolError;
1977 type Args = CountingOperationArgs;
1978 type Output = i32;
1979
1980 fn description(&self) -> String {
1981 "Subtract y from x".to_string()
1982 }
1983
1984 fn parameters(&self) -> serde_json::Value {
1985 arithmetic_tool_definition(Self::NAME, "Subtract y from x").parameters
1986 }
1987
1988 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
1989 self.calls.fetch_add(1, Ordering::SeqCst);
1990 Ok(args.x - args.y)
1991 }
1992 }
1993
1994 fn streaming_tool_then_text_model() -> MockCompletionModel {
1995 MockCompletionModel::from_stream_turns([
1996 vec![
1997 MockStreamEvent::tool_call(
1998 "tool_call_1",
1999 "add",
2000 serde_json::json!({"x": 1, "y": 2}),
2001 )
2002 .with_call_id("call_1"),
2003 MockStreamEvent::final_response_with_total_tokens(4),
2004 ],
2005 vec![
2006 MockStreamEvent::text("done"),
2007 MockStreamEvent::final_response_with_total_tokens(6),
2008 ],
2009 ])
2010 }
2011
2012 fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
2013 Usage {
2014 input_tokens,
2015 output_tokens,
2016 total_tokens: input_tokens + output_tokens,
2017 cached_input_tokens: 0,
2018 cache_creation_input_tokens: 0,
2019 tool_use_prompt_tokens: 0,
2020 reasoning_tokens: 0,
2021 }
2022 }
2023
2024 #[derive(Clone, Debug, Default)]
2025 struct CapturedSpan {
2026 id: u64,
2027 name: String,
2028 parent_id: Option<u64>,
2029 fields: HashMap<String, u64>,
2030 }
2031
2032 #[derive(Clone, Default)]
2033 struct CapturedSpans(Arc<Mutex<Vec<CapturedSpan>>>);
2034
2035 impl CapturedSpans {
2036 fn clear(&self) {
2037 if let Ok(mut spans) = self.0.lock() {
2038 spans.clear();
2039 }
2040 }
2041
2042 fn insert(&self, id: &Id, name: &str, parent_id: Option<u64>) {
2043 let id = id.into_u64();
2044 if let Ok(mut spans) = self.0.lock() {
2045 spans.push(CapturedSpan {
2046 id,
2047 name: name.to_string(),
2048 parent_id,
2049 fields: HashMap::new(),
2050 });
2051 }
2052 }
2053
2054 fn record(&self, id: &Id, fields: Vec<(String, u64)>) {
2055 if let Ok(mut spans) = self.0.lock()
2056 && let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
2057 {
2058 span.fields.extend(fields);
2059 }
2060 }
2061
2062 fn snapshot(&self) -> Vec<CapturedSpan> {
2063 self.0.lock().map(|spans| spans.clone()).unwrap_or_default()
2064 }
2065 }
2066
2067 struct SpanCaptureLayer {
2068 spans: CapturedSpans,
2069 }
2070
2071 impl<S> Layer<S> for SpanCaptureLayer
2072 where
2073 S: Subscriber,
2074 S: for<'lookup> LookupSpan<'lookup>,
2075 {
2076 fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
2077 let parent_id = attrs
2078 .parent()
2079 .map(Id::into_u64)
2080 .or_else(|| ctx.current_span().id().map(Id::into_u64));
2081 self.spans.insert(id, attrs.metadata().name(), parent_id);
2082 }
2083
2084 fn on_record(&self, span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
2085 let mut fields = Vec::new();
2086 values.record(&mut SpanFieldCaptureVisitor {
2087 fields: &mut fields,
2088 });
2089 self.spans.record(span, fields);
2090 }
2091 }
2092
2093 struct SpanFieldCaptureVisitor<'a> {
2094 fields: &'a mut Vec<(String, u64)>,
2095 }
2096
2097 impl Visit for SpanFieldCaptureVisitor<'_> {
2098 fn record_u64(&mut self, field: &Field, value: u64) {
2099 self.fields.push((field.name().to_string(), value));
2100 }
2101
2102 fn record_str(&mut self, field: &Field, _value: &str) {
2105 self.fields.push((field.name().to_string(), 0));
2106 }
2107
2108 fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
2109 self.fields.push((field.name().to_string(), 0));
2110 }
2111 }
2112
2113 async fn assert_stream_usage_recorded_on_chat_spans(
2114 agent: crate::agent::Agent<MockCompletionModel>,
2115 prompt: &str,
2116 max_turns: usize,
2117 expected_usages: &[Usage],
2118 ) {
2119 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2122 let spans = CapturedSpans::default();
2123 let subscriber = Registry::default().with(SpanCaptureLayer {
2124 spans: spans.clone(),
2125 });
2126 let _default = tracing::subscriber::set_default(subscriber);
2127
2128 let warmup_model = MockCompletionModel::from_stream_turns([[
2139 MockStreamEvent::text("warmup"),
2140 MockStreamEvent::final_response(Usage::default()),
2141 ]]);
2142 let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
2143 let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
2144 while let Some(item) = warmup_stream
2145 .try_next()
2146 .await
2147 .expect("warmup stream should not error")
2148 {
2149 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2150 break;
2151 }
2152 }
2153 tracing::callsite::rebuild_interest_cache();
2154 spans.clear();
2155
2156 let empty_history: &[Message] = &[];
2157 let outer_span = tracing::info_span!("outer", gen_ai.completion = tracing::field::Empty);
2160
2161 async {
2162 let mut stream = agent
2163 .stream_prompt(prompt)
2164 .history(empty_history)
2165 .max_turns(max_turns)
2166 .await;
2167
2168 while let Some(item) = stream.try_next().await.expect("stream should not error") {
2169 if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
2170 break;
2171 }
2172 }
2173 }
2174 .instrument(outer_span)
2175 .await;
2176
2177 let span_snapshot = spans.snapshot();
2178 let outer_span_id = span_snapshot
2179 .iter()
2180 .find(|span| span.name == "outer")
2181 .map(|span| span.id)
2182 .expect("outer span should be captured");
2183 let chat_spans = span_snapshot
2184 .iter()
2185 .filter(|span| span.name == "chat_streaming")
2186 .collect::<Vec<_>>();
2187
2188 assert_eq!(chat_spans.len(), expected_usages.len());
2189 assert!(
2190 span_snapshot.iter().all(|span| span.name != "invoke_agent"),
2191 "outer span path should not create invoke_agent"
2192 );
2193
2194 for (chat_span, expected_usage) in chat_spans.into_iter().zip(expected_usages) {
2195 assert_eq!(chat_span.parent_id, Some(outer_span_id));
2196 assert_eq!(
2197 chat_span.fields.get("gen_ai.usage.input_tokens"),
2198 Some(&expected_usage.input_tokens)
2199 );
2200 assert_eq!(
2201 chat_span.fields.get("gen_ai.usage.output_tokens"),
2202 Some(&expected_usage.output_tokens)
2203 );
2204 assert_eq!(
2205 chat_span.fields.get("gen_ai.usage.cache_read.input_tokens"),
2206 Some(&expected_usage.cached_input_tokens)
2207 );
2208 assert_eq!(
2209 chat_span
2210 .fields
2211 .get("gen_ai.usage.cache_creation.input_tokens"),
2212 Some(&expected_usage.cache_creation_input_tokens)
2213 );
2214 assert_eq!(
2215 chat_span.fields.get("gen_ai.usage.tool_use_prompt_tokens"),
2216 Some(&expected_usage.tool_use_prompt_tokens)
2217 );
2218 assert_eq!(
2219 chat_span.fields.get("gen_ai.usage.reasoning_tokens"),
2220 Some(&expected_usage.reasoning_tokens)
2221 );
2222 }
2223
2224 let outer_span = span_snapshot
2225 .iter()
2226 .find(|span| span.id == outer_span_id)
2227 .expect("outer span should be present");
2228 assert!(
2229 outer_span
2230 .fields
2231 .keys()
2232 .all(|field| !field.starts_with("gen_ai.usage.")),
2233 "usage should not be recorded onto the caller's outer span"
2234 );
2235 assert!(
2236 !outer_span.fields.contains_key("gen_ai.completion"),
2237 "gen_ai.completion should not be recorded onto the caller's outer span \
2238 (parity with the blocking driver)"
2239 );
2240 }
2241
2242 #[test]
2243 fn completion_calls_stream_item_serializes_and_deserializes_expected_shape() {
2244 let item: MultiTurnStreamItem<MockResponse> =
2245 MultiTurnStreamItem::CompletionCall(CompletionCall::new(2, usage(3, 4)));
2246
2247 let value = serde_json::to_value(&item).expect("serialize completion call event");
2248
2249 assert_eq!(
2250 value,
2251 serde_json::json!({
2252 "type": "completionCall",
2253 "call_index": 2,
2254 "usage": {
2255 "input_tokens": 3,
2256 "output_tokens": 4,
2257 "total_tokens": 7,
2258 "cached_input_tokens": 0,
2259 "cache_creation_input_tokens": 0,
2260 "tool_use_prompt_tokens": 0,
2261 "reasoning_tokens": 0,
2262 }
2263 })
2264 );
2265
2266 let item: MultiTurnStreamItem<MockResponse> =
2267 serde_json::from_value(value).expect("deserialize completion call event");
2268 match item {
2269 MultiTurnStreamItem::CompletionCall(call_usage) => {
2270 assert_eq!(call_usage, CompletionCall::new(2, usage(3, 4)));
2271 }
2272 other => panic!("expected completion call event, got {other:?}"),
2273 }
2274
2275 let item: MultiTurnStreamItem<MockResponse> =
2276 MultiTurnStreamItem::CompletionCall(CompletionCall::new(3, Usage::new()));
2277 let value = serde_json::to_value(&item).expect("serialize missing usage event");
2278
2279 assert_eq!(
2282 value,
2283 serde_json::json!({
2284 "type": "completionCall",
2285 "call_index": 3,
2286 "usage": {
2287 "input_tokens": 0,
2288 "output_tokens": 0,
2289 "total_tokens": 0,
2290 "cached_input_tokens": 0,
2291 "cache_creation_input_tokens": 0,
2292 "tool_use_prompt_tokens": 0,
2293 "reasoning_tokens": 0,
2294 }
2295 })
2296 );
2297
2298 let legacy: MultiTurnStreamItem<MockResponse> = serde_json::from_value(serde_json::json!({
2301 "type": "completionCall",
2302 "call_index": 3,
2303 "usage": null
2304 }))
2305 .expect("legacy null-usage event should deserialize");
2306 match legacy {
2307 MultiTurnStreamItem::CompletionCall(call) => {
2308 assert_eq!(call, CompletionCall::new(3, Usage::new()));
2309 }
2310 other => panic!("expected completion call event, got {other:?}"),
2311 }
2312 }
2313
2314 #[test]
2315 fn final_response_serializes_completion_calls_with_missing_usage() {
2316 let item: MultiTurnStreamItem<MockResponse> =
2317 MultiTurnStreamItem::final_response_with_completion_calls(
2318 OneOrMany::one(AssistantContent::text("done")),
2319 usage(3, 4),
2320 vec![
2321 CompletionCall::new(0, Usage::new()),
2322 CompletionCall::new(1, usage(3, 4)),
2323 ],
2324 None,
2325 );
2326
2327 if let MultiTurnStreamItem::FinalResponse(response) = &item {
2328 assert_eq!(response.requests(), 2);
2329 }
2330
2331 let value = serde_json::to_value(&item).expect("serialize final response");
2332
2333 assert_eq!(
2334 value.get("completion_calls"),
2335 Some(&serde_json::json!([
2336 {
2337 "call_index": 0,
2338 "usage": {
2339 "input_tokens": 0,
2340 "output_tokens": 0,
2341 "total_tokens": 0,
2342 "cached_input_tokens": 0,
2343 "cache_creation_input_tokens": 0,
2344 "tool_use_prompt_tokens": 0,
2345 "reasoning_tokens": 0,
2346 }
2347 },
2348 {
2349 "call_index": 1,
2350 "usage": {
2351 "input_tokens": 3,
2352 "output_tokens": 4,
2353 "total_tokens": 7,
2354 "cached_input_tokens": 0,
2355 "cache_creation_input_tokens": 0,
2356 "tool_use_prompt_tokens": 0,
2357 "reasoning_tokens": 0,
2358 }
2359 }
2360 ]))
2361 );
2362 }
2363
2364 fn streaming_text_then_final_model() -> MockCompletionModel {
2365 MockCompletionModel::from_stream_turns([[
2366 MockStreamEvent::text("hello"),
2367 MockStreamEvent::text(" world"),
2368 MockStreamEvent::final_response_with_total_tokens(3),
2369 ]])
2370 }
2371
2372 fn citation_metadata() -> serde_json::Value {
2373 serde_json::json!({
2374 "citations": [{
2375 "type": "web_search_result_location",
2376 "cited_text": "Claude Shannon was born in 1916.",
2377 "url": "https://example.com/shannon",
2378 "title": "Claude Shannon",
2379 "encrypted_index": "encrypted-reference"
2380 }]
2381 })
2382 }
2383
2384 fn streaming_cited_text_then_final_model() -> MockCompletionModel {
2385 MockCompletionModel::from_stream_turns([[
2386 MockStreamEvent::text_start(Some(citation_metadata())),
2387 MockStreamEvent::text("cited "),
2388 MockStreamEvent::text_start(None),
2389 MockStreamEvent::text("answer"),
2390 MockStreamEvent::final_response_with_total_tokens(3),
2391 ]])
2392 }
2393
2394 fn streaming_cited_text_then_tool_model() -> MockCompletionModel {
2395 MockCompletionModel::from_stream_turns([
2396 vec![
2397 MockStreamEvent::text_start(Some(citation_metadata())),
2398 MockStreamEvent::text("I need a tool. "),
2399 MockStreamEvent::tool_call(
2400 "tool_call_1",
2401 "add",
2402 serde_json::json!({"x": 1, "y": 2}),
2403 )
2404 .with_call_id("call_1"),
2405 MockStreamEvent::final_response_with_total_tokens(4),
2406 ],
2407 vec![
2408 MockStreamEvent::text("done"),
2409 MockStreamEvent::final_response_with_total_tokens(6),
2410 ],
2411 ])
2412 }
2413
2414 fn streaming_final_only_model() -> MockCompletionModel {
2415 MockCompletionModel::from_stream_turns([[
2416 MockStreamEvent::final_response_with_total_tokens(1),
2417 ]])
2418 }
2419
2420 #[derive(Clone)]
2421 struct TerminateOnStreamFinish;
2422
2423 impl AgentHook<MockCompletionModel> for TerminateOnStreamFinish {
2424 async fn on_event(
2425 &self,
2426 _ctx: &HookContext,
2427 event: StepEvent<'_, MockCompletionModel>,
2428 ) -> Flow {
2429 match event {
2430 StepEvent::StreamResponseFinish { .. } => {
2431 Flow::terminate("stop after completion call")
2432 }
2433 _ => Flow::cont(),
2434 }
2435 }
2436 }
2437
2438 type RecordedToolCallDelta = (String, String, Option<String>, String);
2439
2440 #[derive(Clone)]
2441 struct RepairDefaultApiHook;
2442
2443 impl AgentHook<MockCompletionModel> for RepairDefaultApiHook {
2444 async fn on_event(
2445 &self,
2446 _ctx: &HookContext,
2447 event: StepEvent<'_, MockCompletionModel>,
2448 ) -> Flow {
2449 match event {
2450 StepEvent::InvalidToolCall(context) => {
2451 assert_eq!(context.tool_name, "default_api");
2452 Flow::repair("add")
2453 }
2454 _ => Flow::cont(),
2455 }
2456 }
2457 }
2458
2459 #[derive(Clone)]
2460 struct RetryDefaultApiHook;
2461
2462 impl AgentHook<MockCompletionModel> for RetryDefaultApiHook {
2463 async fn on_event(
2464 &self,
2465 _ctx: &HookContext,
2466 event: StepEvent<'_, MockCompletionModel>,
2467 ) -> Flow {
2468 match event {
2469 StepEvent::InvalidToolCall(context) => {
2470 assert_eq!(context.tool_name, "default_api");
2471 if let Some(args) = context.args.as_deref() {
2472 assert!(!args.is_empty());
2473 }
2474 Flow::retry("Use the add tool instead")
2475 }
2476 _ => Flow::cont(),
2477 }
2478 }
2479 }
2480
2481 #[derive(Clone)]
2482 struct SkipDefaultApiHook;
2483
2484 impl AgentHook<MockCompletionModel> for SkipDefaultApiHook {
2485 async fn on_event(
2486 &self,
2487 _ctx: &HookContext,
2488 event: StepEvent<'_, MockCompletionModel>,
2489 ) -> Flow {
2490 match event {
2491 StepEvent::InvalidToolCall(context) => {
2492 assert_eq!(context.tool_name, "default_api");
2493 Flow::skip("default_api was skipped")
2494 }
2495 _ => Flow::cont(),
2496 }
2497 }
2498 }
2499
2500 #[derive(Clone, Default)]
2501 struct RecordingInvalidToolCallHook {
2502 contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
2503 }
2504
2505 impl RecordingInvalidToolCallHook {
2506 fn observed(&self) -> Vec<InvalidToolCallContext> {
2507 self.contexts
2508 .lock()
2509 .expect("invalid tool context records mutex was poisoned")
2510 .clone()
2511 }
2512 }
2513
2514 impl AgentHook<MockCompletionModel> for RecordingInvalidToolCallHook {
2515 async fn on_event(
2516 &self,
2517 _ctx: &HookContext,
2518 event: StepEvent<'_, MockCompletionModel>,
2519 ) -> Flow {
2520 match event {
2521 StepEvent::InvalidToolCall(context) => {
2522 self.contexts
2523 .lock()
2524 .expect("invalid tool context records mutex was poisoned")
2525 .push(context.clone());
2526 Flow::fail()
2527 }
2528 _ => Flow::cont(),
2529 }
2530 }
2531 }
2532
2533 #[derive(Clone, Default)]
2534 struct RecordingToolCallDeltaHook {
2535 deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
2536 }
2537
2538 impl RecordingToolCallDeltaHook {
2539 fn observed(&self) -> Vec<RecordedToolCallDelta> {
2540 self.deltas
2541 .lock()
2542 .expect("tool call delta hook records mutex was poisoned")
2543 .clone()
2544 }
2545 }
2546
2547 impl AgentHook<MockCompletionModel> for RecordingToolCallDeltaHook {
2548 async fn on_event(
2549 &self,
2550 _ctx: &HookContext,
2551 event: StepEvent<'_, MockCompletionModel>,
2552 ) -> Flow {
2553 match event {
2554 StepEvent::ToolCallDelta {
2555 tool_call_id,
2556 internal_call_id,
2557 tool_name,
2558 delta,
2559 } => {
2560 let record = (
2561 tool_call_id.to_string(),
2562 internal_call_id.to_string(),
2563 tool_name.map(str::to_string),
2564 delta.to_string(),
2565 );
2566 self.deltas
2567 .lock()
2568 .expect("tool call delta hook records mutex was poisoned")
2569 .push(record);
2570 Flow::cont()
2571 }
2572 _ => Flow::cont(),
2573 }
2574 }
2575 }
2576
2577 #[derive(Clone, Default)]
2578 struct RecordingTextDeltaHook {
2579 deltas: Arc<Mutex<Vec<(String, String)>>>,
2580 }
2581
2582 impl RecordingTextDeltaHook {
2583 fn observed(&self) -> Vec<(String, String)> {
2584 self.deltas
2585 .lock()
2586 .expect("text delta hook records mutex was poisoned")
2587 .clone()
2588 }
2589 }
2590
2591 impl AgentHook<MockCompletionModel> for RecordingTextDeltaHook {
2592 async fn on_event(
2593 &self,
2594 _ctx: &HookContext,
2595 event: StepEvent<'_, MockCompletionModel>,
2596 ) -> Flow {
2597 match event {
2598 StepEvent::TextDelta { delta, aggregated } => {
2599 let record = (delta.to_string(), aggregated.to_string());
2600 self.deltas
2601 .lock()
2602 .expect("text delta hook records mutex was poisoned")
2603 .push(record);
2604 Flow::cont()
2605 }
2606 _ => Flow::cont(),
2607 }
2608 }
2609 }
2610
2611 #[derive(Clone)]
2612 struct RecordingTextAndSkipInvalidToolHook {
2613 text: RecordingTextDeltaHook,
2614 }
2615
2616 impl AgentHook<MockCompletionModel> for RecordingTextAndSkipInvalidToolHook {
2617 async fn on_event(
2618 &self,
2619 _ctx: &HookContext,
2620 event: StepEvent<'_, MockCompletionModel>,
2621 ) -> Flow {
2622 match event {
2623 event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
2624 event @ StepEvent::InvalidToolCall(_) => {
2625 SkipDefaultApiHook.on_event(_ctx, event).await
2626 }
2627 _ => Flow::cont(),
2628 }
2629 }
2630 }
2631
2632 #[derive(Clone)]
2633 struct RecordingTextAndRetryInvalidToolHook {
2634 text: RecordingTextDeltaHook,
2635 }
2636
2637 impl AgentHook<MockCompletionModel> for RecordingTextAndRetryInvalidToolHook {
2638 async fn on_event(
2639 &self,
2640 _ctx: &HookContext,
2641 event: StepEvent<'_, MockCompletionModel>,
2642 ) -> Flow {
2643 match event {
2644 event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
2645 event @ StepEvent::InvalidToolCall(_) => {
2646 RetryDefaultApiHook.on_event(_ctx, event).await
2647 }
2648 _ => Flow::cont(),
2649 }
2650 }
2651 }
2652
2653 #[derive(Clone)]
2654 struct RecordingDeltaAndRetryInvalidToolHook {
2655 delta: RecordingToolCallDeltaHook,
2656 }
2657
2658 impl AgentHook<MockCompletionModel> for RecordingDeltaAndRetryInvalidToolHook {
2659 async fn on_event(
2660 &self,
2661 _ctx: &HookContext,
2662 event: StepEvent<'_, MockCompletionModel>,
2663 ) -> Flow {
2664 match event {
2665 event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
2666 event @ StepEvent::InvalidToolCall(_) => {
2667 RetryDefaultApiHook.on_event(_ctx, event).await
2668 }
2669 _ => Flow::cont(),
2670 }
2671 }
2672 }
2673
2674 #[derive(Clone)]
2675 struct RecordingDeltaAndSkipInvalidToolHook {
2676 delta: RecordingToolCallDeltaHook,
2677 }
2678
2679 impl AgentHook<MockCompletionModel> for RecordingDeltaAndSkipInvalidToolHook {
2680 async fn on_event(
2681 &self,
2682 _ctx: &HookContext,
2683 event: StepEvent<'_, MockCompletionModel>,
2684 ) -> Flow {
2685 match event {
2686 event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
2687 event @ StepEvent::InvalidToolCall(_) => {
2688 SkipDefaultApiHook.on_event(_ctx, event).await
2689 }
2690 _ => Flow::cont(),
2691 }
2692 }
2693 }
2694
2695 #[derive(Clone, Default)]
2696 struct TerminatingToolCallDeltaHook {
2697 deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
2698 }
2699
2700 impl TerminatingToolCallDeltaHook {
2701 fn observed(&self) -> Vec<RecordedToolCallDelta> {
2702 self.deltas
2703 .lock()
2704 .expect("tool call delta hook records mutex was poisoned")
2705 .clone()
2706 }
2707 }
2708
2709 impl AgentHook<MockCompletionModel> for TerminatingToolCallDeltaHook {
2710 async fn on_event(
2711 &self,
2712 _ctx: &HookContext,
2713 event: StepEvent<'_, MockCompletionModel>,
2714 ) -> Flow {
2715 match event {
2716 StepEvent::ToolCallDelta {
2717 tool_call_id,
2718 internal_call_id,
2719 tool_name,
2720 delta,
2721 } => {
2722 let record = (
2723 tool_call_id.to_string(),
2724 internal_call_id.to_string(),
2725 tool_name.map(str::to_string),
2726 delta.to_string(),
2727 );
2728 self.deltas
2729 .lock()
2730 .expect("tool call delta hook records mutex was poisoned")
2731 .push(record);
2732 Flow::terminate("stop on tool call delta")
2733 }
2734 _ => Flow::cont(),
2735 }
2736 }
2737 }
2738
2739 fn text_metadata(content: &OneOrMany<AssistantContent>) -> Option<&serde_json::Value> {
2740 content.iter().find_map(|item| match item {
2741 AssistantContent::Text(text) => text.additional_params.as_ref(),
2742 _ => None,
2743 })
2744 }
2745
2746 #[tokio::test]
2747 async fn stream_prompt_continues_after_tool_call_turn() {
2748 let model = streaming_tool_then_text_model();
2749 let recorded = model.clone();
2750 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2751 let empty_history: &[Message] = &[];
2752
2753 let mut stream = agent
2754 .stream_prompt("do tool work")
2755 .history(empty_history)
2756 .max_turns(3)
2757 .await;
2758 let mut saw_tool_call = false;
2759 let mut saw_tool_result = false;
2760 let mut saw_final_response = false;
2761 let mut final_text = String::new();
2762 let mut final_response_text = None;
2763 let mut final_history = None;
2764
2765 while let Some(item) = stream.next().await {
2766 match item {
2767 Ok(MultiTurnStreamItem::StreamAssistantItem(
2768 StreamedAssistantContent::ToolCall { .. },
2769 )) => {
2770 saw_tool_call = true;
2771 }
2772 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2773 ..
2774 })) => {
2775 saw_tool_result = true;
2776 }
2777 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
2778 text,
2779 ))) => {
2780 final_text.push_str(&text.text);
2781 }
2782 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
2783 saw_final_response = true;
2784 final_response_text = Some(res.output().to_owned());
2785 final_history = res.messages().map(|history| history.to_vec());
2786 break;
2787 }
2788 Ok(_) => {}
2789 Err(err) => panic!("unexpected streaming error: {err:?}"),
2790 }
2791 }
2792
2793 assert!(saw_tool_call);
2794 assert!(saw_tool_result);
2795 assert!(saw_final_response);
2796 assert_eq!(final_text, "done");
2797 assert_eq!(final_response_text.as_deref(), Some("done"));
2798 let history = final_history.expect("expected final response history");
2799 assert!(history.iter().any(|message| matches!(
2800 message,
2801 Message::Assistant { content, .. }
2802 if content.iter().any(|item| matches!(
2803 item,
2804 AssistantContent::Text(text) if text.text == "done"
2805 ))
2806 )));
2807 let requests = recorded.requests();
2808 assert_eq!(requests.len(), 2);
2809 assert!(validate_follow_up_tool_history(&requests[1]).is_ok());
2810 }
2811
2812 #[tokio::test]
2818 async fn streaming_prompt_request_tool_concurrency_runs_tools_concurrently() {
2819 let barrier = Arc::new(tokio::sync::Barrier::new(2));
2820 let model = MockCompletionModel::from_stream_turns([
2821 vec![
2822 MockStreamEvent::tool_call("b1", "barrier_tool", serde_json::json!({})),
2823 MockStreamEvent::tool_call("b2", "barrier_tool", serde_json::json!({})),
2824 MockStreamEvent::final_response_with_total_tokens(0),
2825 ],
2826 vec![
2827 MockStreamEvent::text("done"),
2828 MockStreamEvent::final_response_with_total_tokens(0),
2829 ],
2830 ]);
2831 let agent = AgentBuilder::new(model)
2832 .tool(MockBarrierTool::new(barrier))
2833 .build();
2834
2835 let drive = async {
2836 let mut stream = agent
2837 .stream_prompt("hit the barrier twice")
2838 .max_turns(3)
2839 .tool_concurrency(2)
2840 .await;
2841 while let Some(item) = stream.next().await {
2842 item.unwrap_or_else(|err| panic!("unexpected streaming error: {err:?}"));
2843 }
2844 };
2845
2846 tokio::time::timeout(Duration::from_secs(5), drive)
2847 .await
2848 .expect("streamed tools must run concurrently, not deadlock at the barrier");
2849 }
2850
2851 #[tokio::test]
2854 async fn tool_extensions_reach_tool_through_streaming_loop() {
2855 let model = MockCompletionModel::from_stream_turns([
2856 vec![
2857 MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
2858 .with_call_id("call_1"),
2859 MockStreamEvent::final_response_with_total_tokens(4),
2860 ],
2861 vec![
2862 MockStreamEvent::text("done"),
2863 MockStreamEvent::final_response_with_total_tokens(6),
2864 ],
2865 ]);
2866 let probe = MockExtensionsProbeTool::default();
2867 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
2868 let empty_history: &[Message] = &[];
2869
2870 let mut extensions = ToolCallExtensions::new();
2871 extensions.insert(SessionId("xyz-789".to_string()));
2872
2873 let mut stream = agent
2874 .stream_prompt("do tool work")
2875 .tool_extensions(extensions)
2876 .history(empty_history)
2877 .max_turns(3)
2878 .await;
2879
2880 while let Some(item) = stream.next().await {
2881 match item {
2882 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
2883 Err(err) => panic!("unexpected streaming error: {err:?}"),
2884 Ok(_) => {}
2885 }
2886 }
2887
2888 assert_eq!(probe.observed().as_deref(), Some("session:xyz-789"));
2889 }
2890
2891 #[tokio::test]
2895 async fn streaming_tool_runs_with_empty_context_when_none_supplied() {
2896 let model = MockCompletionModel::from_stream_turns([
2897 vec![
2898 MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
2899 .with_call_id("call_1"),
2900 MockStreamEvent::final_response_with_total_tokens(4),
2901 ],
2902 vec![
2903 MockStreamEvent::text("done"),
2904 MockStreamEvent::final_response_with_total_tokens(6),
2905 ],
2906 ]);
2907 let probe = MockExtensionsProbeTool::default();
2908 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
2909 let empty_history: &[Message] = &[];
2910
2911 let mut stream = agent
2912 .stream_prompt("do tool work")
2913 .history(empty_history)
2914 .max_turns(3)
2915 .await;
2916
2917 while let Some(item) = stream.next().await {
2918 match item {
2919 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
2920 Err(err) => panic!("unexpected streaming error: {err:?}"),
2921 Ok(_) => {}
2922 }
2923 }
2924
2925 assert_eq!(probe.observed().as_deref(), Some("no-session"));
2926 }
2927
2928 #[tokio::test]
2929 async fn unknown_tool_call_fails_before_streaming_second_request() {
2930 let model = MockCompletionModel::from_stream_turns([
2931 vec![
2932 MockStreamEvent::tool_call(
2933 "tool_call_1",
2934 "default_api",
2935 serde_json::json!({"x": 1, "y": 2}),
2936 ),
2937 MockStreamEvent::final_response_with_total_tokens(4),
2938 ],
2939 vec![
2940 MockStreamEvent::text("should not be requested"),
2941 MockStreamEvent::final_response_with_total_tokens(6),
2942 ],
2943 ]);
2944 let recorded = model.clone();
2945 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2946
2947 let mut stream = agent
2948 .stream_prompt("use the tool")
2949 .add_hook(PanicOnUnknownToolHook)
2950 .max_turns(3)
2951 .await;
2952 let mut saw_tool_call = false;
2953 let mut error = None;
2954
2955 while let Some(item) = stream.next().await {
2956 match item {
2957 Ok(MultiTurnStreamItem::StreamAssistantItem(
2958 StreamedAssistantContent::ToolCall { .. },
2959 )) => {
2960 saw_tool_call = true;
2961 }
2962 Ok(_) => {}
2963 Err(err) => {
2964 error = Some(err);
2965 break;
2966 }
2967 }
2968 }
2969
2970 assert!(!saw_tool_call);
2971 let error = error.expect("unknown model-emitted tool should fail");
2972 match error {
2973 StreamingError::Prompt(err) => match *err {
2974 PromptError::UnknownToolCall {
2975 tool_name,
2976 available_tools,
2977 allowed_tools,
2978 chat_history,
2979 } => {
2980 assert_eq!(tool_name, "default_api");
2981 assert_eq!(available_tools, vec!["add".to_string()]);
2982 assert_eq!(allowed_tools, vec!["add".to_string()]);
2983 assert!(history_contains_tool_call(&chat_history, "default_api"));
2984 }
2985 other => panic!("expected UnknownToolCall, got {other:?}"),
2986 },
2987 other => panic!("expected prompt streaming error, got {other:?}"),
2988 }
2989 assert_eq!(recorded.request_count(), 1);
2990 }
2991
2992 #[tokio::test]
2993 async fn invalid_tool_call_hook_can_repair_streaming_tool_name() {
2994 let model = MockCompletionModel::from_stream_turns([
2995 vec![
2996 MockStreamEvent::tool_call(
2997 "tool_call_1",
2998 "default_api",
2999 serde_json::json!({"x": 2, "y": 3}),
3000 ),
3001 MockStreamEvent::final_response_with_total_tokens(4),
3002 ],
3003 vec![
3004 MockStreamEvent::text("done"),
3005 MockStreamEvent::final_response_with_total_tokens(6),
3006 ],
3007 ]);
3008 let recorded = model.clone();
3009 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3010
3011 let mut stream = agent
3012 .stream_prompt("use the tool")
3013 .add_hook(RepairDefaultApiHook)
3014 .max_turns(3)
3015 .history(Vec::<Message>::new())
3016 .await;
3017 let mut saw_repaired_tool_call = false;
3018 let mut saw_tool_result = false;
3019 let mut final_response_text = None;
3020
3021 while let Some(item) = stream.next().await {
3022 match item {
3023 Ok(MultiTurnStreamItem::StreamAssistantItem(
3024 StreamedAssistantContent::ToolCall { tool_call, .. },
3025 )) => {
3026 assert_eq!(tool_call.function.name, "add");
3027 saw_repaired_tool_call = true;
3028 }
3029 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3030 tool_result,
3031 ..
3032 })) => {
3033 assert!(tool_result.content.iter().any(|content| {
3034 matches!(
3035 content,
3036 ToolResultContent::Text(text) if text.text == "5"
3037 )
3038 }));
3039 saw_tool_result = true;
3040 }
3041 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3042 final_response_text = Some(response.output().to_string());
3043 break;
3044 }
3045 Ok(_) => {}
3046 Err(err) => panic!("unexpected streaming error: {err:?}"),
3047 }
3048 }
3049
3050 assert!(saw_repaired_tool_call);
3051 assert!(saw_tool_result);
3052 assert_eq!(final_response_text.as_deref(), Some("done"));
3053 assert_eq!(recorded.request_count(), 2);
3054 }
3055
3056 #[tokio::test]
3057 async fn invalid_tool_call_context_uses_completed_streaming_tool_call_provider_id() {
3058 let invalid_hook = RecordingInvalidToolCallHook::default();
3059 let model = MockCompletionModel::from_stream_turns([
3060 vec![
3061 MockStreamEvent::tool_call(
3062 "tool_call_1",
3063 "default_api",
3064 serde_json::json!({"x": 2, "y": 3}),
3065 )
3066 .with_call_id("provider_call_1"),
3067 MockStreamEvent::final_response_with_total_tokens(4),
3068 ],
3069 vec![
3070 MockStreamEvent::text("should not be requested"),
3071 MockStreamEvent::final_response_with_total_tokens(6),
3072 ],
3073 ]);
3074 let recorded = model.clone();
3075 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3076
3077 let mut stream = agent
3078 .stream_prompt("use the tool")
3079 .add_hook(invalid_hook.clone())
3080 .max_turns(3)
3081 .await;
3082 let mut error = None;
3083
3084 while let Some(item) = stream.next().await {
3085 if let Err(err) = item {
3086 error = Some(err);
3087 break;
3088 }
3089 }
3090
3091 assert!(error.is_some(), "invalid tool should fail");
3092 assert_eq!(recorded.request_count(), 1);
3093 let contexts = invalid_hook.observed();
3094 assert_eq!(contexts.len(), 1);
3095 let context = &contexts[0];
3096 assert_eq!(context.tool_name, "default_api");
3097 assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
3098 assert!(context.internal_call_id.is_some());
3099 assert!(context.is_streaming);
3100 }
3101
3102 #[tokio::test]
3103 async fn invalid_tool_call_hook_skip_emits_streaming_tool_result() {
3104 let add_calls = Arc::new(AtomicU32::new(0));
3105 let model = MockCompletionModel::from_stream_turns([
3106 vec![
3107 MockStreamEvent::tool_call(
3108 "tool_call_1",
3109 "default_api",
3110 serde_json::json!({"x": 2, "y": 3}),
3111 )
3112 .with_call_id("call_1"),
3113 MockStreamEvent::final_response_with_total_tokens(4),
3114 ],
3115 vec![
3116 MockStreamEvent::text("continued"),
3117 MockStreamEvent::final_response_with_total_tokens(6),
3118 ],
3119 ]);
3120 let recorded = model.clone();
3121 let agent = AgentBuilder::new(model)
3122 .tool(CountingAddTool {
3123 calls: add_calls.clone(),
3124 })
3125 .build();
3126
3127 let mut stream = agent
3128 .stream_prompt("use the tool")
3129 .add_hook(SkipDefaultApiHook)
3130 .max_turns(3)
3131 .history(Vec::<Message>::new())
3132 .await;
3133 let mut skipped_tool_result = None;
3134 let mut final_response_text = None;
3135
3136 while let Some(item) = stream.next().await {
3137 match item {
3138 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3139 tool_result,
3140 internal_call_id,
3141 })) => {
3142 assert!(!internal_call_id.is_empty());
3143 skipped_tool_result = Some(tool_result);
3144 }
3145 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3146 final_response_text = Some(response.output().to_string());
3147 break;
3148 }
3149 Ok(_) => {}
3150 Err(err) => panic!("unexpected streaming error: {err:?}"),
3151 }
3152 }
3153
3154 let skipped_tool_result =
3155 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3156 assert_eq!(skipped_tool_result.id, "tool_call_1");
3157 assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_1"));
3158 assert!(skipped_tool_result.content.iter().any(|content| matches!(
3159 content,
3160 ToolResultContent::Text(text) if text.text == "default_api was skipped"
3161 )));
3162 assert_eq!(final_response_text.as_deref(), Some("continued"));
3163 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3164
3165 let requests = recorded.requests();
3166 assert_eq!(requests.len(), 2);
3167 let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3168 assert!(matches!(
3169 follow_up_history.get(2),
3170 Some(Message::User { content })
3171 if content.iter().any(|item| matches!(
3172 item,
3173 UserContent::ToolResult(result)
3174 if result.id == "tool_call_1"
3175 && result.content.iter().any(|content| matches!(
3176 content,
3177 ToolResultContent::Text(text)
3178 if text.text == "default_api was skipped"
3179 ))
3180 ))
3181 ));
3182 }
3183
3184 #[tokio::test]
3185 async fn invalid_tool_call_hook_retries_mixed_streaming_turn_without_executing_valid_call() {
3186 let add_calls = Arc::new(AtomicU32::new(0));
3187 let model = MockCompletionModel::from_stream_turns([
3188 vec![
3189 MockStreamEvent::text("checking "),
3190 MockStreamEvent::tool_call(
3191 "tool_call_1",
3192 "add",
3193 serde_json::json!({"x": 2, "y": 3}),
3194 )
3195 .with_call_id("call_1"),
3196 MockStreamEvent::tool_call(
3197 "tool_call_2",
3198 "default_api",
3199 serde_json::json!({"x": 4, "y": 5}),
3200 )
3201 .with_call_id("call_2"),
3202 MockStreamEvent::final_response_with_total_tokens(4),
3203 ],
3204 vec![
3205 MockStreamEvent::text("retried"),
3206 MockStreamEvent::final_response_with_total_tokens(6),
3207 ],
3208 ]);
3209 let recorded = model.clone();
3210 let agent = AgentBuilder::new(model)
3211 .tool(CountingAddTool {
3212 calls: add_calls.clone(),
3213 })
3214 .build();
3215
3216 let mut stream = agent
3217 .stream_prompt("use the tool")
3218 .add_hook(RetryDefaultApiHook)
3219 .max_turns(3)
3220 .history(Vec::<Message>::new())
3221 .max_invalid_tool_call_retries(1)
3222 .await;
3223 let mut completion_call_events = Vec::new();
3224 let mut final_response_text = None;
3225 let mut final_response_usage = Usage::new();
3226 let mut final_completion_calls = Vec::new();
3227
3228 while let Some(item) = stream.next().await {
3229 match item {
3230 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
3231 completion_call_events.push(completion_call);
3232 }
3233 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3234 final_response_text = Some(response.output().to_string());
3235 final_response_usage = response.usage();
3236 final_completion_calls = response.completion_calls().to_vec();
3237 break;
3238 }
3239 Ok(_) => {}
3240 Err(err) => panic!("unexpected streaming error: {err:?}"),
3241 }
3242 }
3243
3244 assert_eq!(final_response_text.as_deref(), Some("retried"));
3245 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3246 let mut first_usage = Usage::new();
3247 first_usage.total_tokens = 4;
3248 let mut second_usage = Usage::new();
3249 second_usage.total_tokens = 6;
3250 let expected_completion_calls = vec![
3251 CompletionCall::new(0, first_usage),
3252 CompletionCall::new(1, second_usage),
3253 ];
3254 assert_eq!(completion_call_events, expected_completion_calls);
3255 assert_eq!(final_completion_calls, expected_completion_calls);
3256 assert_eq!(final_response_usage.total_tokens, 10);
3257
3258 let requests = recorded.requests();
3259 assert_eq!(requests.len(), 2);
3260 let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3261 assert_eq!(retry_history.len(), 3);
3262 assert!(matches!(
3263 retry_history.get(1),
3264 Some(Message::Assistant { content, .. })
3265 if content.iter().any(|item| matches!(
3266 item,
3267 AssistantContent::Text(text) if text.text == "checking "
3268 ))
3269 && content.iter().any(|item| matches!(
3270 item,
3271 AssistantContent::ToolCall(tool_call)
3272 if tool_call.id == "tool_call_1"
3273 && tool_call.function.name == "add"
3274 ))
3275 && content.iter().any(|item| matches!(
3276 item,
3277 AssistantContent::ToolCall(tool_call)
3278 if tool_call.id == "tool_call_2"
3279 && tool_call.function.name == "default_api"
3280 ))
3281 ));
3282 assert!(matches!(
3283 retry_history.get(2),
3284 Some(Message::User { content })
3285 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3286 && content.iter().any(|item| matches!(
3287 item,
3288 UserContent::ToolResult(result)
3289 if result.id == "tool_call_1"
3290 && result.content.iter().any(|content| matches!(
3291 content,
3292 ToolResultContent::Text(text)
3293 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3294 ))
3295 ))
3296 && content.iter().any(|item| matches!(
3297 item,
3298 UserContent::ToolResult(result)
3299 if result.id == "tool_call_2"
3300 && result.content.iter().any(|content| matches!(
3301 content,
3302 ToolResultContent::Text(text)
3303 if text.text == "Use the add tool instead"
3304 ))
3305 ))
3306 ));
3307 }
3308
3309 #[tokio::test]
3310 async fn invalid_tool_call_hook_skips_mixed_streaming_turn_without_executing_valid_call() {
3311 let add_calls = Arc::new(AtomicU32::new(0));
3312 let model = MockCompletionModel::from_stream_turns([
3313 vec![
3314 MockStreamEvent::text("checking "),
3315 MockStreamEvent::tool_call(
3316 "tool_call_1",
3317 "add",
3318 serde_json::json!({"x": 2, "y": 3}),
3319 )
3320 .with_call_id("call_1"),
3321 MockStreamEvent::tool_call(
3322 "tool_call_2",
3323 "default_api",
3324 serde_json::json!({"x": 4, "y": 5}),
3325 )
3326 .with_call_id("call_2"),
3327 MockStreamEvent::final_response_with_total_tokens(4),
3328 ],
3329 vec![
3330 MockStreamEvent::text("continued"),
3331 MockStreamEvent::final_response_with_total_tokens(6),
3332 ],
3333 ]);
3334 let recorded = model.clone();
3335 let agent = AgentBuilder::new(model)
3336 .tool(CountingAddTool {
3337 calls: add_calls.clone(),
3338 })
3339 .build();
3340
3341 let mut stream = agent
3342 .stream_prompt("use the tool")
3343 .add_hook(SkipDefaultApiHook)
3344 .max_turns(3)
3345 .history(Vec::<Message>::new())
3346 .await;
3347 let mut skipped_tool_result = None;
3348 let mut final_response_text = None;
3349
3350 while let Some(item) = stream.next().await {
3351 match item {
3352 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3353 tool_result,
3354 ..
3355 })) => {
3356 skipped_tool_result = Some(tool_result);
3357 }
3358 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3359 final_response_text = Some(response.output().to_string());
3360 break;
3361 }
3362 Ok(_) => {}
3363 Err(err) => panic!("unexpected streaming error: {err:?}"),
3364 }
3365 }
3366
3367 let skipped_tool_result =
3368 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3369 assert_eq!(skipped_tool_result.id, "tool_call_2");
3370 assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_2"));
3371 assert_eq!(final_response_text.as_deref(), Some("continued"));
3372 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3373
3374 let requests = recorded.requests();
3375 assert_eq!(requests.len(), 2);
3376 let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3377 assert_eq!(follow_up_history.len(), 3);
3378 assert!(matches!(
3379 follow_up_history.get(1),
3380 Some(Message::Assistant { content, .. })
3381 if content.iter().any(|item| matches!(
3382 item,
3383 AssistantContent::Text(text) if text.text == "checking "
3384 ))
3385 && content.iter().any(|item| matches!(
3386 item,
3387 AssistantContent::ToolCall(tool_call)
3388 if tool_call.id == "tool_call_1"
3389 && tool_call.function.name == "add"
3390 ))
3391 && content.iter().any(|item| matches!(
3392 item,
3393 AssistantContent::ToolCall(tool_call)
3394 if tool_call.id == "tool_call_2"
3395 && tool_call.function.name == "default_api"
3396 ))
3397 ));
3398 assert!(matches!(
3399 follow_up_history.get(2),
3400 Some(Message::User { content })
3401 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3402 && content.iter().any(|item| matches!(
3403 item,
3404 UserContent::ToolResult(result)
3405 if result.id == "tool_call_1"
3406 && result.call_id.as_deref() == Some("call_1")
3407 && result.content.iter().any(|content| matches!(
3408 content,
3409 ToolResultContent::Text(text)
3410 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3411 ))
3412 ))
3413 && content.iter().any(|item| matches!(
3414 item,
3415 UserContent::ToolResult(result)
3416 if result.id == "tool_call_2"
3417 && result.call_id.as_deref() == Some("call_2")
3418 && result.content.iter().any(|content| matches!(
3419 content,
3420 ToolResultContent::Text(text)
3421 if text.text == "default_api was skipped"
3422 ))
3423 ))
3424 ));
3425 }
3426
3427 #[tokio::test]
3428 async fn invalid_completed_tool_call_skip_preserves_streaming_reasoning_history() {
3429 let model = MockCompletionModel::from_stream_turns([
3430 vec![
3431 MockStreamEvent::text("checking "),
3432 MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
3433 MockStreamEvent::tool_call(
3434 "tool_call_1",
3435 "default_api",
3436 serde_json::json!({"x": 2, "y": 3}),
3437 ),
3438 MockStreamEvent::final_response_with_total_tokens(4),
3439 ],
3440 vec![
3441 MockStreamEvent::text("continued"),
3442 MockStreamEvent::final_response_with_total_tokens(6),
3443 ],
3444 ]);
3445 let recorded = model.clone();
3446 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3447
3448 let mut stream = agent
3449 .stream_prompt("use the tool")
3450 .add_hook(SkipDefaultApiHook)
3451 .max_turns(3)
3452 .history(Vec::<Message>::new())
3453 .await;
3454
3455 while let Some(item) = stream.next().await {
3456 match item {
3457 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3458 Ok(_) => {}
3459 Err(err) => panic!("unexpected streaming error: {err:?}"),
3460 }
3461 }
3462
3463 let requests = recorded.requests();
3464 assert_eq!(requests.len(), 2);
3465 let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3466 assert!(history_contains_text(&follow_up_history, "checking "));
3467 assert!(assistant_reasoning_precedes_tool_call(
3468 &follow_up_history,
3469 "reasoned step",
3470 "default_api"
3471 ));
3472 assert!(
3473 assistant_reasoning_precedes_text_and_tool_call(
3474 &follow_up_history,
3475 "reasoned step",
3476 "checking ",
3477 "default_api"
3478 ),
3479 "{follow_up_history:?}"
3480 );
3481 }
3482
3483 #[tokio::test]
3484 async fn invalid_name_delta_retry_preserves_streaming_reasoning_history() {
3485 let model = MockCompletionModel::from_stream_turns([
3486 vec![
3487 MockStreamEvent::reasoning_delta(Some("rs_1"), "delta reason"),
3488 MockStreamEvent::tool_call_arguments_delta(
3489 "tool_call_1",
3490 "internal_1",
3491 r#"{"x":2,"y":3}"#,
3492 ),
3493 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3494 MockStreamEvent::final_response_with_total_tokens(4),
3495 ],
3496 vec![
3497 MockStreamEvent::text("retried"),
3498 MockStreamEvent::final_response_with_total_tokens(6),
3499 ],
3500 ]);
3501 let recorded = model.clone();
3502 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3503
3504 let mut stream = agent
3505 .stream_prompt("use the tool")
3506 .add_hook(RetryDefaultApiHook)
3507 .max_turns(3)
3508 .history(Vec::<Message>::new())
3509 .max_invalid_tool_call_retries(1)
3510 .await;
3511
3512 while let Some(item) = stream.next().await {
3513 match item {
3514 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3515 Ok(_) => {}
3516 Err(err) => panic!("unexpected streaming error: {err:?}"),
3517 }
3518 }
3519
3520 let requests = recorded.requests();
3521 assert_eq!(requests.len(), 2);
3522 let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3523 assert!(assistant_reasoning_precedes_tool_call(
3524 &retry_history,
3525 "delta reason",
3526 "default_api"
3527 ));
3528 }
3529
3530 #[tokio::test]
3531 async fn invalid_tool_call_hook_skip_resets_streaming_text_delta_state() {
3532 let text_hook = RecordingTextDeltaHook::default();
3533 let model = MockCompletionModel::from_stream_turns([
3534 vec![
3535 MockStreamEvent::text("stale "),
3536 MockStreamEvent::tool_call(
3537 "tool_call_1",
3538 "default_api",
3539 serde_json::json!({"x": 2, "y": 3}),
3540 ),
3541 MockStreamEvent::final_response_with_total_tokens(4),
3542 ],
3543 vec![
3544 MockStreamEvent::text("fresh"),
3545 MockStreamEvent::final_response_with_total_tokens(6),
3546 ],
3547 ]);
3548 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3549
3550 let mut stream = agent
3551 .stream_prompt("use the tool")
3552 .add_hook(RecordingTextAndSkipInvalidToolHook {
3553 text: text_hook.clone(),
3554 })
3555 .max_turns(3)
3556 .history(Vec::<Message>::new())
3557 .await;
3558
3559 while let Some(item) = stream.next().await {
3560 match item {
3561 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3562 Ok(_) => {}
3563 Err(err) => panic!("unexpected streaming error: {err:?}"),
3564 }
3565 }
3566
3567 assert_eq!(
3568 text_hook.observed(),
3569 vec![
3570 ("stale ".to_string(), "stale ".to_string()),
3571 ("fresh".to_string(), "fresh".to_string()),
3572 ]
3573 );
3574 }
3575
3576 #[tokio::test]
3577 async fn invalid_tool_call_delta_retry_uses_structured_tool_feedback() {
3578 let delta_hook = RecordingToolCallDeltaHook::default();
3579 let add_calls = Arc::new(AtomicU32::new(0));
3580 let model = MockCompletionModel::from_stream_turns([
3581 vec![
3582 MockStreamEvent::text("checking "),
3583 MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
3584 MockStreamEvent::tool_call(
3585 "tool_call_0",
3586 "add",
3587 serde_json::json!({"x": 1, "y": 2}),
3588 )
3589 .with_call_id("call_0"),
3590 MockStreamEvent::tool_call_arguments_delta(
3591 "tool_call_1",
3592 "internal_1",
3593 r#"{"x":2,"y":3}"#,
3594 ),
3595 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3596 MockStreamEvent::final_response_with_total_tokens(4),
3597 ],
3598 vec![
3599 MockStreamEvent::text("retried"),
3600 MockStreamEvent::final_response_with_total_tokens(6),
3601 ],
3602 ]);
3603 let recorded = model.clone();
3604 let agent = AgentBuilder::new(model)
3605 .tool(CountingAddTool {
3606 calls: add_calls.clone(),
3607 })
3608 .build();
3609
3610 let mut stream = agent
3611 .stream_prompt("use the tool")
3612 .add_hook(RecordingDeltaAndRetryInvalidToolHook {
3613 delta: delta_hook.clone(),
3614 })
3615 .max_turns(3)
3616 .history(Vec::<Message>::new())
3617 .max_invalid_tool_call_retries(1)
3618 .await;
3619 let mut completion_call_events = Vec::new();
3620 let mut final_response_text = None;
3621 let mut final_response_usage = Usage::new();
3622 let mut final_completion_calls = Vec::new();
3623
3624 while let Some(item) = stream.next().await {
3625 match item {
3626 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
3627 completion_call_events.push(completion_call);
3628 }
3629 Ok(MultiTurnStreamItem::StreamAssistantItem(
3630 StreamedAssistantContent::ToolCallDelta { .. },
3631 )) => panic!("invalid tool-call delta should not be emitted"),
3632 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3633 final_response_text = Some(response.output().to_string());
3634 final_response_usage = response.usage();
3635 final_completion_calls = response.completion_calls().to_vec();
3636 break;
3637 }
3638 Ok(_) => {}
3639 Err(err) => panic!("unexpected streaming error: {err:?}"),
3640 }
3641 }
3642
3643 assert_eq!(final_response_text.as_deref(), Some("retried"));
3644 assert!(delta_hook.observed().is_empty());
3645 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3646 let mut first_usage = Usage::new();
3647 first_usage.total_tokens = 4;
3648 let mut second_usage = Usage::new();
3649 second_usage.total_tokens = 6;
3650 let expected_completion_calls = vec![
3651 CompletionCall::new(0, first_usage),
3652 CompletionCall::new(1, second_usage),
3653 ];
3654 assert_eq!(completion_call_events, expected_completion_calls);
3655 assert_eq!(final_completion_calls, expected_completion_calls);
3656 assert_eq!(final_response_usage.total_tokens, 10);
3657
3658 let requests = recorded.requests();
3659 assert_eq!(requests.len(), 2);
3660 let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3661 assert!(matches!(
3662 retry_history.get(1),
3663 Some(Message::Assistant { content, .. })
3664 if content.iter().any(|item| matches!(
3665 item,
3666 AssistantContent::Text(text) if text.text == "checking "
3667 ))
3668 && content.iter().any(|item| matches!(
3669 item,
3670 AssistantContent::ToolCall(tool_call)
3671 if tool_call.id == "tool_call_0"
3672 && tool_call.function.name == "add"
3673 ))
3674 && content.iter().any(|item| matches!(
3675 item,
3676 AssistantContent::ToolCall(tool_call)
3677 if tool_call.id == "tool_call_1"
3678 && tool_call.function.name == "default_api"
3679 && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
3680 ))
3681 ));
3682 assert!(matches!(
3683 retry_history.get(2),
3684 Some(Message::User { content })
3685 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3686 && content.iter().any(|item| matches!(
3687 item,
3688 UserContent::ToolResult(result)
3689 if result.id == "tool_call_0"
3690 && result.call_id.as_deref() == Some("call_0")
3691 && result.content.iter().any(|content| matches!(
3692 content,
3693 ToolResultContent::Text(text)
3694 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3695 ))
3696 ))
3697 && content.iter().any(|item| matches!(
3698 item,
3699 UserContent::ToolResult(result)
3700 if result.id == "tool_call_1"
3701 && result.content.iter().any(|content| matches!(
3702 content,
3703 ToolResultContent::Text(text)
3704 if text.text == "Use the add tool instead"
3705 ))
3706 ))
3707 ));
3708 }
3709
3710 #[tokio::test]
3711 async fn invalid_tool_call_delta_context_includes_same_turn_history_and_tool_call_id() {
3712 let invalid_hook = RecordingInvalidToolCallHook::default();
3713 let model = MockCompletionModel::from_stream_turns([
3714 vec![
3715 MockStreamEvent::text("checking "),
3716 MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
3717 MockStreamEvent::tool_call(
3718 "tool_call_0",
3719 "add",
3720 serde_json::json!({"x": 1, "y": 2}),
3721 )
3722 .with_call_id("call_0"),
3723 MockStreamEvent::tool_call_arguments_delta(
3724 "tool_call_1",
3725 "internal_1",
3726 r#"{"x":2,"y":3}"#,
3727 ),
3728 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3729 MockStreamEvent::final_response_with_total_tokens(4),
3730 ],
3731 vec![
3732 MockStreamEvent::text("should not be requested"),
3733 MockStreamEvent::final_response_with_total_tokens(6),
3734 ],
3735 ]);
3736 let recorded = model.clone();
3737 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3738
3739 let mut stream = agent
3740 .stream_prompt("use the tool")
3741 .add_hook(invalid_hook.clone())
3742 .max_turns(3)
3743 .await;
3744 let mut error = None;
3745
3746 while let Some(item) = stream.next().await {
3747 if let Err(err) = item {
3748 error = Some(err);
3749 break;
3750 }
3751 }
3752
3753 assert!(error.is_some(), "invalid name delta should fail");
3754 assert_eq!(recorded.request_count(), 1);
3755 let contexts = invalid_hook.observed();
3756 assert_eq!(contexts.len(), 1);
3757 let context = &contexts[0];
3758 assert_eq!(context.tool_name, "default_api");
3759 assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
3760 assert_eq!(context.internal_call_id.as_deref(), Some("internal_1"));
3761 assert!(context.is_streaming);
3762 assert!(history_contains_text(&context.chat_history, "checking "));
3763 assert!(
3764 assistant_reasoning_precedes_tool_call(
3765 &context.chat_history,
3766 "diagnostic reason",
3767 "add"
3768 ),
3769 "{:?}",
3770 context.chat_history
3771 );
3772 assert!(history_contains_tool_call(&context.chat_history, "add"));
3773 assert!(history_contains_tool_call(
3774 &context.chat_history,
3775 "default_api"
3776 ));
3777 }
3778
3779 #[tokio::test]
3780 async fn invalid_tool_call_delta_retry_resets_streaming_text_delta_state() {
3781 let text_hook = RecordingTextDeltaHook::default();
3782 let model = MockCompletionModel::from_stream_turns([
3783 vec![
3784 MockStreamEvent::text("stale "),
3785 MockStreamEvent::tool_call_arguments_delta(
3786 "tool_call_1",
3787 "internal_1",
3788 r#"{"x":2,"y":3}"#,
3789 ),
3790 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3791 MockStreamEvent::final_response_with_total_tokens(4),
3792 ],
3793 vec![
3794 MockStreamEvent::text("fresh"),
3795 MockStreamEvent::final_response_with_total_tokens(6),
3796 ],
3797 ]);
3798 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3799
3800 let mut stream = agent
3801 .stream_prompt("use the tool")
3802 .add_hook(RecordingTextAndRetryInvalidToolHook {
3803 text: text_hook.clone(),
3804 })
3805 .max_turns(3)
3806 .history(Vec::<Message>::new())
3807 .max_invalid_tool_call_retries(1)
3808 .await;
3809
3810 while let Some(item) = stream.next().await {
3811 match item {
3812 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
3813 Ok(_) => {}
3814 Err(err) => panic!("unexpected streaming error: {err:?}"),
3815 }
3816 }
3817
3818 assert_eq!(
3819 text_hook.observed(),
3820 vec![
3821 ("stale ".to_string(), "stale ".to_string()),
3822 ("fresh".to_string(), "fresh".to_string()),
3823 ]
3824 );
3825 }
3826
3827 #[tokio::test]
3828 async fn invalid_tool_call_delta_skip_uses_structured_tool_feedback() {
3829 let delta_hook = RecordingToolCallDeltaHook::default();
3830 let add_calls = Arc::new(AtomicU32::new(0));
3831 let model = MockCompletionModel::from_stream_turns([
3832 vec![
3833 MockStreamEvent::text("checking "),
3834 MockStreamEvent::tool_call(
3835 "tool_call_0",
3836 "add",
3837 serde_json::json!({"x": 1, "y": 2}),
3838 )
3839 .with_call_id("call_0"),
3840 MockStreamEvent::tool_call_arguments_delta(
3841 "tool_call_1",
3842 "internal_1",
3843 r#"{"x":2,"y":3}"#,
3844 ),
3845 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
3846 MockStreamEvent::final_response_with_total_tokens(4),
3847 ],
3848 vec![
3849 MockStreamEvent::text("continued"),
3850 MockStreamEvent::final_response_with_total_tokens(6),
3851 ],
3852 ]);
3853 let recorded = model.clone();
3854 let agent = AgentBuilder::new(model)
3855 .tool(CountingAddTool {
3856 calls: add_calls.clone(),
3857 })
3858 .build();
3859
3860 let mut stream = agent
3861 .stream_prompt("use the tool")
3862 .add_hook(RecordingDeltaAndSkipInvalidToolHook {
3863 delta: delta_hook.clone(),
3864 })
3865 .max_turns(3)
3866 .history(Vec::<Message>::new())
3867 .await;
3868 let mut skipped_tool_result = None;
3869 let mut final_response_text = None;
3870
3871 while let Some(item) = stream.next().await {
3872 match item {
3873 Ok(MultiTurnStreamItem::StreamAssistantItem(
3874 StreamedAssistantContent::ToolCallDelta { .. },
3875 )) => panic!("invalid tool-call delta should not be emitted"),
3876 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
3877 tool_result,
3878 internal_call_id,
3879 })) => {
3880 assert_eq!(internal_call_id, "internal_1");
3881 skipped_tool_result = Some(tool_result);
3882 }
3883 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
3884 final_response_text = Some(response.output().to_string());
3885 break;
3886 }
3887 Ok(_) => {}
3888 Err(err) => panic!("unexpected streaming error: {err:?}"),
3889 }
3890 }
3891
3892 let skipped_tool_result =
3893 skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
3894 assert_eq!(skipped_tool_result.id, "tool_call_1");
3895 assert!(skipped_tool_result.call_id.is_none());
3896 assert!(skipped_tool_result.content.iter().any(|content| matches!(
3897 content,
3898 ToolResultContent::Text(text) if text.text == "default_api was skipped"
3899 )));
3900 assert_eq!(final_response_text.as_deref(), Some("continued"));
3901 assert!(delta_hook.observed().is_empty());
3902 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
3903
3904 let requests = recorded.requests();
3905 assert_eq!(requests.len(), 2);
3906 let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
3907 assert!(matches!(
3908 follow_up_history.get(1),
3909 Some(Message::Assistant { content, .. })
3910 if content.iter().any(|item| matches!(
3911 item,
3912 AssistantContent::Text(text) if text.text == "checking "
3913 ))
3914 && content.iter().any(|item| matches!(
3915 item,
3916 AssistantContent::ToolCall(tool_call)
3917 if tool_call.id == "tool_call_0"
3918 && tool_call.function.name == "add"
3919 ))
3920 && content.iter().any(|item| matches!(
3921 item,
3922 AssistantContent::ToolCall(tool_call)
3923 if tool_call.id == "tool_call_1"
3924 && tool_call.function.name == "default_api"
3925 && tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
3926 ))
3927 ));
3928 assert!(matches!(
3929 follow_up_history.get(2),
3930 Some(Message::User { content })
3931 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
3932 && content.iter().any(|item| matches!(
3933 item,
3934 UserContent::ToolResult(result)
3935 if result.id == "tool_call_0"
3936 && result.call_id.as_deref() == Some("call_0")
3937 && result.content.iter().any(|content| matches!(
3938 content,
3939 ToolResultContent::Text(text)
3940 if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
3941 ))
3942 ))
3943 && content.iter().any(|item| matches!(
3944 item,
3945 UserContent::ToolResult(result)
3946 if result.id == "tool_call_1"
3947 && result.content.iter().any(|content| matches!(
3948 content,
3949 ToolResultContent::Text(text)
3950 if text.text == "default_api was skipped"
3951 ))
3952 ))
3953 ));
3954 }
3955
3956 #[tokio::test]
3957 async fn streaming_retry_budget_exhaustion_history_contains_invalid_tool_call() {
3958 let model = MockCompletionModel::from_stream_turns([
3959 vec![
3960 MockStreamEvent::tool_call(
3961 "tool_call_1",
3962 "default_api",
3963 serde_json::json!({"x": 1, "y": 2}),
3964 ),
3965 MockStreamEvent::final_response_with_total_tokens(4),
3966 ],
3967 vec![
3968 MockStreamEvent::text("should not be requested"),
3969 MockStreamEvent::final_response_with_total_tokens(6),
3970 ],
3971 ]);
3972 let recorded = model.clone();
3973 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
3974
3975 let mut stream = agent
3976 .stream_prompt("use the tool")
3977 .add_hook(RetryDefaultApiHook)
3978 .max_turns(3)
3979 .max_invalid_tool_call_retries(0)
3980 .await;
3981 let mut error = None;
3982
3983 while let Some(item) = stream.next().await {
3984 if let Err(err) = item {
3985 error = Some(err);
3986 break;
3987 }
3988 }
3989
3990 let error = error.expect("retry budget exhaustion should fail");
3991 match error {
3992 StreamingError::Prompt(err) => match *err {
3993 PromptError::UnknownToolCall {
3994 tool_name,
3995 chat_history,
3996 ..
3997 } => {
3998 assert_eq!(tool_name, "default_api");
3999 assert!(history_contains_tool_call(&chat_history, "default_api"));
4000 }
4001 other => panic!("expected UnknownToolCall, got {other:?}"),
4002 },
4003 other => panic!("expected prompt streaming error, got {other:?}"),
4004 }
4005 assert_eq!(recorded.request_count(), 1);
4006 }
4007
4008 #[tokio::test]
4009 async fn streaming_name_delta_retry_budget_exhaustion_history_includes_same_turn_context() {
4010 let model = MockCompletionModel::from_stream_turns([
4011 vec![
4012 MockStreamEvent::text("checking "),
4013 MockStreamEvent::tool_call(
4014 "tool_call_0",
4015 "add",
4016 serde_json::json!({"x": 1, "y": 2}),
4017 )
4018 .with_call_id("call_0"),
4019 MockStreamEvent::tool_call_arguments_delta(
4020 "tool_call_1",
4021 "internal_1",
4022 r#"{"x":2,"y":3}"#,
4023 ),
4024 MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
4025 MockStreamEvent::final_response_with_total_tokens(4),
4026 ],
4027 vec![
4028 MockStreamEvent::text("should not be requested"),
4029 MockStreamEvent::final_response_with_total_tokens(6),
4030 ],
4031 ]);
4032 let recorded = model.clone();
4033 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4034
4035 let mut stream = agent
4036 .stream_prompt("use the tool")
4037 .add_hook(RetryDefaultApiHook)
4038 .max_turns(3)
4039 .max_invalid_tool_call_retries(0)
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 let error = error.expect("retry budget exhaustion should fail");
4051 match error {
4052 StreamingError::Prompt(err) => match *err {
4053 PromptError::UnknownToolCall {
4054 tool_name,
4055 chat_history,
4056 ..
4057 } => {
4058 assert_eq!(tool_name, "default_api");
4059 assert!(history_contains_text(&chat_history, "checking "));
4060 assert!(history_contains_tool_call(&chat_history, "add"));
4061 assert!(history_contains_tool_call(&chat_history, "default_api"));
4062 }
4063 other => panic!("expected UnknownToolCall, got {other:?}"),
4064 },
4065 other => panic!("expected prompt streaming error, got {other:?}"),
4066 }
4067 assert_eq!(recorded.request_count(), 1);
4068 }
4069
4070 #[tokio::test]
4071 async fn completed_unknown_tool_call_after_text_fails_before_finish_hook_or_later_emit() {
4072 let add_calls = Arc::new(AtomicU32::new(0));
4073 let model = MockCompletionModel::from_stream_turns([
4074 vec![
4075 MockStreamEvent::text("thinking "),
4076 MockStreamEvent::tool_call(
4077 "tool_call_1",
4078 "default_api",
4079 serde_json::json!({"x": 1, "y": 2}),
4080 ),
4081 MockStreamEvent::final_response_with_total_tokens(4),
4082 ],
4083 vec![
4084 MockStreamEvent::text("should not be requested"),
4085 MockStreamEvent::final_response_with_total_tokens(6),
4086 ],
4087 ]);
4088 let recorded = model.clone();
4089 let agent = AgentBuilder::new(model)
4090 .tool(CountingAddTool {
4091 calls: add_calls.clone(),
4092 })
4093 .build();
4094
4095 let mut stream = agent
4096 .stream_prompt("use the tool")
4097 .add_hook(PanicOnUnknownToolHook)
4098 .max_turns(3)
4099 .await;
4100 let mut saw_text = false;
4101 let mut saw_completion_call = false;
4102 let mut saw_final_response = false;
4103 let mut saw_tool_call = false;
4104 let mut saw_tool_result = false;
4105 let mut error = None;
4106
4107 while let Some(item) = stream.next().await {
4108 match item {
4109 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(_))) => {
4110 saw_text = true;
4111 }
4112 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4113 saw_completion_call = true;
4114 }
4115 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Final(
4116 _,
4117 )))
4118 | Ok(MultiTurnStreamItem::FinalResponse(_)) => {
4119 saw_final_response = true;
4120 }
4121 Ok(MultiTurnStreamItem::StreamAssistantItem(
4122 StreamedAssistantContent::ToolCall { .. },
4123 )) => {
4124 saw_tool_call = true;
4125 }
4126 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4127 ..
4128 })) => {
4129 saw_tool_result = true;
4130 }
4131 Ok(_) => {}
4132 Err(err) => {
4133 error = Some(err);
4134 break;
4135 }
4136 }
4137 }
4138
4139 assert!(saw_text);
4140 assert!(!saw_completion_call);
4141 assert!(!saw_final_response);
4142 assert!(!saw_tool_call);
4143 assert!(!saw_tool_result);
4144 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4145 let error = error.expect("completed unknown tool call should fail immediately");
4146 match error {
4147 StreamingError::Prompt(err) => match *err {
4148 PromptError::UnknownToolCall {
4149 tool_name,
4150 available_tools,
4151 allowed_tools,
4152 chat_history,
4153 } => {
4154 assert_eq!(tool_name, "default_api");
4155 assert_eq!(available_tools, vec!["add".to_string()]);
4156 assert_eq!(allowed_tools, vec!["add".to_string()]);
4157 assert!(history_contains_tool_call(&chat_history, "default_api"));
4158 }
4159 other => panic!("expected UnknownToolCall, got {other:?}"),
4160 },
4161 other => panic!("expected prompt streaming error, got {other:?}"),
4162 }
4163 assert_eq!(recorded.request_count(), 1);
4164 }
4165
4166 #[tokio::test]
4167 async fn mixed_streaming_tool_calls_fail_before_any_tool_execution() {
4168 let add_calls = Arc::new(AtomicU32::new(0));
4169 let model = MockCompletionModel::from_stream_turns([
4170 vec![
4171 MockStreamEvent::tool_call(
4172 "tool_call_1",
4173 "add",
4174 serde_json::json!({"x": 1, "y": 2}),
4175 )
4176 .with_call_id("call_1"),
4177 MockStreamEvent::tool_call(
4178 "tool_call_2",
4179 "default_api",
4180 serde_json::json!({"x": 3, "y": 4}),
4181 ),
4182 MockStreamEvent::final_response_with_total_tokens(4),
4183 ],
4184 vec![
4185 MockStreamEvent::text("should not be requested"),
4186 MockStreamEvent::final_response_with_total_tokens(6),
4187 ],
4188 ]);
4189 let recorded = model.clone();
4190 let agent = AgentBuilder::new(model)
4191 .tool(CountingAddTool {
4192 calls: add_calls.clone(),
4193 })
4194 .build();
4195
4196 let mut stream = agent
4197 .stream_prompt("use tools")
4198 .add_hook(PanicOnUnknownToolHook)
4199 .max_turns(3)
4200 .await;
4201 let mut saw_completion_call = false;
4202 let mut saw_tool_call = false;
4203 let mut saw_tool_result = false;
4204 let mut error = None;
4205
4206 while let Some(item) = stream.next().await {
4207 match item {
4208 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4209 saw_completion_call = true;
4210 }
4211 Ok(MultiTurnStreamItem::StreamAssistantItem(
4212 StreamedAssistantContent::ToolCall { .. },
4213 )) => {
4214 saw_tool_call = true;
4215 }
4216 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4217 ..
4218 })) => {
4219 saw_tool_result = true;
4220 }
4221 Ok(_) => {}
4222 Err(err) => {
4223 error = Some(err);
4224 break;
4225 }
4226 }
4227 }
4228
4229 assert!(!saw_completion_call);
4230 assert!(!saw_tool_call);
4231 assert!(!saw_tool_result);
4232 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4233 let error = error.expect("mixed unknown streamed tool call should fail");
4234 match error {
4235 StreamingError::Prompt(err) => match *err {
4236 PromptError::UnknownToolCall {
4237 tool_name,
4238 available_tools,
4239 allowed_tools,
4240 chat_history,
4241 } => {
4242 assert_eq!(tool_name, "default_api");
4243 assert_eq!(available_tools, vec!["add".to_string()]);
4244 assert_eq!(allowed_tools, vec!["add".to_string()]);
4245 assert!(history_contains_tool_call(&chat_history, "default_api"));
4246 }
4247 other => panic!("expected UnknownToolCall, got {other:?}"),
4248 },
4249 other => panic!("expected prompt streaming error, got {other:?}"),
4250 }
4251 assert_eq!(recorded.request_count(), 1);
4252 }
4253
4254 #[tokio::test]
4255 async fn multiple_valid_streaming_tool_calls_execute_after_batch_validation() {
4256 let add_calls = Arc::new(AtomicU32::new(0));
4257 let subtract_calls = Arc::new(AtomicU32::new(0));
4258 let model = MockCompletionModel::from_stream_turns([
4259 vec![
4260 MockStreamEvent::tool_call(
4261 "tool_call_1",
4262 "add",
4263 serde_json::json!({"x": 1, "y": 2}),
4264 )
4265 .with_call_id("call_1"),
4266 MockStreamEvent::tool_call(
4267 "tool_call_2",
4268 "subtract",
4269 serde_json::json!({"x": 8, "y": 3}),
4270 )
4271 .with_call_id("call_2"),
4272 MockStreamEvent::final_response_with_total_tokens(4),
4273 ],
4274 vec![
4275 MockStreamEvent::text("done"),
4276 MockStreamEvent::final_response_with_total_tokens(6),
4277 ],
4278 ]);
4279 let recorded = model.clone();
4280 let agent = AgentBuilder::new(model)
4281 .tool(CountingAddTool {
4282 calls: add_calls.clone(),
4283 })
4284 .tool(CountingSubtractTool {
4285 calls: subtract_calls.clone(),
4286 })
4287 .build();
4288
4289 let mut stream = agent.stream_prompt("use tools").max_turns(3).await;
4290 let mut tool_call_names = Vec::new();
4291 let mut tool_result_ids = Vec::new();
4292 let mut final_response_text = None;
4293
4294 while let Some(item) = stream.next().await {
4295 match item {
4296 Ok(MultiTurnStreamItem::StreamAssistantItem(
4297 StreamedAssistantContent::ToolCall { tool_call, .. },
4298 )) => {
4299 tool_call_names.push(tool_call.function.name);
4300 }
4301 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4302 tool_result,
4303 ..
4304 })) => {
4305 tool_result_ids.push(tool_result.id);
4306 }
4307 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
4308 final_response_text = Some(response.output().to_owned());
4309 break;
4310 }
4311 Ok(_) => {}
4312 Err(err) => panic!("unexpected streaming error: {err:?}"),
4313 }
4314 }
4315
4316 assert_eq!(
4317 tool_call_names,
4318 vec!["add".to_string(), "subtract".to_string()]
4319 );
4320 assert_eq!(
4321 tool_result_ids,
4322 vec!["tool_call_1".to_string(), "tool_call_2".to_string()]
4323 );
4324 assert_eq!(add_calls.load(Ordering::SeqCst), 1);
4325 assert_eq!(subtract_calls.load(Ordering::SeqCst), 1);
4326 assert_eq!(final_response_text.as_deref(), Some("done"));
4327 assert_eq!(recorded.request_count(), 2);
4328 }
4329
4330 #[tokio::test]
4331 async fn disallowed_specific_tool_call_fails_before_streaming_second_request() {
4332 let model = MockCompletionModel::from_stream_turns([
4333 vec![
4334 MockStreamEvent::tool_call(
4335 "tool_call_1",
4336 "subtract",
4337 serde_json::json!({"x": 3, "y": 1}),
4338 ),
4339 MockStreamEvent::final_response_with_total_tokens(4),
4340 ],
4341 vec![
4342 MockStreamEvent::text("should not be requested"),
4343 MockStreamEvent::final_response_with_total_tokens(6),
4344 ],
4345 ]);
4346 let recorded = model.clone();
4347 let agent = AgentBuilder::new(model)
4348 .tool(MockAddTool)
4349 .tool(MockSubtractTool)
4350 .tool_choice(ToolChoice::Specific {
4351 function_names: vec!["add".to_string()],
4352 })
4353 .build();
4354
4355 let mut stream = agent
4356 .stream_prompt("use the allowed tool")
4357 .add_hook(PanicOnUnknownToolHook)
4358 .max_turns(3)
4359 .await;
4360 let mut saw_tool_call = false;
4361 let mut error = None;
4362
4363 while let Some(item) = stream.next().await {
4364 match item {
4365 Ok(MultiTurnStreamItem::StreamAssistantItem(
4366 StreamedAssistantContent::ToolCall { .. },
4367 )) => {
4368 saw_tool_call = true;
4369 }
4370 Ok(_) => {}
4371 Err(err) => {
4372 error = Some(err);
4373 break;
4374 }
4375 }
4376 }
4377
4378 assert!(!saw_tool_call);
4379 let error = error.expect("disallowed model-emitted tool should fail");
4380 match error {
4381 StreamingError::Prompt(err) => match *err {
4382 PromptError::UnknownToolCall {
4383 tool_name,
4384 available_tools,
4385 allowed_tools,
4386 chat_history,
4387 } => {
4388 assert_eq!(tool_name, "subtract");
4389 assert_eq!(
4390 available_tools,
4391 vec!["add".to_string(), "subtract".to_string()]
4392 );
4393 assert_eq!(allowed_tools, vec!["add".to_string()]);
4394 assert!(history_contains_tool_call(&chat_history, "subtract"));
4395 }
4396 other => panic!("expected UnknownToolCall, got {other:?}"),
4397 },
4398 other => panic!("expected prompt streaming error, got {other:?}"),
4399 }
4400 assert_eq!(recorded.request_count(), 1);
4401 }
4402
4403 #[tokio::test]
4404 async fn mixed_specific_tool_calls_fail_before_any_tool_execution() {
4405 let add_calls = Arc::new(AtomicU32::new(0));
4406 let model = MockCompletionModel::from_stream_turns([
4407 vec![
4408 MockStreamEvent::tool_call(
4409 "tool_call_1",
4410 "add",
4411 serde_json::json!({"x": 1, "y": 2}),
4412 ),
4413 MockStreamEvent::tool_call(
4414 "tool_call_2",
4415 "subtract",
4416 serde_json::json!({"x": 3, "y": 1}),
4417 ),
4418 MockStreamEvent::final_response_with_total_tokens(4),
4419 ],
4420 vec![
4421 MockStreamEvent::text("should not be requested"),
4422 MockStreamEvent::final_response_with_total_tokens(6),
4423 ],
4424 ]);
4425 let recorded = model.clone();
4426 let agent = AgentBuilder::new(model)
4427 .tool(CountingAddTool {
4428 calls: add_calls.clone(),
4429 })
4430 .tool(MockSubtractTool)
4431 .tool_choice(ToolChoice::Specific {
4432 function_names: vec!["add".to_string()],
4433 })
4434 .build();
4435
4436 let mut stream = agent
4437 .stream_prompt("use the allowed tool")
4438 .add_hook(PanicOnUnknownToolHook)
4439 .max_turns(3)
4440 .await;
4441 let mut saw_tool_call = false;
4442 let mut saw_tool_result = false;
4443 let mut error = None;
4444
4445 while let Some(item) = stream.next().await {
4446 match item {
4447 Ok(MultiTurnStreamItem::StreamAssistantItem(
4448 StreamedAssistantContent::ToolCall { .. },
4449 )) => {
4450 saw_tool_call = true;
4451 }
4452 Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
4453 ..
4454 })) => {
4455 saw_tool_result = true;
4456 }
4457 Ok(_) => {}
4458 Err(err) => {
4459 error = Some(err);
4460 break;
4461 }
4462 }
4463 }
4464
4465 assert!(!saw_tool_call);
4466 assert!(!saw_tool_result);
4467 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
4468 let error = error.expect("mixed disallowed streamed tool call should fail");
4469 match error {
4470 StreamingError::Prompt(err) => match *err {
4471 PromptError::UnknownToolCall {
4472 tool_name,
4473 available_tools,
4474 allowed_tools,
4475 chat_history,
4476 } => {
4477 assert_eq!(tool_name, "subtract");
4478 assert_eq!(
4479 available_tools,
4480 vec!["add".to_string(), "subtract".to_string()]
4481 );
4482 assert_eq!(allowed_tools, vec!["add".to_string()]);
4483 assert!(history_contains_tool_call(&chat_history, "subtract"));
4484 }
4485 other => panic!("expected UnknownToolCall, got {other:?}"),
4486 },
4487 other => panic!("expected prompt streaming error, got {other:?}"),
4488 }
4489 assert_eq!(recorded.request_count(), 1);
4490 }
4491
4492 #[tokio::test]
4493 async fn tool_choice_none_rejects_streaming_tool_call() {
4494 let model = MockCompletionModel::from_stream_turns([
4495 vec![
4496 MockStreamEvent::tool_call(
4497 "tool_call_1",
4498 "add",
4499 serde_json::json!({"x": 1, "y": 2}),
4500 ),
4501 MockStreamEvent::final_response_with_total_tokens(4),
4502 ],
4503 vec![
4504 MockStreamEvent::text("should not be requested"),
4505 MockStreamEvent::final_response_with_total_tokens(6),
4506 ],
4507 ]);
4508 let recorded = model.clone();
4509 let agent = AgentBuilder::new(model)
4510 .tool(MockAddTool)
4511 .tool_choice(ToolChoice::None)
4512 .build();
4513
4514 let mut stream = agent
4515 .stream_prompt("do not use tools")
4516 .add_hook(PanicOnUnknownToolHook)
4517 .max_turns(3)
4518 .await;
4519 let mut saw_tool_call = false;
4520 let mut error = None;
4521
4522 while let Some(item) = stream.next().await {
4523 match item {
4524 Ok(MultiTurnStreamItem::StreamAssistantItem(
4525 StreamedAssistantContent::ToolCall { .. },
4526 )) => {
4527 saw_tool_call = true;
4528 }
4529 Ok(_) => {}
4530 Err(err) => {
4531 error = Some(err);
4532 break;
4533 }
4534 }
4535 }
4536
4537 assert!(!saw_tool_call);
4538 let error = error.expect("ToolChoice::None should reject returned tool calls");
4539 match error {
4540 StreamingError::Prompt(err) => match *err {
4541 PromptError::UnknownToolCall {
4542 tool_name,
4543 available_tools,
4544 allowed_tools,
4545 chat_history,
4546 } => {
4547 assert_eq!(tool_name, "add");
4548 assert_eq!(available_tools, vec!["add".to_string()]);
4549 assert!(allowed_tools.is_empty());
4550 assert!(history_contains_tool_call(&chat_history, "add"));
4551 }
4552 other => panic!("expected UnknownToolCall, got {other:?}"),
4553 },
4554 other => panic!("expected prompt streaming error, got {other:?}"),
4555 }
4556 assert_eq!(recorded.request_count(), 1);
4557 }
4558
4559 #[tokio::test]
4560 async fn tool_choice_none_rejects_streaming_tool_call_name_delta_before_hook_or_emit() {
4561 let model = MockCompletionModel::from_stream_turns([
4562 vec![
4563 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4564 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4565 MockStreamEvent::final_response_with_total_tokens(4),
4566 ],
4567 vec![
4568 MockStreamEvent::text("should not be requested"),
4569 MockStreamEvent::final_response_with_total_tokens(6),
4570 ],
4571 ]);
4572 let recorded = model.clone();
4573 let agent = AgentBuilder::new(model)
4574 .tool(MockAddTool)
4575 .tool_choice(ToolChoice::None)
4576 .build();
4577
4578 let mut stream = agent
4579 .stream_prompt("do not use tools")
4580 .add_hook(PanicOnUnknownToolHook)
4581 .max_turns(3)
4582 .await;
4583 let mut saw_delta = false;
4584 let mut error = None;
4585
4586 while let Some(item) = stream.next().await {
4587 match item {
4588 Ok(MultiTurnStreamItem::StreamAssistantItem(
4589 StreamedAssistantContent::ToolCallDelta { .. },
4590 )) => {
4591 saw_delta = true;
4592 }
4593 Ok(_) => {}
4594 Err(err) => {
4595 error = Some(err);
4596 break;
4597 }
4598 }
4599 }
4600
4601 assert!(!saw_delta);
4602 let error = error.expect("ToolChoice::None should reject returned tool-call deltas");
4603 match error {
4604 StreamingError::Prompt(err) => match *err {
4605 PromptError::UnknownToolCall {
4606 tool_name,
4607 available_tools,
4608 allowed_tools,
4609 chat_history,
4610 } => {
4611 assert_eq!(tool_name, "add");
4612 assert_eq!(available_tools, vec!["add".to_string()]);
4613 assert!(allowed_tools.is_empty());
4614 assert!(history_contains_tool_call(&chat_history, "add"));
4615 }
4616 other => panic!("expected UnknownToolCall, got {other:?}"),
4617 },
4618 other => panic!("expected prompt streaming error, got {other:?}"),
4619 }
4620 assert_eq!(recorded.request_count(), 1);
4621 }
4622
4623 #[tokio::test]
4624 async fn unknown_tool_call_name_delta_fails_before_streaming_delta_hook_or_emit() {
4625 let model = MockCompletionModel::from_stream_turns([
4626 vec![
4627 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
4628 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4629 MockStreamEvent::final_response_with_total_tokens(4),
4630 ],
4631 vec![
4632 MockStreamEvent::text("should not be requested"),
4633 MockStreamEvent::final_response_with_total_tokens(6),
4634 ],
4635 ]);
4636 let recorded = model.clone();
4637 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4638
4639 let mut stream = agent
4640 .stream_prompt("stream a bad tool call")
4641 .add_hook(PanicOnUnknownToolHook)
4642 .max_turns(3)
4643 .await;
4644 let mut saw_delta = false;
4645 let mut error = None;
4646
4647 while let Some(item) = stream.next().await {
4648 match item {
4649 Ok(MultiTurnStreamItem::StreamAssistantItem(
4650 StreamedAssistantContent::ToolCallDelta { .. },
4651 )) => {
4652 saw_delta = true;
4653 }
4654 Ok(_) => {}
4655 Err(err) => {
4656 error = Some(err);
4657 break;
4658 }
4659 }
4660 }
4661
4662 assert!(!saw_delta);
4663 let error = error.expect("unknown tool-call name delta should fail");
4664 match error {
4665 StreamingError::Prompt(err) => match *err {
4666 PromptError::UnknownToolCall {
4667 tool_name,
4668 available_tools,
4669 allowed_tools,
4670 chat_history,
4671 } => {
4672 assert_eq!(tool_name, "default_api");
4673 assert_eq!(available_tools, vec!["add".to_string()]);
4674 assert_eq!(allowed_tools, vec!["add".to_string()]);
4675 assert!(history_contains_tool_call(&chat_history, "default_api"));
4676 }
4677 other => panic!("expected UnknownToolCall, got {other:?}"),
4678 },
4679 other => panic!("expected prompt streaming error, got {other:?}"),
4680 }
4681 assert_eq!(recorded.request_count(), 1);
4682 }
4683
4684 #[tokio::test]
4685 async fn tool_call_args_delta_before_unknown_name_fails_before_hook_or_emit() {
4686 let model = MockCompletionModel::from_stream_turns([
4687 vec![
4688 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4689 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
4690 MockStreamEvent::final_response_with_total_tokens(4),
4691 ],
4692 vec![
4693 MockStreamEvent::text("should not be requested"),
4694 MockStreamEvent::final_response_with_total_tokens(6),
4695 ],
4696 ]);
4697 let recorded = model.clone();
4698 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4699
4700 let mut stream = agent
4701 .stream_prompt("stream a bad tool call")
4702 .add_hook(PanicOnUnknownToolHook)
4703 .max_turns(3)
4704 .await;
4705 let mut saw_delta = false;
4706 let mut error = None;
4707
4708 while let Some(item) = stream.next().await {
4709 match item {
4710 Ok(MultiTurnStreamItem::StreamAssistantItem(
4711 StreamedAssistantContent::ToolCallDelta { .. },
4712 )) => {
4713 saw_delta = true;
4714 }
4715 Ok(_) => {}
4716 Err(err) => {
4717 error = Some(err);
4718 break;
4719 }
4720 }
4721 }
4722
4723 assert!(!saw_delta);
4724 let error = error.expect("unknown tool-call name should reject buffered args");
4725 match error {
4726 StreamingError::Prompt(err) => match *err {
4727 PromptError::UnknownToolCall {
4728 tool_name,
4729 available_tools,
4730 allowed_tools,
4731 chat_history,
4732 } => {
4733 assert_eq!(tool_name, "default_api");
4734 assert_eq!(available_tools, vec!["add".to_string()]);
4735 assert_eq!(allowed_tools, vec!["add".to_string()]);
4736 assert!(history_contains_tool_call(&chat_history, "default_api"));
4737 }
4738 other => panic!("expected UnknownToolCall, got {other:?}"),
4739 },
4740 other => panic!("expected prompt streaming error, got {other:?}"),
4741 }
4742 assert_eq!(recorded.request_count(), 1);
4743 }
4744
4745 #[tokio::test]
4746 async fn tool_call_args_delta_before_valid_name_buffers_then_emits_in_safe_order() {
4747 let model = MockCompletionModel::from_stream_turns([[
4748 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
4749 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4750 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
4751 MockStreamEvent::final_response_with_total_tokens(3),
4752 ]]);
4753 let hook = RecordingToolCallDeltaHook::default();
4754 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4755
4756 let mut stream = agent
4757 .stream_prompt("stream a tool call")
4758 .add_hook(hook.clone())
4759 .await;
4760 let mut stream_deltas = Vec::new();
4761
4762 while let Some(item) = stream.next().await {
4763 match item {
4764 Ok(MultiTurnStreamItem::StreamAssistantItem(
4765 StreamedAssistantContent::ToolCallDelta {
4766 id,
4767 internal_call_id,
4768 content,
4769 },
4770 )) => {
4771 stream_deltas.push((id, internal_call_id, content));
4772 }
4773 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4774 Ok(_) => {}
4775 Err(err) => panic!("unexpected streaming error: {err:?}"),
4776 }
4777 }
4778
4779 assert_eq!(
4780 hook.observed(),
4781 vec![
4782 (
4783 "tool_1".to_string(),
4784 "internal_1".to_string(),
4785 Some("add".to_string()),
4786 String::new()
4787 ),
4788 (
4789 "tool_1".to_string(),
4790 "internal_1".to_string(),
4791 None,
4792 "{\"x\":".to_string()
4793 ),
4794 (
4795 "tool_1".to_string(),
4796 "internal_1".to_string(),
4797 None,
4798 "1}".to_string()
4799 ),
4800 ]
4801 );
4802 assert_eq!(
4803 stream_deltas,
4804 vec![
4805 (
4806 "tool_1".to_string(),
4807 "internal_1".to_string(),
4808 ToolCallDeltaContent::Name("add".to_string())
4809 ),
4810 (
4811 "tool_1".to_string(),
4812 "internal_1".to_string(),
4813 ToolCallDeltaContent::Delta("{\"x\":".to_string())
4814 ),
4815 (
4816 "tool_1".to_string(),
4817 "internal_1".to_string(),
4818 ToolCallDeltaContent::Delta("1}".to_string())
4819 ),
4820 ]
4821 );
4822 }
4823
4824 #[tokio::test]
4825 async fn tool_call_args_delta_without_name_errors_at_stream_end() {
4826 let model = MockCompletionModel::from_stream_turns([
4827 vec![
4828 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4829 MockStreamEvent::final_response_with_total_tokens(4),
4830 ],
4831 vec![
4832 MockStreamEvent::text("should not be requested"),
4833 MockStreamEvent::final_response_with_total_tokens(6),
4834 ],
4835 ]);
4836 let recorded = model.clone();
4837 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4838
4839 let mut stream = agent
4840 .stream_prompt("stream an incomplete tool call")
4841 .add_hook(PanicOnUnknownToolHook)
4842 .max_turns(3)
4843 .await;
4844 let mut saw_delta = false;
4845 let mut saw_completion_call = false;
4846 let mut saw_final_response = false;
4847 let mut error = None;
4848
4849 while let Some(item) = stream.next().await {
4850 match item {
4851 Ok(MultiTurnStreamItem::StreamAssistantItem(
4852 StreamedAssistantContent::ToolCallDelta { .. },
4853 )) => {
4854 saw_delta = true;
4855 }
4856 Ok(MultiTurnStreamItem::CompletionCall(_)) => {
4857 saw_completion_call = true;
4858 }
4859 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
4860 saw_final_response = true;
4861 }
4862 Ok(_) => {}
4863 Err(err) => {
4864 error = Some(err);
4865 break;
4866 }
4867 }
4868 }
4869
4870 assert!(!saw_delta);
4871 assert!(!saw_completion_call);
4872 assert!(!saw_final_response);
4873 let error = error.expect("unterminated tool-call args delta should fail");
4874 match error {
4875 StreamingError::Completion(CompletionError::ResponseError(message)) => {
4876 assert!(
4877 message.contains("streamed tool call arguments"),
4878 "{message}"
4879 );
4880 assert!(message.contains("tool_1"), "{message}");
4881 assert!(message.contains("internal_1"), "{message}");
4882 }
4883 other => panic!("expected completion response error, got {other:?}"),
4884 }
4885 assert_eq!(recorded.request_count(), 1);
4886 }
4887
4888 #[tokio::test]
4889 async fn tool_choice_none_buffers_args_then_rejects_name_without_emit() {
4890 let model = MockCompletionModel::from_stream_turns([
4891 vec![
4892 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
4893 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4894 MockStreamEvent::final_response_with_total_tokens(4),
4895 ],
4896 vec![
4897 MockStreamEvent::text("should not be requested"),
4898 MockStreamEvent::final_response_with_total_tokens(6),
4899 ],
4900 ]);
4901 let recorded = model.clone();
4902 let agent = AgentBuilder::new(model)
4903 .tool(MockAddTool)
4904 .tool_choice(ToolChoice::None)
4905 .build();
4906
4907 let mut stream = agent
4908 .stream_prompt("do not use tools")
4909 .add_hook(PanicOnUnknownToolHook)
4910 .max_turns(3)
4911 .await;
4912 let mut saw_delta = false;
4913 let mut error = None;
4914
4915 while let Some(item) = stream.next().await {
4916 match item {
4917 Ok(MultiTurnStreamItem::StreamAssistantItem(
4918 StreamedAssistantContent::ToolCallDelta { .. },
4919 )) => {
4920 saw_delta = true;
4921 }
4922 Ok(_) => {}
4923 Err(err) => {
4924 error = Some(err);
4925 break;
4926 }
4927 }
4928 }
4929
4930 assert!(!saw_delta);
4931 let error = error.expect("ToolChoice::None should reject buffered tool-call deltas");
4932 match error {
4933 StreamingError::Prompt(err) => match *err {
4934 PromptError::UnknownToolCall {
4935 tool_name,
4936 available_tools,
4937 allowed_tools,
4938 chat_history,
4939 } => {
4940 assert_eq!(tool_name, "add");
4941 assert_eq!(available_tools, vec!["add".to_string()]);
4942 assert!(allowed_tools.is_empty());
4943 assert!(history_contains_tool_call(&chat_history, "add"));
4944 }
4945 other => panic!("expected UnknownToolCall, got {other:?}"),
4946 },
4947 other => panic!("expected prompt streaming error, got {other:?}"),
4948 }
4949 assert_eq!(recorded.request_count(), 1);
4950 }
4951
4952 #[tokio::test]
4953 async fn stream_prompt_emits_tool_call_deltas_without_hook() {
4954 let model = MockCompletionModel::from_stream_turns([[
4955 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
4956 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
4957 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
4958 MockStreamEvent::final_response_with_total_tokens(3),
4959 ]]);
4960 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
4961
4962 let mut stream = agent.stream_prompt("stream a tool call").await;
4963 let mut deltas = Vec::new();
4964
4965 while let Some(item) = stream.next().await {
4966 match item {
4967 Ok(MultiTurnStreamItem::StreamAssistantItem(
4968 StreamedAssistantContent::ToolCallDelta {
4969 id,
4970 internal_call_id,
4971 content,
4972 },
4973 )) => {
4974 deltas.push((id, internal_call_id, content));
4975 }
4976 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
4977 Ok(_) => {}
4978 Err(err) => panic!("unexpected streaming error: {err:?}"),
4979 }
4980 }
4981
4982 assert_eq!(
4983 deltas,
4984 vec![
4985 (
4986 "tool_1".to_string(),
4987 "internal_1".to_string(),
4988 ToolCallDeltaContent::Name("add".to_string())
4989 ),
4990 (
4991 "tool_1".to_string(),
4992 "internal_1".to_string(),
4993 ToolCallDeltaContent::Delta("{\"x\":".to_string())
4994 ),
4995 (
4996 "tool_1".to_string(),
4997 "internal_1".to_string(),
4998 ToolCallDeltaContent::Delta("1}".to_string())
4999 ),
5000 ]
5001 );
5002 }
5003
5004 #[tokio::test]
5005 async fn stream_prompt_emits_tool_call_deltas_after_hook_continue() {
5006 let model = MockCompletionModel::from_stream_turns([[
5007 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
5008 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
5009 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
5010 MockStreamEvent::final_response_with_total_tokens(3),
5011 ]]);
5012 let hook = RecordingToolCallDeltaHook::default();
5013 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5014
5015 let mut stream = agent
5016 .stream_prompt("stream a tool call")
5017 .add_hook(hook.clone())
5018 .await;
5019 let mut stream_deltas = Vec::new();
5020
5021 while let Some(item) = stream.next().await {
5022 match item {
5023 Ok(MultiTurnStreamItem::StreamAssistantItem(
5024 StreamedAssistantContent::ToolCallDelta {
5025 id,
5026 internal_call_id,
5027 content,
5028 },
5029 )) => {
5030 stream_deltas.push((id, internal_call_id, content));
5031 }
5032 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5033 Ok(_) => {}
5034 Err(err) => panic!("unexpected streaming error: {err:?}"),
5035 }
5036 }
5037
5038 assert_eq!(
5039 hook.observed(),
5040 vec![
5041 (
5042 "tool_1".to_string(),
5043 "internal_1".to_string(),
5044 Some("add".to_string()),
5045 String::new()
5046 ),
5047 (
5048 "tool_1".to_string(),
5049 "internal_1".to_string(),
5050 None,
5051 "{\"x\":".to_string()
5052 ),
5053 (
5054 "tool_1".to_string(),
5055 "internal_1".to_string(),
5056 None,
5057 "1}".to_string()
5058 ),
5059 ]
5060 );
5061 assert_eq!(
5062 stream_deltas,
5063 vec![
5064 (
5065 "tool_1".to_string(),
5066 "internal_1".to_string(),
5067 ToolCallDeltaContent::Name("add".to_string())
5068 ),
5069 (
5070 "tool_1".to_string(),
5071 "internal_1".to_string(),
5072 ToolCallDeltaContent::Delta("{\"x\":".to_string())
5073 ),
5074 (
5075 "tool_1".to_string(),
5076 "internal_1".to_string(),
5077 ToolCallDeltaContent::Delta("1}".to_string())
5078 ),
5079 ]
5080 );
5081 }
5082
5083 #[tokio::test]
5084 async fn stream_prompt_tool_call_deltas_hook_termination_prevents_delta_emit() {
5085 let model = MockCompletionModel::from_stream_turns([[
5086 MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
5087 MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
5088 MockStreamEvent::final_response_with_total_tokens(3),
5089 ]]);
5090 let hook = TerminatingToolCallDeltaHook::default();
5091 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5092
5093 let mut stream = agent
5094 .stream_prompt("stream a tool call")
5095 .add_hook(hook.clone())
5096 .await;
5097 let mut saw_delta = false;
5098 let mut saw_final_response = false;
5099 let mut error_message = None;
5100
5101 while let Some(item) = stream.next().await {
5102 match item {
5103 Ok(MultiTurnStreamItem::StreamAssistantItem(
5104 StreamedAssistantContent::ToolCallDelta { .. },
5105 )) => {
5106 saw_delta = true;
5107 }
5108 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5109 saw_final_response = true;
5110 }
5111 Ok(_) => {}
5112 Err(err) => {
5113 error_message = Some(err.to_string());
5114 break;
5115 }
5116 }
5117 }
5118
5119 assert_eq!(
5120 hook.observed(),
5121 vec![(
5122 "tool_1".to_string(),
5123 "internal_1".to_string(),
5124 Some("add".to_string()),
5125 String::new()
5126 )]
5127 );
5128 assert!(!saw_delta);
5129 assert!(!saw_final_response);
5130 assert!(
5131 error_message
5132 .as_deref()
5133 .is_some_and(|message| message.contains("PromptCancelled: stop on tool call delta")),
5134 "expected hook termination error, got {error_message:?}"
5135 );
5136 }
5137
5138 #[tokio::test]
5139 async fn stream_prompt_exposes_completion_calls() {
5140 let first_call_usage = usage(10, 2);
5141 let second_call_usage = usage(25, 5);
5142 let model = MockCompletionModel::from_stream_turns([
5143 vec![
5144 MockStreamEvent::tool_call(
5145 "tool_call_1",
5146 "add",
5147 serde_json::json!({"x": 1, "y": 2}),
5148 )
5149 .with_call_id("call_1"),
5150 MockStreamEvent::final_response(first_call_usage),
5151 ],
5152 vec![
5153 MockStreamEvent::text("done"),
5154 MockStreamEvent::final_response(second_call_usage),
5155 ],
5156 ]);
5157 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5158 let empty_history: &[Message] = &[];
5159
5160 let mut stream = agent
5161 .stream_prompt("do tool work")
5162 .history(empty_history)
5163 .max_turns(3)
5164 .await;
5165 let mut completion_calls_events = Vec::new();
5166 let mut final_response = None;
5167
5168 while let Some(item) = stream.next().await {
5169 match item {
5170 Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
5171 completion_calls_events.push(call_usage);
5172 }
5173 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5174 final_response = Some(response);
5175 break;
5176 }
5177 Ok(_) => {}
5178 Err(err) => panic!("unexpected streaming error: {err:?}"),
5179 }
5180 }
5181
5182 assert_eq!(
5183 completion_calls_events,
5184 vec![
5185 CompletionCall::new(0, first_call_usage),
5186 CompletionCall::new(1, second_call_usage)
5187 ]
5188 );
5189
5190 let final_response = final_response.expect("expected final response");
5191 assert_eq!(
5192 final_response.usage(),
5193 Usage {
5194 input_tokens: 35,
5195 output_tokens: 7,
5196 total_tokens: 42,
5197 cached_input_tokens: 0,
5198 cache_creation_input_tokens: 0,
5199 tool_use_prompt_tokens: 0,
5200 reasoning_tokens: 0,
5201 }
5202 );
5203 assert_eq!(
5204 final_response.completion_calls(),
5205 &[
5206 CompletionCall::new(0, first_call_usage),
5207 CompletionCall::new(1, second_call_usage)
5208 ]
5209 );
5210 }
5211
5212 #[tokio::test(flavor = "current_thread")]
5213 async fn stream_prompt_records_single_call_usage_on_chat_span_under_outer_span() {
5214 let call_usage = usage(10, 2);
5215 let model = MockCompletionModel::from_stream_turns([[
5216 MockStreamEvent::text("done"),
5217 MockStreamEvent::final_response(call_usage),
5218 ]]);
5219 let agent = AgentBuilder::new(model).build();
5220
5221 assert_stream_usage_recorded_on_chat_spans(agent, "say done", 1, &[call_usage]).await;
5222 }
5223
5224 #[tokio::test(flavor = "current_thread")]
5225 async fn stream_prompt_records_multi_turn_usage_on_chat_spans_under_outer_span() {
5226 let first_call_usage = usage(10, 2);
5227 let second_call_usage = usage(25, 5);
5228 let model = MockCompletionModel::from_stream_turns([
5229 vec![
5230 MockStreamEvent::tool_call(
5231 "tool_call_1",
5232 "add",
5233 serde_json::json!({"x": 1, "y": 2}),
5234 )
5235 .with_call_id("call_1"),
5236 MockStreamEvent::final_response(first_call_usage),
5237 ],
5238 vec![
5239 MockStreamEvent::text("done"),
5240 MockStreamEvent::final_response(second_call_usage),
5241 ],
5242 ]);
5243 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5244
5245 assert_stream_usage_recorded_on_chat_spans(
5246 agent,
5247 "do tool work",
5248 3,
5249 &[first_call_usage, second_call_usage],
5250 )
5251 .await;
5252 }
5253
5254 #[tokio::test]
5255 async fn stream_prompt_emits_completion_call_before_finish_hook_termination() {
5256 let call_usage = usage(10, 2);
5257 let model = MockCompletionModel::from_stream_turns([[
5258 MockStreamEvent::text("done"),
5259 MockStreamEvent::final_response(call_usage),
5260 ]]);
5261 let agent = AgentBuilder::new(model).build();
5262
5263 let mut stream = agent
5264 .stream_prompt("say done")
5265 .add_hook(TerminateOnStreamFinish)
5266 .await;
5267 let mut completion_calls = Vec::new();
5268 let mut saw_error = false;
5269
5270 while let Some(item) = stream.next().await {
5271 match item {
5272 Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
5273 completion_calls.push(completion_call);
5274 }
5275 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5276 panic!("unexpected final response after hook termination: {response:?}");
5277 }
5278 Ok(_) => {}
5279 Err(_) => {
5280 saw_error = true;
5281 break;
5282 }
5283 }
5284 }
5285
5286 assert_eq!(completion_calls, vec![CompletionCall::new(0, call_usage)]);
5287 assert!(saw_error);
5288 }
5289
5290 #[tokio::test]
5291 async fn stream_prompt_completion_calls_records_unreported_usage() {
5292 let second_call_usage = usage(25, 5);
5293 let model = MockCompletionModel::from_stream_turns([
5294 vec![
5295 MockStreamEvent::tool_call(
5296 "tool_call_1",
5297 "add",
5298 serde_json::json!({"x": 1, "y": 2}),
5299 )
5300 .with_call_id("call_1"),
5301 ],
5302 vec![
5303 MockStreamEvent::text("done"),
5304 MockStreamEvent::final_response(second_call_usage),
5305 ],
5306 ]);
5307 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5308 let empty_history: &[Message] = &[];
5309
5310 let mut stream = agent
5311 .stream_prompt("do tool work")
5312 .history(empty_history)
5313 .max_turns(3)
5314 .await;
5315 let mut completion_calls_events = Vec::new();
5316 let mut final_response = None;
5317
5318 while let Some(item) = stream.next().await {
5319 match item {
5320 Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
5321 completion_calls_events.push(call_usage);
5322 }
5323 Ok(MultiTurnStreamItem::FinalResponse(response)) => {
5324 final_response = Some(response);
5325 break;
5326 }
5327 Ok(_) => {}
5328 Err(err) => panic!("unexpected streaming error: {err:?}"),
5329 }
5330 }
5331
5332 let expected_usage = vec![
5333 CompletionCall::new(0, Usage::new()),
5334 CompletionCall::new(1, second_call_usage),
5335 ];
5336 assert_eq!(completion_calls_events, expected_usage);
5337
5338 let final_response = final_response.expect("expected final response");
5339 assert_eq!(final_response.completion_calls(), expected_usage.as_slice());
5340 }
5341
5342 #[tokio::test]
5343 async fn final_response_matches_streamed_text_when_provider_final_is_textless() {
5344 let agent = AgentBuilder::new(streaming_text_then_final_model()).build();
5345
5346 let mut stream = agent.stream_prompt("say hello").await;
5347 let mut streamed_text = String::new();
5348 let mut final_response_text = None;
5349
5350 while let Some(item) = stream.next().await {
5351 match item {
5352 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5353 text,
5354 ))) => streamed_text.push_str(&text.text),
5355 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5356 final_response_text = Some(res.output().to_owned());
5357 break;
5358 }
5359 Ok(_) => {}
5360 Err(err) => panic!("unexpected streaming error: {err:?}"),
5361 }
5362 }
5363
5364 assert_eq!(streamed_text, "hello world");
5365 assert_eq!(final_response_text.as_deref(), Some("hello world"));
5366 }
5367
5368 #[tokio::test]
5369 async fn final_response_preserves_structured_text_metadata() {
5370 let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
5371
5372 let mut stream = agent.stream_prompt("answer with citations").await;
5373 let mut final_response = None;
5374
5375 while let Some(item) = stream.next().await {
5376 match item {
5377 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5378 final_response = Some(res);
5379 break;
5380 }
5381 Ok(_) => {}
5382 Err(err) => panic!("unexpected streaming error: {err:?}"),
5383 }
5384 }
5385
5386 let final_response = final_response.expect("expected final response");
5387 assert_eq!(final_response.output(), "cited answer");
5388 let metadata = text_metadata(final_response.content())
5389 .expect("expected text metadata in final content");
5390 assert_eq!(
5391 metadata["citations"][0]["encrypted_index"],
5392 "encrypted-reference"
5393 );
5394 }
5395
5396 #[tokio::test]
5397 async fn final_response_history_preserves_structured_text_metadata() {
5398 let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
5399
5400 let empty_history: &[Message] = &[];
5401 let mut stream = agent
5402 .stream_prompt("answer with citations")
5403 .history(empty_history)
5404 .await;
5405 let mut final_response = None;
5406
5407 while let Some(item) = stream.next().await {
5408 match item {
5409 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5410 final_response = Some(res);
5411 break;
5412 }
5413 Ok(_) => {}
5414 Err(err) => panic!("unexpected streaming error: {err:?}"),
5415 }
5416 }
5417
5418 let final_response = final_response.expect("expected final response");
5419 let history = final_response
5420 .messages()
5421 .expect("with_history should include final history");
5422 let assistant_content = history
5423 .iter()
5424 .find_map(|message| match message {
5425 Message::Assistant { content, .. } => Some(content),
5426 _ => None,
5427 })
5428 .expect("expected assistant message in history");
5429 let metadata =
5430 text_metadata(assistant_content).expect("expected text metadata in assistant history");
5431 assert_eq!(
5432 metadata["citations"][0]["encrypted_index"],
5433 "encrypted-reference"
5434 );
5435 }
5436
5437 #[tokio::test]
5438 async fn tool_follow_up_history_preserves_structured_text_metadata() {
5439 let model = streaming_cited_text_then_tool_model();
5440 let recorded = model.clone();
5441 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
5442 let empty_history: &[Message] = &[];
5443
5444 let mut stream = agent
5445 .stream_prompt("use a tool with citations")
5446 .history(empty_history)
5447 .max_turns(3)
5448 .await;
5449
5450 while let Some(item) = stream.next().await {
5451 match item {
5452 Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
5453 Ok(_) => {}
5454 Err(err) => panic!("unexpected streaming error: {err:?}"),
5455 }
5456 }
5457
5458 let requests = recorded.requests();
5459 assert_eq!(requests.len(), 2);
5460 let follow_up_history = requests[1].chat_history.iter().collect::<Vec<_>>();
5461 let assistant_content = follow_up_history
5462 .iter()
5463 .find_map(|message| match message {
5464 Message::Assistant { content, .. } => Some(content),
5465 _ => None,
5466 })
5467 .expect("expected assistant message in follow-up history");
5468 let metadata = text_metadata(assistant_content)
5469 .expect("expected citation metadata in follow-up assistant history");
5470 assert_eq!(
5471 metadata["citations"][0]["encrypted_index"],
5472 "encrypted-reference"
5473 );
5474 }
5475
5476 #[tokio::test]
5477 async fn final_response_can_remain_empty_for_truly_textless_turns() {
5478 let agent = AgentBuilder::new(streaming_final_only_model()).build();
5479
5480 let mut stream = agent.stream_prompt("say nothing").await;
5481 let mut streamed_text = String::new();
5482 let mut final_response_text = None;
5483
5484 while let Some(item) = stream.next().await {
5485 match item {
5486 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5487 text,
5488 ))) => streamed_text.push_str(&text.text),
5489 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5490 final_response_text = Some(res.output().to_owned());
5491 break;
5492 }
5493 Ok(_) => {}
5494 Err(err) => panic!("unexpected streaming error: {err:?}"),
5495 }
5496 }
5497
5498 assert!(streamed_text.is_empty());
5499 assert_eq!(final_response_text.as_deref(), Some(""));
5500 }
5501
5502 async fn background_logger(stop: Arc<AtomicBool>, leak_count: Arc<AtomicU32>) {
5505 let mut interval = tokio::time::interval(Duration::from_millis(50));
5506 let mut count = 0u32;
5507
5508 while !stop.load(Ordering::Relaxed) {
5509 interval.tick().await;
5510 count += 1;
5511
5512 tracing::event!(
5513 target: "background_logger",
5514 tracing::Level::INFO,
5515 count = count,
5516 "Background tick"
5517 );
5518
5519 let current = tracing::Span::current();
5521 if !current.is_disabled() && !current.is_none() {
5522 leak_count.fetch_add(1, Ordering::Relaxed);
5523 }
5524 }
5525
5526 tracing::info!(target: "background_logger", total_ticks = count, "Background logger stopped");
5527 }
5528
5529 #[tokio::test(flavor = "current_thread")]
5537 #[ignore = "This requires an API key"]
5538 async fn test_span_context_isolation() -> anyhow::Result<()> {
5539 let stop = Arc::new(AtomicBool::new(false));
5540 let leak_count = Arc::new(AtomicU32::new(0));
5541
5542 let bg_stop = stop.clone();
5544 let bg_leak = leak_count.clone();
5545 let bg_handle = tokio::spawn(async move {
5546 background_logger(bg_stop, bg_leak).await;
5547 });
5548
5549 tokio::time::sleep(Duration::from_millis(100)).await;
5551
5552 let client = anthropic::Client::from_env()?;
5555 let agent = client
5556 .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
5557 .preamble("You are a helpful assistant.")
5558 .temperature(0.1)
5559 .max_tokens(100)
5560 .build();
5561
5562 let mut stream = agent
5563 .stream_prompt("Say 'hello world' and nothing else.")
5564 .await;
5565
5566 let mut full_content = String::new();
5567 while let Some(item) = stream.next().await {
5568 match item {
5569 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5570 text,
5571 ))) => {
5572 full_content.push_str(&text.text);
5573 }
5574 Ok(MultiTurnStreamItem::FinalResponse(_)) => {
5575 break;
5576 }
5577 Err(e) => {
5578 tracing::warn!("Error: {:?}", e);
5579 break;
5580 }
5581 _ => {}
5582 }
5583 }
5584
5585 tracing::info!("Got response: {:?}", full_content);
5586
5587 stop.store(true, Ordering::Relaxed);
5589 bg_handle.await?;
5590
5591 let leaks = leak_count.load(Ordering::Relaxed);
5592 anyhow::ensure!(
5593 leaks == 0,
5594 "SPAN LEAK DETECTED: Background logger was inside unexpected spans {leaks} times. \
5595 This indicates that span.enter() is being used inside async_stream instead of .instrument()"
5596 );
5597
5598 Ok(())
5599 }
5600
5601 #[tokio::test]
5608 #[ignore = "This requires an API key"]
5609 async fn test_chat_history_in_final_response() -> anyhow::Result<()> {
5610 use crate::message::Message;
5611
5612 let client = anthropic::Client::from_env()?;
5613 let agent = client
5614 .agent(anthropic::completion::CLAUDE_HAIKU_4_5)
5615 .preamble("You are a helpful assistant. Keep responses brief.")
5616 .temperature(0.1)
5617 .max_tokens(50)
5618 .build();
5619
5620 let empty_history: &[Message] = &[];
5622 let mut stream = agent
5623 .stream_prompt("Say 'hello' and nothing else.")
5624 .history(empty_history)
5625 .await;
5626
5627 let mut response_text = String::new();
5629 let mut final_history = None;
5630 while let Some(item) = stream.next().await {
5631 match item {
5632 Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
5633 text,
5634 ))) => {
5635 response_text.push_str(&text.text);
5636 }
5637 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5638 final_history = res.messages().map(|h| h.to_vec());
5639 break;
5640 }
5641 Err(e) => {
5642 return Err(e.into());
5643 }
5644 _ => {}
5645 }
5646 }
5647
5648 let history = final_history
5649 .ok_or_else(|| anyhow::anyhow!("final response should include history"))?;
5650
5651 anyhow::ensure!(
5653 history.iter().any(|m| matches!(m, Message::User { .. })),
5654 "History should contain the user message"
5655 );
5656
5657 anyhow::ensure!(
5659 history
5660 .iter()
5661 .any(|m| matches!(m, Message::Assistant { .. })),
5662 "History should contain the assistant response"
5663 );
5664
5665 tracing::info!(
5666 "History after streaming: {} messages, response: {:?}",
5667 history.len(),
5668 response_text
5669 );
5670
5671 Ok(())
5672 }
5673
5674 #[tokio::test]
5675 async fn streaming_appends_to_memory_after_final_response() {
5676 use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5677
5678 let memory = InMemoryConversationMemory::new();
5679 let agent = AgentBuilder::new(streaming_text_then_final_model())
5680 .memory(memory.clone())
5681 .build();
5682
5683 let mut stream = agent
5684 .stream_prompt("hi there")
5685 .conversation("stream-thread")
5686 .await;
5687
5688 let mut history_in_final = None;
5689 while let Some(item) = stream.next().await {
5690 match item {
5691 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5692 history_in_final = res.messages().map(|h| h.to_vec());
5693 break;
5694 }
5695 Ok(_) => {}
5696 Err(err) => panic!("unexpected streaming error: {err:?}"),
5697 }
5698 }
5699
5700 let final_history = history_in_final
5701 .expect("PromptResponse.messages should be populated when memory is configured");
5702 assert_eq!(
5703 final_history.len(),
5704 2,
5705 "user prompt + assistant response in final history: {final_history:?}"
5706 );
5707
5708 let stored = memory.load("stream-thread").await.unwrap();
5709 assert_eq!(stored.len(), 2, "memory should contain user + assistant");
5710 }
5711
5712 #[tokio::test]
5713 async fn streaming_reasoning_without_tools_does_not_duplicate_final_history() {
5714 let agent = AgentBuilder::new(MockCompletionModel::from_stream_turns([[
5715 MockStreamEvent::text("final answer"),
5716 MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
5717 MockStreamEvent::final_response_with_total_tokens(3),
5718 ]]))
5719 .build();
5720
5721 let mut stream = agent
5722 .stream_prompt("think before answering")
5723 .history(Vec::<Message>::new())
5724 .await;
5725
5726 let mut history_in_final = None;
5727 while let Some(item) = stream.next().await {
5728 match item {
5729 Ok(MultiTurnStreamItem::FinalResponse(res)) => {
5730 history_in_final = res.messages().map(|h| h.to_vec());
5731 break;
5732 }
5733 Ok(_) => {}
5734 Err(err) => panic!("unexpected streaming error: {err:?}"),
5735 }
5736 }
5737
5738 let final_history = history_in_final
5739 .expect("PromptResponse.messages should be populated when with_history is used");
5740 assert_eq!(
5741 final_history.len(),
5742 2,
5743 "user prompt + one assistant response in final history: {final_history:?}"
5744 );
5745
5746 assert!(matches!(
5747 final_history.first(),
5748 Some(Message::User { content })
5749 if matches!(
5750 content.first(),
5751 UserContent::Text(text) if text.text == "think before answering"
5752 )
5753 ));
5754
5755 let assistant_messages = final_history
5756 .iter()
5757 .filter_map(|message| match message {
5758 Message::Assistant { content, .. } => Some(content),
5759 _ => None,
5760 })
5761 .collect::<Vec<_>>();
5762 assert_eq!(
5763 assistant_messages.len(),
5764 1,
5765 "reasoning turn should produce exactly one assistant history message: {final_history:?}"
5766 );
5767 let assistant_content = assistant_messages
5768 .first()
5769 .expect("expected assistant history message");
5770 assert!(assistant_content.iter().any(|item| matches!(
5771 item,
5772 AssistantContent::Text(text) if text.text == "final answer"
5773 )));
5774 assert!(assistant_content.iter().any(|item| matches!(
5775 item,
5776 AssistantContent::Reasoning(reasoning)
5777 if reasoning.id.as_deref() == Some("rs_1")
5778 && reasoning.content.iter().any(|content| matches!(
5779 content,
5780 ReasoningContent::Text { text, .. } if text == "reasoned step"
5781 ))
5782 )));
5783 let reasoning_index = assistant_content
5784 .iter()
5785 .position(|item| matches!(item, AssistantContent::Reasoning(_)))
5786 .expect("assistant history should contain reasoning");
5787 let text_index = assistant_content
5788 .iter()
5789 .position(|item| matches!(item, AssistantContent::Text(_)))
5790 .expect("assistant history should contain text");
5791 assert!(
5792 reasoning_index < text_index,
5793 "assistant reasoning must be stored before assistant text: {assistant_content:?}"
5794 );
5795 }
5796
5797 #[tokio::test]
5798 async fn streaming_with_history_overrides_memory() {
5799 use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5800
5801 let memory = InMemoryConversationMemory::new();
5802 memory
5803 .append("t1", vec![Message::user("from-memory")])
5804 .await
5805 .unwrap();
5806
5807 let agent = AgentBuilder::new(streaming_text_then_final_model())
5808 .memory(memory.clone())
5809 .build();
5810
5811 let mut stream = agent
5812 .stream_prompt("hi")
5813 .conversation("t1")
5814 .history(vec![Message::user("from-caller")])
5815 .await;
5816
5817 while let Some(item) = stream.next().await {
5818 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5819 break;
5820 }
5821 }
5822
5823 let stored = memory.load("t1").await.unwrap();
5824 assert_eq!(
5825 stored.len(),
5826 1,
5827 "with_history bypasses memory; only the pre-seeded entry remains: {stored:?}"
5828 );
5829 }
5830
5831 #[tokio::test]
5832 async fn streaming_without_memory_disables_for_request() {
5833 use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5834
5835 let memory = InMemoryConversationMemory::new();
5836 let agent = AgentBuilder::new(streaming_text_then_final_model())
5837 .memory(memory.clone())
5838 .conversation("default")
5839 .build();
5840
5841 let mut stream = agent.stream_prompt("hi").without_memory().await;
5842
5843 while let Some(item) = stream.next().await {
5844 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5845 break;
5846 }
5847 }
5848
5849 let stored = memory.load("default").await.unwrap();
5850 assert!(stored.is_empty(), "without_memory disables save");
5851 }
5852
5853 #[tokio::test]
5854 async fn streaming_load_error_yields_memory_error() {
5855 let agent = AgentBuilder::new(streaming_text_then_final_model())
5856 .memory(FailingMemory::default())
5857 .build();
5858
5859 let mut stream = agent.stream_prompt("hi").conversation("t1").await;
5860
5861 let first = stream.next().await.expect("at least one item");
5862 match first {
5863 Err(err) => {
5864 let msg = format!("{err:?}");
5865 assert!(
5866 msg.contains("Memory") || msg.contains("memory") || msg.contains("load boom"),
5867 "expected memory error, got: {msg}"
5868 );
5869 }
5870 Ok(other) => panic!("expected memory error, got {other:?}"),
5871 }
5872 }
5873
5874 #[tokio::test]
5875 async fn streaming_with_filter_shapes_loaded_history() {
5876 use crate::memory::{ConversationMemory, InMemoryConversationMemory};
5877
5878 let memory = InMemoryConversationMemory::new()
5879 .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
5880 memory
5881 .append(
5882 "t1",
5883 vec![
5884 Message::user("1"),
5885 Message::assistant("2"),
5886 Message::user("3"),
5887 Message::assistant("4"),
5888 ],
5889 )
5890 .await
5891 .unwrap();
5892
5893 let model = MockCompletionModel::from_stream_turns([[
5894 MockStreamEvent::text("ok"),
5895 MockStreamEvent::final_response_with_total_tokens(1),
5896 ]]);
5897 let recorded = model.clone();
5898 let agent = AgentBuilder::new(model).memory(memory).build();
5899
5900 let mut stream = agent.stream_prompt("ping").conversation("t1").await;
5901 while let Some(item) = stream.next().await {
5902 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5903 break;
5904 }
5905 }
5906
5907 let received = recorded.requests()[0]
5908 .chat_history
5909 .iter()
5910 .cloned()
5911 .collect::<Vec<_>>();
5912 assert_eq!(
5913 received.len(),
5914 3,
5915 "window-truncated history (2) + current prompt: {received:?}"
5916 );
5917 }
5918
5919 #[tokio::test]
5920 async fn streaming_append_error_does_not_suppress_final_response() {
5921 let agent = AgentBuilder::new(streaming_text_then_final_model())
5922 .memory(AppendFailingMemory::default())
5923 .build();
5924
5925 let mut stream = agent.stream_prompt("hi").conversation("t1").await;
5926
5927 let mut saw_final = false;
5928 while let Some(item) = stream.next().await {
5929 if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
5930 saw_final = true;
5931 break;
5932 }
5933 }
5934 assert!(
5935 saw_final,
5936 "FinalResponse must be yielded even when memory.append fails"
5937 );
5938 }
5939}