1use std::sync::{
29 Arc,
30 atomic::{AtomicU64, Ordering},
31};
32
33use futures::StreamExt;
34use tracing::{Instrument, info_span, span::Id};
35
36use super::{
37 completion::{Agent, DynamicContextStore, PreparedCompletionRequest},
38 hook::{
39 AgentHook, Flow, HookContext, HookStack, InvalidToolCallHookAction, RequestPatch, StepEvent,
40 },
41 prompt_request::{
42 PromptResponse,
43 streaming::{
44 DriveItem, DriveStream, MultiTurnStreamItem, StreamingError, TurnSource, drive_agent,
45 drive_tool_calls, record_usage_on_span, streaming_error_into_prompt,
46 },
47 tool_result_message, tool_result_output,
48 },
49 run::{
50 AgentRun, DEFAULT_OUTPUT_RETRIES, ModelTurn, ModelTurnOutcome, OutputMode, PendingToolCall,
51 },
52};
53use crate::{
54 completion::{CompletionError, CompletionModel, Document, Message, PromptError},
55 json_utils,
56 memory::ConversationMemory,
57 message::{ToolCall, ToolChoice, UserContent},
58 tool::{ToolCallExtensions, ToolExecutionResult, ToolOutcome, server::ToolServerHandle},
59};
60
61use super::UNKNOWN_AGENT_NAME;
62
63macro_rules! build_chat_span {
72 ($runner:expr, $effective_preamble:expr, $name:literal) => {
73 ::tracing::info_span!(
74 target: "rig::agent_chat",
75 parent: ::tracing::Span::current(),
76 $name,
77 gen_ai.operation.name = "chat",
78 gen_ai.agent.name = $runner.agent_name_or_default(),
79 gen_ai.system_instructions = $effective_preamble,
80 gen_ai.provider.name = ::tracing::field::Empty,
81 gen_ai.request.model = ::tracing::field::Empty,
82 gen_ai.response.id = ::tracing::field::Empty,
83 gen_ai.response.model = ::tracing::field::Empty,
84 gen_ai.usage.output_tokens = ::tracing::field::Empty,
85 gen_ai.usage.input_tokens = ::tracing::field::Empty,
86 gen_ai.usage.cache_read.input_tokens = ::tracing::field::Empty,
87 gen_ai.usage.cache_creation.input_tokens = ::tracing::field::Empty,
88 gen_ai.usage.tool_use_prompt_tokens = ::tracing::field::Empty,
89 gen_ai.usage.reasoning_tokens = ::tracing::field::Empty,
90 gen_ai.input.messages = ::tracing::field::Empty,
91 gen_ai.output.messages = ::tracing::field::Empty,
92 )
93 };
94}
95pub(crate) use build_chat_span;
96
97fn flow_name(flow: &Flow) -> &'static str {
99 match flow {
100 Flow::Continue => "Continue",
101 Flow::Terminate { .. } => "Terminate",
102 Flow::Skip { .. } => "Skip",
103 Flow::RewriteArgs { .. } => "RewriteArgs",
104 Flow::RewriteResult { .. } => "RewriteResult",
105 Flow::PatchRequest { .. } => "PatchRequest",
106 Flow::Fail => "Fail",
107 Flow::Retry { .. } => "Retry",
108 Flow::Repair { .. } => "Repair",
109 }
110}
111
112pub(crate) fn observe_flow(flow: Flow) -> Option<String> {
120 match flow {
121 Flow::Continue => None,
122 Flow::Terminate { reason } => Some(reason),
123 other => Some(format!(
124 "hook returned `{}` for an observe-only event, which only honors \
125 Continue/Terminate — terminating the run (fail-closed)",
126 flow_name(&other)
127 )),
128 }
129}
130
131pub(crate) enum ToolCallDecision {
133 Proceed,
135 ProceedWith(serde_json::Value),
138 Skip(String),
140 Terminate(String),
142}
143
144pub(crate) fn flow_into_tool_call(flow: Flow) -> ToolCallDecision {
149 match flow {
150 Flow::Continue => ToolCallDecision::Proceed,
151 Flow::RewriteArgs { args } => ToolCallDecision::ProceedWith(args),
152 Flow::Skip { reason } => ToolCallDecision::Skip(reason),
153 Flow::Terminate { reason } => ToolCallDecision::Terminate(reason),
154 other => ToolCallDecision::Terminate(format!(
155 "hook returned `{}` for a tool-call event, which only honors \
156 Continue/RewriteArgs/Skip/Terminate — terminating the run (fail-closed) \
157 rather than executing the tool",
158 flow_name(&other)
159 )),
160 }
161}
162
163pub(crate) enum ToolResultDecision {
165 Keep,
167 Replace(String),
169 Terminate(String),
171}
172
173pub(crate) fn flow_into_tool_result(flow: Flow) -> ToolResultDecision {
177 match flow {
178 Flow::Continue => ToolResultDecision::Keep,
179 Flow::RewriteResult { result } => ToolResultDecision::Replace(result),
180 Flow::Terminate { reason } => ToolResultDecision::Terminate(reason),
181 other => ToolResultDecision::Terminate(format!(
182 "hook returned `{}` for a tool-result event, which only honors \
183 Continue/RewriteResult/Terminate — terminating the run (fail-closed)",
184 flow_name(&other)
185 )),
186 }
187}
188
189pub(crate) enum CompletionCallDecision {
191 Proceed,
193 Patch(RequestPatch),
196 Terminate(String),
198}
199
200pub(crate) fn flow_into_completion_call(flow: Flow) -> CompletionCallDecision {
205 match flow {
206 Flow::Continue => CompletionCallDecision::Proceed,
207 Flow::PatchRequest { patch } => CompletionCallDecision::Patch(patch),
208 Flow::Terminate { reason } => CompletionCallDecision::Terminate(reason),
209 other => CompletionCallDecision::Terminate(format!(
210 "hook returned `{}` for a completion-call event, which only honors \
211 Continue/PatchRequest/Terminate — terminating the run (fail-closed)",
212 flow_name(&other)
213 )),
214 }
215}
216
217pub(crate) enum InvalidDecision {
219 Terminate(String),
221 Action(InvalidToolCallHookAction),
223}
224
225pub(crate) fn flow_into_invalid(flow: Flow) -> InvalidDecision {
229 match flow {
230 Flow::Terminate { reason } => InvalidDecision::Terminate(reason),
231 Flow::Retry { feedback } => {
232 InvalidDecision::Action(InvalidToolCallHookAction::retry(feedback))
233 }
234 Flow::Repair { tool_name } => {
235 InvalidDecision::Action(InvalidToolCallHookAction::repair(tool_name))
236 }
237 Flow::Skip { reason } => InvalidDecision::Action(InvalidToolCallHookAction::skip(reason)),
238 Flow::Continue | Flow::Fail => InvalidDecision::Action(InvalidToolCallHookAction::fail()),
240 other @ (Flow::RewriteArgs { .. }
244 | Flow::RewriteResult { .. }
245 | Flow::PatchRequest { .. }) => InvalidDecision::Terminate(format!(
246 "hook returned `{}` for an invalid tool-call event, which only \
247 honors Fail/Retry/Repair/Skip/Terminate — terminating the run \
248 (fail-closed)",
249 flow_name(&other)
250 )),
251 }
252}
253
254#[non_exhaustive]
265pub struct AgentRunner<M>
266where
267 M: CompletionModel,
268{
269 pub(crate) prompt: Message,
270 pub(crate) chat_history: Option<Vec<Message>>,
271 pub(crate) max_turns: usize,
272 pub(crate) max_invalid_tool_call_retries: usize,
273 pub(crate) model: Arc<M>,
274 pub(crate) agent_name: Option<String>,
275 pub(crate) preamble: Option<String>,
276 pub(crate) static_context: Vec<Document>,
277 pub(crate) temperature: Option<f64>,
278 pub(crate) max_tokens: Option<u64>,
279 pub(crate) additional_params: Option<serde_json::Value>,
280 pub(crate) tool_server_handle: ToolServerHandle,
281 pub(crate) tool_extensions: ToolCallExtensions,
286 pub(crate) dynamic_context: DynamicContextStore,
287 pub(crate) tool_choice: Option<ToolChoice>,
288 pub(crate) output_schema: Option<schemars::Schema>,
289 pub(crate) output_mode: OutputMode,
290 pub(crate) concurrency: usize,
291 pub(crate) memory: Option<Arc<dyn ConversationMemory>>,
292 pub(crate) conversation_id: Option<String>,
293 pub(crate) hooks: HookStack<M>,
294}
295
296impl<M> AgentRunner<M>
297where
298 M: CompletionModel,
299{
300 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
303 Self {
304 prompt: prompt.into(),
305 chat_history: None,
306 max_turns: agent.default_max_turns.unwrap_or(1),
307 max_invalid_tool_call_retries: 0,
308 model: agent.model.clone(),
309 agent_name: agent.name.clone(),
310 preamble: agent.preamble.clone(),
311 static_context: agent.static_context.clone(),
312 temperature: agent.temperature,
313 max_tokens: agent.max_tokens,
314 additional_params: agent.additional_params.clone(),
315 tool_server_handle: agent.tool_server_handle.clone(),
316 tool_extensions: ToolCallExtensions::new(),
317 dynamic_context: agent.dynamic_context.clone(),
318 tool_choice: agent.tool_choice.clone(),
319 output_schema: agent.output_schema.clone(),
320 output_mode: agent.output_mode.clone(),
321 concurrency: 1,
322 memory: agent.memory.clone(),
323 conversation_id: agent.default_conversation_id.clone(),
324 hooks: agent.hooks.clone(),
325 }
326 }
327
328 pub fn add_hook<H>(mut self, hook: H) -> Self
335 where
336 H: AgentHook<M> + 'static,
337 {
338 self.hooks.push(hook);
339 self
340 }
341}
342
343impl<M> AgentRunner<M>
344where
345 M: CompletionModel,
346{
347 pub fn max_turns(mut self, max_turns: usize) -> Self {
351 self.max_turns = max_turns;
352 self
353 }
354
355 pub fn tool_extensions(mut self, extensions: ToolCallExtensions) -> Self {
362 self.tool_extensions = extensions;
363 self
364 }
365
366 pub fn history<I, T>(mut self, history: I) -> Self
369 where
370 I: IntoIterator<Item = T>,
371 T: Into<Message>,
372 {
373 self.chat_history = Some(history.into_iter().map(Into::into).collect());
374 self
375 }
376
377 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
398 self.concurrency = concurrency.max(1);
399 self
400 }
401
402 pub fn conversation(mut self, id: impl Into<String>) -> Self {
404 self.conversation_id = Some(id.into());
405 self
406 }
407
408 pub fn without_memory(mut self) -> Self {
410 self.memory = None;
411 self.conversation_id = None;
412 self
413 }
414
415 pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
418 self.max_invalid_tool_call_retries = retries;
419 self
420 }
421
422 pub(crate) fn agent_name_or_default(&self) -> &str {
423 self.agent_name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
424 }
425
426 pub(crate) fn build_run(&self, history_override: Option<Vec<Message>>) -> AgentRun {
431 build_agent_run(
432 self.prompt.clone(),
433 self.max_turns,
434 self.max_invalid_tool_call_retries,
435 self.output_schema.as_ref(),
436 history_override.or_else(|| self.chat_history.clone()),
437 self.tool_choice.clone(),
438 )
439 }
440}
441
442pub(crate) fn build_agent_run(
446 prompt: Message,
447 max_turns: usize,
448 max_invalid_tool_call_retries: usize,
449 output_schema: Option<&schemars::Schema>,
450 history: Option<Vec<Message>>,
451 tool_choice: Option<ToolChoice>,
452) -> AgentRun {
453 let mut run = AgentRun::new(prompt)
454 .max_turns(max_turns)
455 .max_invalid_tool_call_retries(max_invalid_tool_call_retries)
456 .with_output_validation(
457 output_schema.map(|schema| schema.as_value().clone()),
458 DEFAULT_OUTPUT_RETRIES,
459 );
460 if let Some(history) = history {
461 run = run.with_history(history);
462 }
463 if let Some(tool_choice) = tool_choice {
464 run = run.with_tool_choice(tool_choice);
465 }
466 run
467}
468
469pub(crate) fn acquire_agent_span(
477 agent_name: &str,
478 preamble: Option<&str>,
479) -> (tracing::Span, bool) {
480 if tracing::Span::current().is_disabled() {
481 let span = info_span!(
482 "invoke_agent",
483 gen_ai.operation.name = "invoke_agent",
484 gen_ai.agent.name = agent_name,
485 gen_ai.system_instructions = preamble,
486 gen_ai.prompt = tracing::field::Empty,
487 gen_ai.completion = tracing::field::Empty,
488 gen_ai.usage.input_tokens = tracing::field::Empty,
489 gen_ai.usage.output_tokens = tracing::field::Empty,
490 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
491 gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
492 gen_ai.usage.tool_use_prompt_tokens = tracing::field::Empty,
493 gen_ai.usage.reasoning_tokens = tracing::field::Empty,
494 );
495 (span, true)
496 } else {
497 (tracing::Span::current(), false)
498 }
499}
500
501pub(crate) enum CompletionCallOutcome {
503 Proceed(Option<RequestPatch>),
506 Terminate(String),
508}
509
510pub(crate) async fn resolve_completion_call<M>(
516 hooks: &HookStack<M>,
517 ctx: &HookContext,
518 prompt: &Message,
519 history: &[Message],
520 turn: usize,
521) -> CompletionCallOutcome
522where
523 M: CompletionModel,
524{
525 match flow_into_completion_call(
526 hooks
527 .on_event(
528 ctx,
529 StepEvent::CompletionCall {
530 prompt,
531 history,
532 turn,
533 },
534 )
535 .await,
536 ) {
537 CompletionCallDecision::Terminate(reason) => CompletionCallOutcome::Terminate(reason),
538 CompletionCallDecision::Patch(patch) => CompletionCallOutcome::Proceed(Some(patch)),
539 CompletionCallDecision::Proceed => CompletionCallOutcome::Proceed(None),
540 }
541}
542
543pub(crate) async fn append_run_messages(
546 memory_handle: Option<&(Arc<dyn ConversationMemory>, String)>,
547 messages: &[Message],
548) {
549 if let Some((memory, id)) = memory_handle
552 && let Err(err) = memory.append(id, messages.to_vec()).await
553 {
554 tracing::warn!(
555 error = %err,
556 conversation_id = %id,
557 "conversation memory append failed; surfacing final response anyway"
558 );
559 }
560}
561
562pub(crate) enum ToolExecution {
564 Executed(Box<ToolCall>),
571 Skipped,
575}
576
577pub(crate) struct ToolCallOutcome {
580 pub content: UserContent,
583 pub execution: ToolExecution,
585}
586
587pub(crate) async fn run_single_tool<M>(
596 hooks: &HookStack<M>,
597 ctx: &HookContext,
598 tool_server: &ToolServerHandle,
599 tool_extensions: &ToolCallExtensions,
600 tool_call: &ToolCall,
601 internal_call_id: &str,
602 error_history: &[Message],
603) -> Result<ToolCallOutcome, PromptError>
604where
605 M: CompletionModel,
606{
607 let tool_name = &tool_call.function.name;
608 let mut args = json_utils::value_to_json_string(&tool_call.function.arguments);
611
612 let tool_span = tracing::Span::current();
613 tool_span.record("gen_ai.tool.name", tool_name);
614 tool_span.record("gen_ai.tool.call.id", &tool_call.id);
615 tool_span.record("gen_ai.tool.call.arguments", &args);
616
617 let (flow, salvaged_rewrite) = hooks
624 .resolve_tool_call(
625 ctx,
626 tool_name,
627 tool_call.call_id.as_deref(),
628 internal_call_id,
629 &args,
630 )
631 .await;
632
633 if let Some(rewritten) = salvaged_rewrite.as_ref() {
636 args = json_utils::value_to_json_string(rewritten);
637 tool_span.record("gen_ai.tool.call.arguments", &args);
638 tracing::debug!(
639 tool_name = tool_name,
640 "tool-call arguments rewritten by a hook"
641 );
642 }
643
644 let mut skipped: Option<ToolExecutionResult> = None;
650 let effective_args: serde_json::Value = match flow_into_tool_call(flow) {
651 ToolCallDecision::Terminate(reason) => {
652 return Err(PromptError::prompt_cancelled(
653 error_history.to_vec(),
654 reason,
655 ));
656 }
657 ToolCallDecision::Skip(reason) => {
658 tracing::info!(tool_name = tool_name, reason = reason, "Tool call rejected");
659 skipped = Some(ToolExecutionResult::skipped(reason));
662 salvaged_rewrite.unwrap_or_else(|| tool_call.function.arguments.clone())
665 }
666 ToolCallDecision::ProceedWith(replacement) => {
667 args = json_utils::value_to_json_string(&replacement);
671 tool_span.record("gen_ai.tool.call.arguments", &args);
672 tracing::debug!(
673 tool_name = tool_name,
674 "tool-call arguments rewritten by a hook"
675 );
676 replacement
677 }
678 ToolCallDecision::Proceed => tool_call.function.arguments.clone(),
679 };
680
681 let (exec, execution) = match skipped {
685 Some(exec) => (exec, ToolExecution::Skipped),
686 None => {
687 let mut effective_tool_call = tool_call.clone();
688 effective_tool_call.function.arguments = effective_args;
689 let exec = tool_server
690 .call_tool_structured(tool_name, &args, tool_extensions)
691 .await;
692 (exec, ToolExecution::Executed(Box::new(effective_tool_call)))
693 }
694 };
695 let synthetic = matches!(execution, ToolExecution::Skipped);
698
699 match flow_into_tool_result(
709 hooks
710 .on_event(
711 ctx,
712 StepEvent::ToolResult {
713 tool_name,
714 tool_call_id: tool_call.call_id.as_deref(),
715 internal_call_id,
716 args: &args,
719 result: &exec.model_output,
720 outcome: &exec.outcome,
721 extensions: &exec.extensions,
722 },
723 )
724 .await,
725 ) {
726 ToolResultDecision::Terminate(reason) => {
727 tracing::info!("tool {tool_name} with args {args}; run terminated by a result hook");
731 Err(PromptError::prompt_cancelled(
732 error_history.to_vec(),
733 reason,
734 ))
735 }
736 ToolResultDecision::Replace(replacement) => {
737 record_tool_outcome(&tool_span, &exec.outcome);
745 tool_span.record("gen_ai.tool.call.result", &replacement);
746 tracing::info!("tool {tool_name} with args {args}; result rewritten by a hook");
747 Ok(ToolCallOutcome {
748 content: tool_result_message(
749 tool_call.id.clone(),
750 tool_call.call_id.clone(),
751 replacement,
752 ),
753 execution,
754 })
755 }
756 ToolResultDecision::Keep => {
757 record_tool_outcome(&tool_span, &exec.outcome);
760 tool_span.record("gen_ai.tool.call.result", &exec.model_output);
761 if synthetic {
762 tracing::info!(
763 "tool {tool_name} skipped by a hook; result: {}",
764 exec.model_output
765 );
766 } else {
767 tracing::info!(
768 "executed tool {tool_name} with args {args}. outcome: {}; result: {}",
769 exec.outcome.as_str(),
770 exec.model_output
771 );
772 }
773 let content = if synthetic {
774 tool_result_message(
775 tool_call.id.clone(),
776 tool_call.call_id.clone(),
777 exec.model_output,
778 )
779 } else {
780 tool_result_output(
781 tool_call.id.clone(),
782 tool_call.call_id.clone(),
783 exec.model_output,
784 )
785 };
786 Ok(ToolCallOutcome { content, execution })
787 }
788 }
789}
790
791fn record_tool_outcome(span: &tracing::Span, outcome: &ToolOutcome) {
797 span.record("gen_ai.tool.call.outcome", outcome.as_str());
798 if let ToolOutcome::Error(failure) = outcome {
799 span.record("gen_ai.tool.error.type", failure.kind.as_str());
800 }
801}
802
803pub(crate) fn new_execute_tool_span() -> tracing::Span {
809 info_span!(
810 "execute_tool",
811 gen_ai.operation.name = "execute_tool",
812 gen_ai.tool.type = "function",
813 gen_ai.tool.name = tracing::field::Empty,
814 gen_ai.tool.call.id = tracing::field::Empty,
815 gen_ai.tool.call.arguments = tracing::field::Empty,
816 gen_ai.tool.call.result = tracing::field::Empty,
817 gen_ai.tool.call.outcome = tracing::field::Empty,
818 gen_ai.tool.error.type = tracing::field::Empty
819 )
820}
821
822pub(crate) struct UnaryTurnSource {
828 current_span_id: AtomicU64,
837}
838
839impl UnaryTurnSource {
840 pub(crate) fn new() -> Self {
841 Self {
842 current_span_id: AtomicU64::new(0),
843 }
844 }
845
846 fn chain_span(&self, span: tracing::Span) -> tracing::Span {
849 let span = match self.current_span_id.load(Ordering::Relaxed) {
850 0 => span,
851 id => span.follows_from(Id::from_u64(id)).to_owned(),
852 };
853 if let Some(id) = span.id() {
854 self.current_span_id.store(id.into_u64(), Ordering::Relaxed);
855 }
856 span
857 }
858}
859
860impl<M> TurnSource<M> for UnaryTurnSource
861where
862 M: CompletionModel,
863{
864 type Raw = M::Response;
865
866 fn open_chat_span(
867 &self,
868 runner: &AgentRunner<M>,
869 effective_preamble: Option<&str>,
870 ) -> tracing::Span {
871 let chat_span = build_chat_span!(runner, effective_preamble, "chat");
872 self.chain_span(chat_span)
873 }
874
875 fn run_model_turn<'a>(
876 &'a mut self,
877 runner: &'a AgentRunner<M>,
878 hook_ctx: &'a HookContext,
879 run: &'a mut AgentRun,
880 prepared: PreparedCompletionRequest<M>,
881 chat_span: tracing::Span,
882 _agent_span: &'a tracing::Span,
883 current_prompt: Message,
884 ) -> DriveStream<'a, M::Response> {
885 Box::pin(async_stream::stream! {
886 let resp = match prepared.builder.send().instrument(chat_span.clone()).await {
887 Ok(resp) => resp,
888 Err(err) => {
889 yield Err(StreamingError::from(err));
890 return;
891 }
892 };
893
894 let mut outcome = match run.model_response(ModelTurn::new(
895 resp.message_id.clone(),
896 resp.choice.clone(),
897 resp.usage,
898 prepared.executable_tool_names,
899 prepared.allowed_tool_names,
900 )) {
901 Ok(outcome) => outcome,
902 Err(err) => {
903 yield Err(Box::new(err).into());
904 return;
905 }
906 };
907
908 loop {
909 match outcome {
910 ModelTurnOutcome::NeedsResolution(context) => {
911 let flow = runner
912 .hooks
913 .on_event(hook_ctx, StepEvent::InvalidToolCall(&context))
914 .await;
915 match flow_into_invalid(flow) {
916 InvalidDecision::Terminate(reason) => {
917 yield Err(StreamingError::Prompt(Box::new(
918 run.cancel_error(reason),
919 )));
920 return;
921 }
922 InvalidDecision::Action(action) => {
923 outcome = match run.resolve_invalid_tool_call(action) {
924 Ok(outcome) => outcome,
925 Err(err) => {
926 yield Err(Box::new(err).into());
927 return;
928 }
929 };
930 }
931 }
932 }
933 ModelTurnOutcome::TurnRetried => break,
934 ModelTurnOutcome::Continue {
935 response_hook_suppressed,
936 } => {
937 if !response_hook_suppressed {
938 if let Some(reason) = observe_flow(
942 runner
943 .hooks
944 .on_event(hook_ctx, StepEvent::CompletionResponse {
945 prompt: ¤t_prompt,
946 response: &resp,
947 })
948 .await,
949 ) {
950 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
951 return;
952 }
953 if let Some(reason) = observe_flow(
954 runner
955 .hooks
956 .on_event(hook_ctx, StepEvent::ModelTurnFinished {
957 turn: hook_ctx.turn(),
958 content: &resp.choice,
959 usage: resp.usage,
960 })
961 .await,
962 ) {
963 yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
964 return;
965 }
966 }
967 break;
968 }
969 }
970 }
971 })
972 }
973
974 fn run_tool_calls<'a>(
975 &'a self,
976 runner: &'a AgentRunner<M>,
977 hook_ctx: &'a HookContext,
978 run: &'a mut AgentRun,
979 calls: Vec<PendingToolCall>,
980 ) -> DriveStream<'a, M::Response> {
981 drive_tool_calls(
985 runner,
986 hook_ctx,
987 run,
988 calls,
989 |span| self.chain_span(span),
990 false,
991 )
992 }
993
994 fn record_run_level_telemetry(
995 &self,
996 agent_span: &tracing::Span,
997 response: &PromptResponse,
998 created_agent_span: bool,
999 ) {
1000 if created_agent_span {
1005 agent_span.record("gen_ai.completion", &response.output);
1006 record_usage_on_span(agent_span, response.usage);
1007 }
1008 }
1009
1010 fn final_item(&self, _response: &PromptResponse) -> Option<MultiTurnStreamItem<M::Response>> {
1011 None
1014 }
1015}
1016
1017impl<M> AgentRunner<M>
1018where
1019 M: CompletionModel,
1020{
1021 pub async fn run(self) -> Result<PromptResponse, PromptError> {
1025 let (agent_span, created_agent_span) =
1026 acquire_agent_span(self.agent_name_or_default(), self.preamble.as_deref());
1027
1028 if let Some(text) = self.prompt.rag_text() {
1029 agent_span.record("gen_ai.prompt", text);
1030 }
1031
1032 let (history_override, memory_handle) = match &self.chat_history {
1036 Some(_) => (None, None),
1037 None => match (&self.memory, &self.conversation_id) {
1038 (Some(memory), Some(id)) => {
1039 let loaded = memory.load(id).await?;
1040 (Some(loaded), Some((memory.clone(), id.clone())))
1041 }
1042 _ => (None, None),
1043 },
1044 };
1045
1046 let run = self.build_run(history_override);
1047
1048 let driver = drive_agent(
1054 self,
1055 UnaryTurnSource::new(),
1056 run,
1057 agent_span,
1058 created_agent_span,
1059 memory_handle,
1060 false,
1061 );
1062 futures::pin_mut!(driver);
1063
1064 let mut response = None;
1065 while let Some(item) = driver.next().await {
1066 match item {
1067 Ok(DriveItem::Done(done)) => response = Some(*done),
1068 Ok(DriveItem::Item(_)) => {}
1069 Err(err) => return Err(streaming_error_into_prompt(err)),
1070 }
1071 }
1072
1073 response.ok_or_else(|| {
1075 PromptError::CompletionError(CompletionError::ResponseError(
1076 "agent run ended without producing a final response".to_string(),
1077 ))
1078 })
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use std::sync::{
1085 Arc, Mutex,
1086 atomic::{AtomicU32, Ordering::SeqCst},
1087 };
1088
1089 use futures::StreamExt;
1090 use serde_json::json;
1091
1092 use crate::agent::AgentBuilder;
1093 use crate::agent::hook::{
1094 AgentHook, Flow, HookContext, RequestPatch, StepEvent, StepEventKind,
1095 };
1096 use crate::agent::prompt_request::streaming::{MultiTurnStreamItem, StreamingError};
1097 use crate::agent::run::OutputMode;
1098 use crate::completion::{CompletionModel, Message, Prompt, PromptError};
1099 use crate::message::{AssistantContent, ToolCall, ToolChoice, ToolFunction, UserContent};
1100 use crate::streaming::{StreamedAssistantContent, StreamedUserContent, StreamingPrompt};
1101 use crate::test_utils::{
1102 MockAddTool, MockBarrierTool, MockCompletionModel, MockOperationArgs, MockStreamEvent,
1103 MockSubtractTool, MockToolError, MockTurn,
1104 };
1105 use crate::tool::Tool;
1106
1107 #[derive(Clone, Default)]
1110 struct RecordingHook {
1111 events: Arc<Mutex<Vec<StepEventKind>>>,
1112 tool_results: Arc<Mutex<Vec<String>>>,
1113 }
1114
1115 impl RecordingHook {
1116 fn shared_events(&self) -> Vec<StepEventKind> {
1120 self.events
1121 .lock()
1122 .expect("events lock")
1123 .iter()
1124 .copied()
1125 .filter(|kind| {
1126 matches!(
1127 kind,
1128 StepEventKind::CompletionCall
1129 | StepEventKind::ToolCall
1130 | StepEventKind::ToolResult
1131 | StepEventKind::InvalidToolCall
1132 )
1133 })
1134 .collect()
1135 }
1136
1137 fn tool_results(&self) -> Vec<String> {
1138 self.tool_results.lock().expect("results lock").clone()
1139 }
1140
1141 fn count(&self, kind: StepEventKind) -> usize {
1144 self.events
1145 .lock()
1146 .expect("events lock")
1147 .iter()
1148 .filter(|recorded| **recorded == kind)
1149 .count()
1150 }
1151 }
1152
1153 impl<M: CompletionModel> AgentHook<M> for RecordingHook {
1154 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1155 self.events.lock().expect("events lock").push(event.kind());
1156 if let StepEvent::ToolResult { result, .. } = event {
1157 self.tool_results
1158 .lock()
1159 .expect("results lock")
1160 .push(result.to_string());
1161 }
1162 Flow::cont()
1163 }
1164 }
1165
1166 fn blocking_model() -> MockCompletionModel {
1167 MockCompletionModel::from_turns([
1168 MockTurn::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
1169 MockTurn::text("the answer is 5"),
1170 ])
1171 }
1172
1173 fn streaming_model() -> MockCompletionModel {
1174 MockCompletionModel::from_stream_turns([
1175 vec![
1176 MockStreamEvent::tool_call_name_delta("tc1", "ic1", "add"),
1177 MockStreamEvent::tool_call_arguments_delta("tc1", "ic1", "{\"x\":2,\"y\":3}"),
1178 MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
1179 MockStreamEvent::final_response_with_total_tokens(0),
1180 ],
1181 vec![
1182 MockStreamEvent::text("the answer is 5"),
1183 MockStreamEvent::final_response_with_total_tokens(0),
1184 ],
1185 ])
1186 }
1187
1188 #[tokio::test]
1191 async fn from_agent_preserves_implicit_one_and_explicit_zero_budgets() {
1192 let implicit_model = blocking_model();
1193 let implicit_recorded = implicit_model.clone();
1194 let implicit_agent = AgentBuilder::new(implicit_model).tool(MockAddTool).build();
1195 let implicit_runner = super::AgentRunner::from_agent(&implicit_agent, "add 2 and 3");
1196 assert_eq!(implicit_runner.max_turns, 1);
1197
1198 let implicit_err = implicit_runner
1199 .run()
1200 .await
1201 .expect_err("implicit budget should reject the second model call");
1202 assert!(matches!(
1203 implicit_err,
1204 PromptError::MaxTurnsError { max_turns: 1, .. }
1205 ));
1206 assert_eq!(implicit_recorded.request_count(), 1);
1207
1208 let zero_model = MockCompletionModel::text("should not be requested");
1209 let zero_recorded = zero_model.clone();
1210 let zero_agent = AgentBuilder::new(zero_model).default_max_turns(0).build();
1211 let zero_runner = super::AgentRunner::from_agent(&zero_agent, "do not call");
1212 assert_eq!(zero_runner.max_turns, 0);
1213
1214 let zero_err = zero_runner
1215 .run()
1216 .await
1217 .expect_err("explicit zero budget should reject the initial model call");
1218 assert!(matches!(
1219 zero_err,
1220 PromptError::MaxTurnsError { max_turns: 0, .. }
1221 ));
1222 assert_eq!(zero_recorded.request_count(), 0);
1223 }
1224
1225 #[tokio::test]
1228 async fn prompt_surfaces_reject_second_tool_roundtrip_request_at_budget_one() {
1229 let blocking_model = blocking_model();
1230 let blocking_recorded = blocking_model.clone();
1231 let blocking_agent = AgentBuilder::new(blocking_model).tool(MockAddTool).build();
1232 let blocking_err = blocking_agent
1233 .prompt("add 2 and 3")
1234 .max_turns(1)
1235 .await
1236 .expect_err("blocking prompt should reject request two");
1237 assert!(matches!(
1238 blocking_err,
1239 PromptError::MaxTurnsError { max_turns: 1, .. }
1240 ));
1241 assert_eq!(blocking_recorded.request_count(), 1);
1242
1243 let streaming_model = streaming_model();
1244 let streaming_recorded = streaming_model.clone();
1245 let streaming_agent = AgentBuilder::new(streaming_model).tool(MockAddTool).build();
1246 let mut stream = streaming_agent
1247 .stream_prompt("add 2 and 3")
1248 .max_turns(1)
1249 .await;
1250 let mut streaming_err = None;
1251 while let Some(item) = stream.next().await {
1252 if let Err(err) = item {
1253 streaming_err = Some(err);
1254 break;
1255 }
1256 }
1257 match streaming_err {
1258 Some(StreamingError::Prompt(err)) => assert!(matches!(
1259 *err,
1260 PromptError::MaxTurnsError { max_turns: 1, .. }
1261 )),
1262 other => panic!("expected streaming max-turns error, got {other:?}"),
1263 }
1264 assert_eq!(streaming_recorded.request_count(), 1);
1265 }
1266
1267 #[tokio::test]
1271 async fn run_and_stream_behave_identically_for_a_tool_call() {
1272 let blocking_hook = RecordingHook::default();
1273 let blocking = AgentBuilder::new(blocking_model())
1274 .tool(MockAddTool)
1275 .build()
1276 .runner("add 2 and 3")
1277 .max_turns(2)
1278 .add_hook(blocking_hook.clone())
1279 .run()
1280 .await
1281 .expect("blocking run should succeed");
1282
1283 let streaming_hook = RecordingHook::default();
1286 let mut stream = AgentBuilder::new(streaming_model())
1287 .tool(MockAddTool)
1288 .build()
1289 .runner("add 2 and 3")
1290 .max_turns(2)
1291 .add_hook(streaming_hook.clone())
1292 .stream()
1293 .await;
1294
1295 let mut final_response = None;
1296 while let Some(item) = stream.next().await {
1297 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
1298 item.map_err(|err| panic!("stream item errored: {err}"))
1299 {
1300 final_response = Some(resp);
1301 }
1302 }
1303 let final_response = final_response.expect("stream should yield a final response");
1304
1305 assert_eq!(blocking.output, "the answer is 5");
1307 assert_eq!(final_response.output(), blocking.output);
1308
1309 assert_eq!(
1312 blocking_hook.shared_events(),
1313 streaming_hook.shared_events()
1314 );
1315 assert_eq!(
1316 blocking_hook.shared_events(),
1317 vec![
1318 StepEventKind::CompletionCall,
1319 StepEventKind::ToolCall,
1320 StepEventKind::ToolResult,
1321 StepEventKind::CompletionCall,
1322 ]
1323 );
1324
1325 assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
1327 assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
1328
1329 let blocking_messages = blocking.messages.expect("blocking messages");
1331 let streaming_messages = final_response
1332 .messages()
1333 .expect("streaming history")
1334 .to_vec();
1335 assert_eq!(
1336 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
1337 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
1338 );
1339 }
1340
1341 mod structured_tool_results {
1346 use std::sync::{Arc, Mutex};
1347
1348 use futures::StreamExt;
1349 use serde_json::json;
1350
1351 use crate::agent::{AgentBuilder, AgentHook, Flow, HookContext, HookStack, StepEvent};
1352 use crate::completion::CompletionModel;
1353 use crate::test_utils::{
1354 MockAddTool, MockCompletionModel, MockDeniedTool, MockFailingTool,
1355 MockHandledFailureTool, MockMetadataTool, MockRequestId, MockStreamEvent, MockTurn,
1356 };
1357 use crate::tool::{ToolFailureKind, ToolOutcome};
1358
1359 #[derive(Clone, Default)]
1362 struct OutcomeHook {
1363 outcomes: Arc<Mutex<Vec<String>>>,
1364 results: Arc<Mutex<Vec<String>>>,
1365 }
1366
1367 impl OutcomeHook {
1368 fn outcomes(&self) -> Vec<String> {
1369 self.outcomes.lock().expect("outcomes").clone()
1370 }
1371
1372 fn results(&self) -> Vec<String> {
1373 self.results.lock().expect("results").clone()
1374 }
1375 }
1376
1377 fn outcome_label(outcome: &ToolOutcome) -> String {
1379 match outcome {
1380 ToolOutcome::Success => "success".to_string(),
1381 ToolOutcome::Error(failure) => format!("error:{}", failure.kind.as_str()),
1382 ToolOutcome::Skipped => "skipped".to_string(),
1383 ToolOutcome::Denied => "denied".to_string(),
1384 }
1385 }
1386
1387 impl<M: CompletionModel> AgentHook<M> for OutcomeHook {
1388 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1389 if let StepEvent::ToolResult {
1390 result, outcome, ..
1391 } = event
1392 {
1393 self.outcomes
1394 .lock()
1395 .expect("outcomes")
1396 .push(outcome_label(outcome));
1397 self.results
1398 .lock()
1399 .expect("results")
1400 .push(result.to_string());
1401 }
1402 Flow::cont()
1403 }
1404 }
1405
1406 fn model_one_tool_then_text(tool: &str) -> MockCompletionModel {
1408 MockCompletionModel::from_turns([
1409 MockTurn::tool_call("tc1", tool, json!({})),
1410 MockTurn::text("done"),
1411 ])
1412 }
1413
1414 fn stream_model_one_tool_then_text(tool: &str) -> MockCompletionModel {
1416 MockCompletionModel::from_stream_turns([
1417 vec![
1418 MockStreamEvent::tool_call_name_delta("tc1", "ic1", tool),
1419 MockStreamEvent::tool_call_arguments_delta("tc1", "ic1", "{}"),
1420 MockStreamEvent::tool_call("tc1", tool, json!({})),
1421 MockStreamEvent::final_response_with_total_tokens(0),
1422 ],
1423 vec![
1424 MockStreamEvent::text("done"),
1425 MockStreamEvent::final_response_with_total_tokens(0),
1426 ],
1427 ])
1428 }
1429
1430 #[tokio::test]
1433 async fn timeout_failure_surfaces_structured_outcome() {
1434 let hook = OutcomeHook::default();
1435 AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1436 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1437 .add_hook(hook.clone())
1438 .build()
1439 .runner("go")
1440 .max_turns(3)
1441 .run()
1442 .await
1443 .expect("run should succeed; a tool timeout is model-visible feedback, not fatal");
1444
1445 assert_eq!(hook.outcomes(), vec!["error:timeout".to_string()]);
1446 assert_eq!(hook.results(), vec!["mock tool call failed".to_string()]);
1448 }
1449
1450 #[tokio::test]
1453 async fn hook_terminates_after_repeated_timeouts() {
1454 #[derive(Clone, Default)]
1455 struct TimeoutCount(usize);
1456
1457 struct TimeoutTerminator;
1458 impl<M: CompletionModel> AgentHook<M> for TimeoutTerminator {
1459 async fn on_event(&self, ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1460 if let StepEvent::ToolResult { outcome, .. } = event
1461 && outcome.is_error_kind(ToolFailureKind::Timeout)
1462 {
1463 let count = ctx.scratchpad().update(|c: &mut TimeoutCount| {
1464 c.0 += 1;
1465 c.0
1466 });
1467 if count >= 2 {
1468 return Flow::terminate("aborting after repeated tool timeouts");
1469 }
1470 }
1471 Flow::cont()
1472 }
1473 }
1474
1475 let observer = OutcomeHook::default();
1476 let err = AgentBuilder::new(MockCompletionModel::from_turns([
1477 MockTurn::tool_call("tc1", "flaky_tool", json!({})),
1478 MockTurn::tool_call("tc2", "flaky_tool", json!({})),
1479 MockTurn::text("unreachable"),
1480 ]))
1481 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1482 .add_hook(observer.clone())
1484 .add_hook(TimeoutTerminator)
1485 .build()
1486 .runner("go")
1487 .max_turns(5)
1488 .run()
1489 .await
1490 .expect_err("the run must terminate after two timeouts");
1491
1492 assert!(
1493 err.to_string()
1494 .contains("aborting after repeated tool timeouts"),
1495 "unexpected error: {err}"
1496 );
1497 assert_eq!(
1498 observer.outcomes(),
1499 vec!["error:timeout".to_string(), "error:timeout".to_string()],
1500 "both timeout outcomes must be observed before termination"
1501 );
1502 }
1503
1504 #[tokio::test]
1507 async fn not_found_outcome_is_structured_and_non_fatal() {
1508 let hook = OutcomeHook::default();
1509 let status: Arc<Mutex<Option<u16>>> = Arc::new(Mutex::new(None));
1510
1511 struct StatusProbe(Arc<Mutex<Option<u16>>>);
1512 impl<M: CompletionModel> AgentHook<M> for StatusProbe {
1513 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1514 if let StepEvent::ToolResult { outcome, .. } = event
1515 && let ToolOutcome::Error(failure) = outcome
1516 {
1517 *self.0.lock().expect("status") = failure.http_status;
1518 }
1519 Flow::cont()
1520 }
1521 }
1522
1523 AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1524 .tool(MockFailingTool::new(ToolFailureKind::NotFound))
1525 .add_hook(hook.clone())
1526 .add_hook(StatusProbe(status.clone()))
1527 .build()
1528 .runner("go")
1529 .max_turns(3)
1530 .run()
1531 .await
1532 .expect("a 404 must not terminate the run by default");
1533
1534 assert_eq!(hook.outcomes(), vec!["error:not_found".to_string()]);
1535 assert_eq!(
1536 *status.lock().expect("status"),
1537 Some(404),
1538 "the structured failure must carry the HTTP status"
1539 );
1540 }
1541
1542 #[tokio::test]
1545 async fn handled_failure_delivers_model_output_and_error_outcome() {
1546 let hook = OutcomeHook::default();
1547 AgentBuilder::new(model_one_tool_then_text("lookup"))
1548 .tool(MockHandledFailureTool)
1549 .add_hook(hook.clone())
1550 .build()
1551 .runner("go")
1552 .max_turns(3)
1553 .run()
1554 .await
1555 .expect("a handled failure is not fatal");
1556
1557 assert_eq!(hook.outcomes(), vec!["error:not_found".to_string()]);
1558 assert_eq!(
1559 hook.results(),
1560 vec!["no record found for id 42; try a different id".to_string()],
1561 "the tool's model-visible output must survive alongside the error outcome"
1562 );
1563 }
1564
1565 #[tokio::test]
1568 async fn flow_skip_produces_skipped_outcome() {
1569 struct SkipHook;
1570 impl<M: CompletionModel> AgentHook<M> for SkipHook {
1571 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1572 if let StepEvent::ToolCall { .. } = event {
1573 Flow::skip("not executed (denied by policy); do not retry")
1574 } else {
1575 Flow::cont()
1576 }
1577 }
1578 }
1579
1580 let observer = OutcomeHook::default();
1581 AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1582 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1583 .add_hook(SkipHook)
1584 .add_hook(observer.clone())
1585 .build()
1586 .runner("go")
1587 .max_turns(3)
1588 .run()
1589 .await
1590 .expect("run should succeed after skipping the tool");
1591
1592 assert_eq!(observer.outcomes(), vec!["skipped".to_string()]);
1593 assert_eq!(
1594 observer.results(),
1595 vec!["not executed (denied by policy); do not retry".to_string()]
1596 );
1597 }
1598
1599 #[tokio::test]
1604 async fn tool_authored_denial_produces_denied_outcome() {
1605 let hook = OutcomeHook::default();
1606 AgentBuilder::new(model_one_tool_then_text("guarded"))
1607 .tool(MockDeniedTool)
1608 .add_hook(hook.clone())
1609 .build()
1610 .runner("go")
1611 .max_turns(3)
1612 .run()
1613 .await
1614 .expect("a tool-authored denial is not fatal");
1615
1616 assert_eq!(hook.outcomes(), vec!["denied".to_string()]);
1617 assert_eq!(
1618 hook.results(),
1619 vec!["access to this resource is not permitted".to_string()],
1620 "the model still receives the tool's denial message"
1621 );
1622 }
1623
1624 #[tokio::test]
1630 async fn rewrite_args_then_skip_reports_rewritten_args() {
1631 struct RewriteHook;
1633 impl<M: CompletionModel> AgentHook<M> for RewriteHook {
1634 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1635 if let StepEvent::ToolCall { .. } = event {
1636 Flow::rewrite_args(json!({ "x": 41, "y": 1 }))
1637 } else {
1638 Flow::cont()
1639 }
1640 }
1641 }
1642 struct SkipHook;
1644 impl<M: CompletionModel> AgentHook<M> for SkipHook {
1645 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1646 if let StepEvent::ToolCall { .. } = event {
1647 Flow::skip("denied after rewrite")
1648 } else {
1649 Flow::cont()
1650 }
1651 }
1652 }
1653 #[derive(Clone, Default)]
1655 struct ArgsProbe {
1656 args: Arc<Mutex<Option<String>>>,
1657 outcome: Arc<Mutex<Option<String>>>,
1658 }
1659 impl<M: CompletionModel> AgentHook<M> for ArgsProbe {
1660 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1661 if let StepEvent::ToolResult { args, outcome, .. } = event {
1662 *self.args.lock().expect("args") = Some(args.to_string());
1663 *self.outcome.lock().expect("outcome") = Some(outcome_label(outcome));
1664 }
1665 Flow::cont()
1666 }
1667 }
1668
1669 async fn run_surface(streaming: bool) -> (String, String) {
1670 let probe = ArgsProbe::default();
1671 if streaming {
1674 let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("add"))
1675 .tool(MockAddTool)
1676 .add_hook(RewriteHook)
1677 .add_hook(SkipHook)
1678 .add_hook(probe.clone())
1679 .build()
1680 .runner("go")
1681 .max_turns(3)
1682 .stream()
1683 .await;
1684 while let Some(item) = stream.next().await {
1685 if let Err(err) = item {
1686 panic!("stream item errored: {err}");
1687 }
1688 }
1689 } else {
1690 AgentBuilder::new(model_one_tool_then_text("add"))
1691 .tool(MockAddTool)
1692 .add_hook(RewriteHook)
1693 .add_hook(SkipHook)
1694 .add_hook(probe.clone())
1695 .build()
1696 .runner("go")
1697 .max_turns(3)
1698 .run()
1699 .await
1700 .expect("run should succeed after skipping the tool");
1701 }
1702 let args = probe.args.lock().expect("args").clone().expect("args seen");
1703 let outcome = probe
1704 .outcome
1705 .lock()
1706 .expect("outcome")
1707 .clone()
1708 .expect("outcome seen");
1709 (args, outcome)
1710 }
1711
1712 for streaming in [false, true] {
1713 let (args, outcome) = run_surface(streaming).await;
1714 assert_eq!(
1715 outcome, "skipped",
1716 "the skipped tool must produce a Skipped outcome (streaming={streaming})"
1717 );
1718 let parsed: serde_json::Value =
1719 serde_json::from_str(&args).expect("ToolResult args are valid JSON");
1720 assert_eq!(
1721 parsed,
1722 json!({ "x": 41, "y": 1 }),
1723 "the skipped ToolResult must report the rewritten args, not the model's \
1724 original {{}} (streaming={streaming}); got {args}"
1725 );
1726 }
1727 }
1728
1729 #[tokio::test]
1734 async fn nested_hook_stack_rewrite_then_skip_reports_rewritten_args() {
1735 struct RewriteHook;
1736 impl<M: CompletionModel> AgentHook<M> for RewriteHook {
1737 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1738 if let StepEvent::ToolCall { .. } = event {
1739 Flow::rewrite_args(json!({ "x": 41, "y": 1 }))
1740 } else {
1741 Flow::cont()
1742 }
1743 }
1744 }
1745 struct SkipHook;
1746 impl<M: CompletionModel> AgentHook<M> for SkipHook {
1747 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1748 if let StepEvent::ToolCall { .. } = event {
1749 Flow::skip("denied after nested rewrite")
1750 } else {
1751 Flow::cont()
1752 }
1753 }
1754 }
1755 #[derive(Clone, Default)]
1756 struct ArgsProbe {
1757 args: Arc<Mutex<Option<String>>>,
1758 outcome: Arc<Mutex<Option<String>>>,
1759 }
1760 impl<M: CompletionModel> AgentHook<M> for ArgsProbe {
1761 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1762 if let StepEvent::ToolResult { args, outcome, .. } = event {
1763 *self.args.lock().expect("args") = Some(args.to_string());
1764 *self.outcome.lock().expect("outcome") = Some(outcome_label(outcome));
1765 }
1766 Flow::cont()
1767 }
1768 }
1769
1770 fn nested_stack() -> HookStack<MockCompletionModel> {
1772 let mut nested = HookStack::<MockCompletionModel>::new();
1773 nested.push(RewriteHook);
1774 nested.push(SkipHook);
1775 nested
1776 }
1777
1778 for streaming in [false, true] {
1781 let probe = ArgsProbe::default();
1782 if streaming {
1783 let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("add"))
1784 .tool(MockAddTool)
1785 .add_hook(nested_stack())
1786 .add_hook(probe.clone())
1787 .build()
1788 .runner("go")
1789 .max_turns(3)
1790 .stream()
1791 .await;
1792 while let Some(item) = stream.next().await {
1793 if let Err(err) = item {
1794 panic!("stream item errored: {err}");
1795 }
1796 }
1797 } else {
1798 AgentBuilder::new(model_one_tool_then_text("add"))
1799 .tool(MockAddTool)
1800 .add_hook(nested_stack())
1801 .add_hook(probe.clone())
1802 .build()
1803 .runner("go")
1804 .max_turns(3)
1805 .run()
1806 .await
1807 .expect("run should succeed after the nested stack skips the tool");
1808 }
1809
1810 assert_eq!(
1811 probe.outcome.lock().expect("outcome").clone(),
1812 Some("skipped".to_string()),
1813 "streaming={streaming}"
1814 );
1815 let args = probe.args.lock().expect("args").clone().expect("args seen");
1816 let parsed: serde_json::Value =
1817 serde_json::from_str(&args).expect("valid JSON args");
1818 assert_eq!(
1819 parsed,
1820 json!({ "x": 41, "y": 1 }),
1821 "the nested stack's rewrite must survive its skip and reach the ToolResult \
1822 (streaming={streaming}); got {args}"
1823 );
1824 }
1825 }
1826
1827 #[tokio::test]
1830 async fn invalid_args_are_classified_as_invalid_args() {
1831 let hook = OutcomeHook::default();
1832 AgentBuilder::new(MockCompletionModel::from_turns([
1833 MockTurn::tool_call("tc1", "add", json!({ "x": "not-a-number", "y": 1 })),
1835 MockTurn::text("done"),
1836 ]))
1837 .tool(MockAddTool)
1838 .add_hook(hook.clone())
1839 .build()
1840 .runner("go")
1841 .max_turns(3)
1842 .run()
1843 .await
1844 .expect("an invalid-args failure is model-visible feedback, not fatal");
1845
1846 assert_eq!(hook.outcomes(), vec!["error:invalid_args".to_string()]);
1847 }
1848
1849 #[tokio::test]
1852 async fn success_extensions_reach_hook_but_not_model() {
1853 let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1854 let model_output: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1855
1856 struct ExtProbe {
1857 seen: Arc<Mutex<Option<String>>>,
1858 model_output: Arc<Mutex<Option<String>>>,
1859 }
1860 impl<M: CompletionModel> AgentHook<M> for ExtProbe {
1861 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1862 if let StepEvent::ToolResult {
1863 result, extensions, ..
1864 } = event
1865 {
1866 *self.seen.lock().expect("seen") =
1867 extensions.get::<MockRequestId>().map(|id| id.0.clone());
1868 *self.model_output.lock().expect("model_output") = Some(result.to_string());
1869 }
1870 Flow::cont()
1871 }
1872 }
1873
1874 AgentBuilder::new(model_one_tool_then_text("with_meta"))
1875 .tool(MockMetadataTool)
1876 .add_hook(ExtProbe {
1877 seen: seen.clone(),
1878 model_output: model_output.clone(),
1879 })
1880 .build()
1881 .runner("go")
1882 .max_turns(3)
1883 .run()
1884 .await
1885 .expect("run should succeed");
1886
1887 assert_eq!(
1888 *seen.lock().expect("seen"),
1889 Some("req-7".to_string()),
1890 "the tool's result extension must reach the hook"
1891 );
1892 let output = model_output
1893 .lock()
1894 .expect("model_output")
1895 .clone()
1896 .expect("output");
1897 assert_eq!(output, "done");
1898 assert!(
1899 !output.contains("req-7"),
1900 "result extensions must never leak into the model-visible output"
1901 );
1902 }
1903
1904 #[tokio::test]
1908 async fn rewrite_result_does_not_mask_the_structured_outcome() {
1909 struct Redact;
1910 impl<M: CompletionModel> AgentHook<M> for Redact {
1911 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
1912 if let StepEvent::ToolResult { .. } = event {
1913 Flow::rewrite_result("[REDACTED]")
1914 } else {
1915 Flow::cont()
1916 }
1917 }
1918 }
1919
1920 let observer = OutcomeHook::default();
1921 AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1922 .tool(MockFailingTool::new(ToolFailureKind::NotFound))
1923 .add_hook(Redact)
1926 .add_hook(observer.clone())
1927 .build()
1928 .runner("go")
1929 .max_turns(3)
1930 .run()
1931 .await
1932 .expect("run should succeed");
1933
1934 assert_eq!(observer.outcomes(), vec!["error:not_found".to_string()]);
1935 assert_eq!(observer.results(), vec!["[REDACTED]".to_string()]);
1936 }
1937
1938 #[tokio::test]
1941 async fn streaming_and_blocking_outcomes_match() {
1942 let blocking = OutcomeHook::default();
1943 AgentBuilder::new(model_one_tool_then_text("flaky_tool"))
1944 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1945 .add_hook(blocking.clone())
1946 .build()
1947 .runner("go")
1948 .max_turns(3)
1949 .run()
1950 .await
1951 .expect("blocking run should succeed");
1952
1953 let streaming = OutcomeHook::default();
1954 let mut stream = AgentBuilder::new(stream_model_one_tool_then_text("flaky_tool"))
1955 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1956 .add_hook(streaming.clone())
1957 .build()
1958 .runner("go")
1959 .max_turns(3)
1960 .stream()
1961 .await;
1962 while let Some(item) = stream.next().await {
1963 if let Err(err) = item {
1964 panic!("stream item errored: {err}");
1965 }
1966 }
1967
1968 assert_eq!(blocking.outcomes(), vec!["error:timeout".to_string()]);
1969 assert_eq!(blocking.outcomes(), streaming.outcomes());
1970 assert_eq!(blocking.results(), streaming.results());
1971 }
1972
1973 #[tokio::test]
1976 async fn concurrent_tools_preserve_order_and_both_outcomes() {
1977 use crate::message::{AssistantContent, ToolCall, ToolFunction, UserContent};
1978
1979 let turn = MockTurn::from_contents([
1980 AssistantContent::ToolCall(ToolCall::new(
1981 "tc_add".to_string(),
1982 ToolFunction::new("add".to_string(), json!({ "x": 2, "y": 3 })),
1983 )),
1984 AssistantContent::ToolCall(ToolCall::new(
1985 "tc_flaky".to_string(),
1986 ToolFunction::new("flaky_tool".to_string(), json!({})),
1987 )),
1988 ])
1989 .expect("two tool calls");
1990
1991 let observer = OutcomeHook::default();
1992 let response = AgentBuilder::new(MockCompletionModel::from_turns([
1993 turn,
1994 MockTurn::text("done"),
1995 ]))
1996 .tool(MockAddTool)
1997 .tool(MockFailingTool::new(ToolFailureKind::Timeout))
1998 .add_hook(observer.clone())
1999 .build()
2000 .runner("go")
2001 .max_turns(3)
2002 .tool_concurrency(2)
2003 .run()
2004 .await
2005 .expect("run should succeed");
2006
2007 let mut outcomes = observer.outcomes();
2009 outcomes.sort();
2010 assert_eq!(
2011 outcomes,
2012 vec!["error:timeout".to_string(), "success".to_string()]
2013 );
2014
2015 let messages = response.messages.expect("messages");
2018 let tool_result_ids: Vec<String> = messages
2019 .iter()
2020 .flat_map(|message| match message {
2021 crate::completion::Message::User { content } => content
2022 .iter()
2023 .filter_map(|c| match c {
2024 UserContent::ToolResult(result) => Some(result.id.clone()),
2025 _ => None,
2026 })
2027 .collect::<Vec<_>>(),
2028 _ => Vec::new(),
2029 })
2030 .collect();
2031 assert_eq!(
2032 tool_result_ids,
2033 vec!["tc_add".to_string(), "tc_flaky".to_string()],
2034 "tool results must be persisted in call order"
2035 );
2036 }
2037 }
2038
2039 mod span_safety_net {
2045 use std::collections::{HashMap, HashSet};
2046 use std::sync::{Arc, Mutex};
2047
2048 use tracing::Instrument;
2049 use tracing::field::{Field, Visit};
2050 use tracing::span::{Attributes, Record};
2051 use tracing::{Id, Subscriber};
2052 use tracing_subscriber::layer::{Context, SubscriberExt};
2053 use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
2054
2055 use crate::agent::AgentBuilder;
2056 use crate::completion::Usage;
2057 use crate::test_utils::{MockAddTool, MockCompletionModel, MockTurn};
2058
2059 #[derive(Clone)]
2060 struct CapturedSpan {
2061 id: u64,
2062 name: String,
2063 field_names: HashSet<String>,
2064 u64_fields: HashMap<String, u64>,
2065 }
2066
2067 #[derive(Clone, Default)]
2068 struct Captured {
2069 spans: Arc<Mutex<Vec<CapturedSpan>>>,
2070 follows: Arc<Mutex<Vec<(u64, u64)>>>,
2072 }
2073
2074 impl Captured {
2075 fn insert(&self, id: &Id, name: &str) {
2076 self.spans.lock().expect("spans").push(CapturedSpan {
2077 id: id.into_u64(),
2078 name: name.to_string(),
2079 field_names: HashSet::new(),
2080 u64_fields: HashMap::new(),
2081 });
2082 }
2083
2084 fn record(&self, id: &Id, names: HashSet<String>, u64s: HashMap<String, u64>) {
2085 let id = id.into_u64();
2086 if let Ok(mut spans) = self.spans.lock()
2087 && let Some(span) = spans.iter_mut().find(|s| s.id == id)
2088 {
2089 span.field_names.extend(names);
2090 span.u64_fields.extend(u64s);
2091 }
2092 }
2093
2094 fn follows_from(&self, span: &Id, follows: &Id) {
2095 self.follows
2096 .lock()
2097 .expect("follows")
2098 .push((span.into_u64(), follows.into_u64()));
2099 }
2100
2101 fn clear(&self) {
2102 self.spans.lock().expect("spans").clear();
2103 self.follows.lock().expect("follows").clear();
2104 }
2105
2106 fn snapshot(&self) -> Vec<CapturedSpan> {
2107 self.spans.lock().expect("spans").clone()
2108 }
2109
2110 fn follows_edges(&self) -> Vec<(u64, u64)> {
2111 self.follows.lock().expect("follows").clone()
2112 }
2113 }
2114
2115 struct CaptureLayer {
2116 captured: Captured,
2117 }
2118
2119 impl<S> Layer<S> for CaptureLayer
2120 where
2121 S: Subscriber + for<'l> LookupSpan<'l>,
2122 {
2123 fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, _ctx: Context<'_, S>) {
2124 self.captured.insert(id, attrs.metadata().name());
2125 }
2126
2127 fn on_record(&self, span: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
2128 let mut visitor = FieldVisitor::default();
2129 values.record(&mut visitor);
2130 self.captured.record(span, visitor.names, visitor.u64s);
2131 }
2132
2133 fn on_follows_from(&self, span: &Id, follows: &Id, _ctx: Context<'_, S>) {
2134 self.captured.follows_from(span, follows);
2135 }
2136 }
2137
2138 #[derive(Default)]
2139 struct FieldVisitor {
2140 names: HashSet<String>,
2141 u64s: HashMap<String, u64>,
2142 }
2143
2144 impl Visit for FieldVisitor {
2145 fn record_u64(&mut self, field: &Field, value: u64) {
2146 self.names.insert(field.name().to_string());
2147 self.u64s.insert(field.name().to_string(), value);
2148 }
2149
2150 fn record_str(&mut self, field: &Field, _value: &str) {
2151 self.names.insert(field.name().to_string());
2152 }
2153
2154 fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
2155 self.names.insert(field.name().to_string());
2156 }
2157 }
2158
2159 fn usage(input: u64, output: u64) -> Usage {
2160 Usage {
2161 input_tokens: input,
2162 output_tokens: output,
2163 ..Usage::new()
2164 }
2165 }
2166
2167 fn tool_then_text_model() -> MockCompletionModel {
2170 MockCompletionModel::from_turns([
2171 MockTurn::tool_call("tc1", "add", serde_json::json!({"x": 2, "y": 3}))
2172 .with_usage(usage(7, 11)),
2173 MockTurn::text("the answer is 5").with_usage(usage(13, 17)),
2174 ])
2175 }
2176
2177 async fn warm_blocking_callsites() {
2182 let agent = AgentBuilder::new(tool_then_text_model())
2183 .tool(MockAddTool)
2184 .build();
2185 let _ = agent.runner("add 2 and 3").max_turns(3).run().await;
2186 }
2187
2188 #[tokio::test]
2189 async fn run_records_usage_and_chains_chat_spans_on_a_created_agent_span() {
2190 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2191 let captured = Captured::default();
2192 let subscriber = Registry::default().with(CaptureLayer {
2193 captured: captured.clone(),
2194 });
2195 let _default = tracing::subscriber::set_default(subscriber);
2196
2197 warm_blocking_callsites().await;
2198 tracing::callsite::rebuild_interest_cache();
2199 captured.clear();
2200
2201 let agent = AgentBuilder::new(tool_then_text_model())
2202 .tool(MockAddTool)
2203 .build();
2204 let response = agent
2205 .runner("add 2 and 3")
2206 .max_turns(3)
2207 .run()
2208 .await
2209 .expect("blocking run should succeed");
2210 assert_eq!(response.output, "the answer is 5");
2211
2212 let spans = captured.snapshot();
2213
2214 let chat_spans: Vec<&CapturedSpan> =
2216 spans.iter().filter(|s| s.name == "chat").collect();
2217 assert_eq!(chat_spans.len(), 2, "two model turns -> two chat spans");
2218 assert!(
2219 spans.iter().all(|s| s.name != "chat_streaming"),
2220 "blocking driver must not emit chat_streaming spans"
2221 );
2222
2223 let agent_span = spans
2225 .iter()
2226 .find(|s| s.name == "invoke_agent")
2227 .expect("blocking run should create an invoke_agent span");
2228
2229 assert_eq!(
2231 agent_span.u64_fields.get("gen_ai.usage.input_tokens"),
2232 Some(&(7 + 13)),
2233 );
2234 assert_eq!(
2235 agent_span.u64_fields.get("gen_ai.usage.output_tokens"),
2236 Some(&(11 + 17)),
2237 );
2238 assert!(
2239 agent_span.field_names.contains("gen_ai.completion"),
2240 "the created agent span records the final completion text"
2241 );
2242
2243 let tool_span = spans
2248 .iter()
2249 .find(|s| s.name == "execute_tool")
2250 .expect("tool turn should emit an execute_tool span");
2251 let edges = captured.follows_edges();
2252 assert!(
2253 edges.contains(&(tool_span.id, chat_spans[0].id)),
2254 "execute_tool should follow_from the first chat span; edges={edges:?}"
2255 );
2256 assert!(
2257 edges.contains(&(chat_spans[1].id, tool_span.id)),
2258 "the second chat span should follow_from execute_tool; edges={edges:?}"
2259 );
2260 }
2261
2262 #[tokio::test]
2263 async fn run_does_not_record_usage_onto_a_caller_supplied_outer_span() {
2264 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2265 let captured = Captured::default();
2266 let subscriber = Registry::default().with(CaptureLayer {
2267 captured: captured.clone(),
2268 });
2269 let _default = tracing::subscriber::set_default(subscriber);
2270
2271 warm_blocking_callsites().await;
2272 tracing::callsite::rebuild_interest_cache();
2273 captured.clear();
2274
2275 let outer = tracing::info_span!(
2279 "outer",
2280 gen_ai.completion = tracing::field::Empty,
2281 gen_ai.usage.input_tokens = tracing::field::Empty,
2282 gen_ai.usage.output_tokens = tracing::field::Empty,
2283 );
2284 async {
2285 let agent = AgentBuilder::new(tool_then_text_model())
2286 .tool(MockAddTool)
2287 .build();
2288 agent
2289 .runner("add 2 and 3")
2290 .max_turns(3)
2291 .run()
2292 .await
2293 .expect("blocking run should succeed");
2294 }
2295 .instrument(outer)
2296 .await;
2297
2298 let spans = captured.snapshot();
2299 assert!(
2301 spans.iter().all(|s| s.name != "invoke_agent"),
2302 "an ambient outer span should be adopted, not wrapped in invoke_agent"
2303 );
2304 let outer_span = spans
2305 .iter()
2306 .find(|s| s.name == "outer")
2307 .expect("outer span should be captured");
2308 assert!(
2309 outer_span
2310 .field_names
2311 .iter()
2312 .all(|name| !name.starts_with("gen_ai.usage.")),
2313 "run-level usage must not be recorded onto a caller-supplied outer span"
2314 );
2315 assert!(
2316 !outer_span.field_names.contains("gen_ai.completion"),
2317 "run-level completion must not be recorded onto a caller-supplied outer span"
2318 );
2319 }
2320
2321 struct SecretTool;
2326 impl crate::tool::Tool for SecretTool {
2327 const NAME: &'static str = "leak";
2328 type Error = crate::test_utils::MockToolError;
2329 type Args = serde_json::Value;
2330 type Output = String;
2331 fn description(&self) -> String {
2332 "returns a secret".to_string()
2333 }
2334
2335 fn parameters(&self) -> serde_json::Value {
2336 serde_json::json!({ "type": "object", "properties": {} })
2337 }
2338 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
2339 Ok("SUPER_SECRET_TOKEN_42".to_string())
2340 }
2341 }
2342
2343 struct RedactResultHook;
2345 impl<M: crate::completion::CompletionModel> crate::agent::AgentHook<M> for RedactResultHook {
2346 async fn on_event(
2347 &self,
2348 _ctx: &crate::agent::HookContext,
2349 event: crate::agent::StepEvent<'_, M>,
2350 ) -> crate::agent::Flow {
2351 if let crate::agent::StepEvent::ToolResult { .. } = event {
2352 crate::agent::Flow::rewrite_result("[REDACTED]")
2353 } else {
2354 crate::agent::Flow::cont()
2355 }
2356 }
2357 }
2358
2359 #[derive(Default)]
2362 struct ResultValueVisitor {
2363 values: Vec<String>,
2364 }
2365 impl Visit for ResultValueVisitor {
2366 fn record_str(&mut self, field: &Field, value: &str) {
2367 if field.name() == "gen_ai.tool.call.result" {
2368 self.values.push(value.to_string());
2369 }
2370 }
2371 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
2372 if field.name() == "gen_ai.tool.call.result" {
2373 self.values.push(format!("{value:?}"));
2374 }
2375 }
2376 }
2377
2378 struct ResultValueLayer {
2379 values: Arc<Mutex<Vec<String>>>,
2380 }
2381 impl<S> Layer<S> for ResultValueLayer
2382 where
2383 S: Subscriber + for<'l> LookupSpan<'l>,
2384 {
2385 fn on_record(&self, _span: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
2386 let mut visitor = ResultValueVisitor::default();
2387 values.record(&mut visitor);
2388 if !visitor.values.is_empty() {
2389 self.values.lock().expect("values").extend(visitor.values);
2390 }
2391 }
2392 }
2393
2394 #[tokio::test]
2399 async fn tool_result_redaction_does_not_leak_raw_output_to_the_span() {
2400 let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
2401 let values: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2402 let subscriber = Registry::default().with(ResultValueLayer {
2403 values: values.clone(),
2404 });
2405 let _default = tracing::subscriber::set_default(subscriber);
2406
2407 warm_blocking_callsites().await;
2410 tracing::callsite::rebuild_interest_cache();
2411 values.lock().expect("values").clear();
2412
2413 let model = MockCompletionModel::from_turns([
2414 MockTurn::tool_call("tc1", "leak", serde_json::json!({})),
2415 MockTurn::text("ok"),
2416 ]);
2417 let response = AgentBuilder::new(model)
2418 .tool(SecretTool)
2419 .add_hook(RedactResultHook)
2420 .build()
2421 .runner("go")
2422 .max_turns(3)
2423 .run()
2424 .await
2425 .expect("run should succeed");
2426 assert_eq!(response.output, "ok");
2427
2428 let captured = values.lock().expect("values").clone();
2429 assert!(
2430 !captured.iter().any(|v| v.contains("SUPER_SECRET_TOKEN_42")),
2431 "the raw tool output must never be recorded on the span; captured: {captured:?}"
2432 );
2433 assert!(
2434 captured.iter().any(|v| v.contains("[REDACTED]")),
2435 "only the redacted replacement is recorded on the span; captured: {captured:?}"
2436 );
2437 }
2438 }
2439
2440 fn tool_call_content(id: &str, args: serde_json::Value) -> AssistantContent {
2441 AssistantContent::ToolCall(ToolCall::new(
2442 id.to_string(),
2443 ToolFunction::new("add".to_string(), args),
2444 ))
2445 }
2446
2447 fn tool_result_text_in_history(messages: &[Message], expected: &str) -> bool {
2451 messages.iter().any(|message| {
2452 matches!(
2453 message,
2454 Message::User { content }
2455 if content.iter().any(|item| matches!(
2456 item,
2457 UserContent::ToolResult(result)
2458 if result.content.iter().any(|c| matches!(
2459 c,
2460 crate::message::ToolResultContent::Text(text)
2461 if text.text == expected
2462 ))
2463 ))
2464 )
2465 })
2466 }
2467
2468 #[tokio::test]
2473 async fn run_and_stream_same_message_history_for_parallel_tool_calls() {
2474 let blocking_model = MockCompletionModel::from_turns([
2475 MockTurn::from_contents([
2476 tool_call_content("tc1", json!({"x": 2, "y": 3})),
2477 tool_call_content("tc2", json!({"x": 10, "y": 20})),
2478 ])
2479 .expect("two tool calls is a valid turn"),
2480 MockTurn::text("done"),
2481 ]);
2482 let blocking = AgentBuilder::new(blocking_model)
2483 .tool(MockAddTool)
2484 .build()
2485 .runner("add two pairs")
2486 .max_turns(3)
2487 .tool_concurrency(4)
2488 .run()
2489 .await
2490 .expect("blocking run should succeed");
2491
2492 let streaming_model = MockCompletionModel::from_stream_turns([
2493 vec![
2494 MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
2495 MockStreamEvent::tool_call("tc2", "add", json!({"x": 10, "y": 20})),
2496 MockStreamEvent::final_response_with_total_tokens(0),
2497 ],
2498 vec![
2499 MockStreamEvent::text("done"),
2500 MockStreamEvent::final_response_with_total_tokens(0),
2501 ],
2502 ]);
2503 let mut stream = AgentBuilder::new(streaming_model)
2504 .tool(MockAddTool)
2505 .build()
2506 .runner("add two pairs")
2507 .max_turns(3)
2508 .stream()
2509 .await;
2510 let mut final_response = None;
2511 while let Some(item) = stream.next().await {
2512 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
2513 item.map_err(|err| panic!("stream item errored: {err}"))
2514 {
2515 final_response = Some(resp);
2516 }
2517 }
2518 let final_response = final_response.expect("stream should yield a final response");
2519
2520 let blocking_messages = blocking.messages.expect("blocking messages");
2521 let streaming_messages = final_response
2522 .messages()
2523 .expect("streaming history")
2524 .to_vec();
2525 assert_eq!(
2526 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
2527 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
2528 );
2529 }
2530
2531 #[derive(Clone)]
2537 struct OutOfOrderTool {
2538 gate: Arc<tokio::sync::Notify>,
2539 order: Arc<AtomicU32>,
2540 }
2541
2542 impl Tool for OutOfOrderTool {
2543 const NAME: &'static str = "add";
2544 type Error = MockToolError;
2545 type Args = MockOperationArgs;
2546 type Output = i32;
2547
2548 fn description(&self) -> String {
2549 MockAddTool.description()
2550 }
2551
2552 fn parameters(&self) -> serde_json::Value {
2553 MockAddTool.parameters()
2554 }
2555
2556 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
2557 let nth = self.order.fetch_add(1, SeqCst);
2558 if nth == 0 {
2559 self.gate.notified().await;
2561 } else {
2562 self.gate.notify_one();
2564 }
2565 Ok(nth as i32)
2566 }
2567 }
2568
2569 #[tokio::test]
2575 async fn run_preserves_tool_call_order_under_out_of_order_completion() {
2576 let model = MockCompletionModel::from_turns([
2577 MockTurn::from_contents([
2578 tool_call_content("tc1", json!({"x": 1, "y": 0})),
2579 tool_call_content("tc2", json!({"x": 2, "y": 0})),
2580 ])
2581 .expect("two tool calls is a valid turn"),
2582 MockTurn::text("done"),
2583 ]);
2584 let response = AgentBuilder::new(model)
2585 .tool(OutOfOrderTool {
2586 gate: Arc::new(tokio::sync::Notify::new()),
2587 order: Arc::new(AtomicU32::new(0)),
2588 })
2589 .build()
2590 .runner("go")
2591 .max_turns(3)
2592 .tool_concurrency(4)
2593 .run()
2594 .await
2595 .expect("run should succeed");
2596
2597 let messages = response.messages.expect("messages");
2598 let result_ids: Vec<String> = messages
2599 .iter()
2600 .flat_map(|message| match message {
2601 Message::User { content } => content
2602 .iter()
2603 .filter_map(|item| match item {
2604 UserContent::ToolResult(result) => Some(result.id.clone()),
2605 _ => None,
2606 })
2607 .collect::<Vec<_>>(),
2608 _ => Vec::new(),
2609 })
2610 .collect();
2611 assert_eq!(result_ids, vec!["tc1".to_string(), "tc2".to_string()]);
2613 }
2614
2615 async fn drive_to_final_response<R: Send + 'static>(
2618 mut stream: crate::agent::prompt_request::streaming::StreamingResult<R>,
2619 ) -> crate::agent::prompt_request::PromptResponse {
2620 let mut final_response = None;
2621 while let Some(item) = stream.next().await {
2622 if let MultiTurnStreamItem::FinalResponse(resp) =
2623 item.unwrap_or_else(|err| panic!("stream item errored: {err}"))
2624 {
2625 final_response = Some(resp);
2626 }
2627 }
2628 final_response.expect("stream should yield a final response")
2629 }
2630
2631 fn tool_result_ids(messages: &[Message]) -> Vec<String> {
2633 messages
2634 .iter()
2635 .flat_map(|message| match message {
2636 Message::User { content } => content
2637 .iter()
2638 .filter_map(|item| match item {
2639 UserContent::ToolResult(result) => Some(result.id.clone()),
2640 _ => None,
2641 })
2642 .collect::<Vec<_>>(),
2643 _ => Vec::new(),
2644 })
2645 .collect()
2646 }
2647
2648 #[tokio::test]
2653 async fn stream_and_run_same_message_history_for_parallel_tool_calls_under_concurrency() {
2654 let blocking_model = MockCompletionModel::from_turns([
2655 MockTurn::from_contents([
2656 tool_call_content("tc1", json!({"x": 2, "y": 3})),
2657 tool_call_content("tc2", json!({"x": 10, "y": 20})),
2658 ])
2659 .expect("two tool calls is a valid turn"),
2660 MockTurn::text("done"),
2661 ]);
2662 let blocking = AgentBuilder::new(blocking_model)
2663 .tool(MockAddTool)
2664 .build()
2665 .runner("add two pairs")
2666 .max_turns(3)
2667 .tool_concurrency(4)
2668 .run()
2669 .await
2670 .expect("blocking run should succeed");
2671
2672 let streaming_model = MockCompletionModel::from_stream_turns([
2673 vec![
2674 MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
2675 MockStreamEvent::tool_call("tc2", "add", json!({"x": 10, "y": 20})),
2676 MockStreamEvent::final_response_with_total_tokens(0),
2677 ],
2678 vec![
2679 MockStreamEvent::text("done"),
2680 MockStreamEvent::final_response_with_total_tokens(0),
2681 ],
2682 ]);
2683 let stream = AgentBuilder::new(streaming_model)
2684 .tool(MockAddTool)
2685 .build()
2686 .runner("add two pairs")
2687 .max_turns(3)
2688 .tool_concurrency(4)
2689 .stream()
2690 .await;
2691 let final_response = drive_to_final_response(stream).await;
2692
2693 let blocking_messages = blocking.messages.expect("blocking messages");
2694 let streaming_messages = final_response
2695 .messages()
2696 .expect("streaming history")
2697 .to_vec();
2698 assert_eq!(
2699 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
2700 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
2701 );
2702 }
2703
2704 #[tokio::test]
2710 async fn stream_preserves_history_order_under_out_of_order_completion() {
2711 let model = MockCompletionModel::from_stream_turns([
2712 vec![
2713 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 0})),
2714 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 0})),
2715 MockStreamEvent::final_response_with_total_tokens(0),
2716 ],
2717 vec![
2718 MockStreamEvent::text("done"),
2719 MockStreamEvent::final_response_with_total_tokens(0),
2720 ],
2721 ]);
2722 let stream = AgentBuilder::new(model)
2723 .tool(OutOfOrderTool {
2724 gate: Arc::new(tokio::sync::Notify::new()),
2725 order: Arc::new(AtomicU32::new(0)),
2726 })
2727 .build()
2728 .runner("go")
2729 .max_turns(3)
2730 .tool_concurrency(4)
2731 .stream()
2732 .await;
2733 let final_response = tokio::time::timeout(
2736 std::time::Duration::from_secs(5),
2737 drive_to_final_response(stream),
2738 )
2739 .await
2740 .expect("streamed tools must run concurrently, not deadlock on the first call");
2741
2742 let messages = final_response.messages().expect("history").to_vec();
2743 assert_eq!(
2745 tool_result_ids(&messages),
2746 vec!["tc1".to_string(), "tc2".to_string()]
2747 );
2748 }
2749
2750 #[tokio::test]
2755 async fn stream_emits_tool_results_in_call_order_after_batch_settles_under_concurrency() {
2756 let model = MockCompletionModel::from_stream_turns([
2757 vec![
2758 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 0})),
2759 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 0})),
2760 MockStreamEvent::final_response_with_total_tokens(0),
2761 ],
2762 vec![
2763 MockStreamEvent::text("done"),
2764 MockStreamEvent::final_response_with_total_tokens(0),
2765 ],
2766 ]);
2767 let mut stream = AgentBuilder::new(model)
2768 .tool(OutOfOrderTool {
2769 gate: Arc::new(tokio::sync::Notify::new()),
2770 order: Arc::new(AtomicU32::new(0)),
2771 })
2772 .build()
2773 .runner("go")
2774 .max_turns(3)
2775 .tool_concurrency(4)
2776 .stream()
2777 .await;
2778
2779 let mut streamed_result_ids = Vec::new();
2780 let mut final_response = None;
2781 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2782 while let Some(item) = stream.next().await {
2783 match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
2784 MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2785 tool_result,
2786 ..
2787 }) => streamed_result_ids.push(tool_result.id),
2788 MultiTurnStreamItem::FinalResponse(resp) => final_response = Some(resp),
2789 _ => {}
2790 }
2791 }
2792 })
2793 .await
2794 .expect("streamed tools must run concurrently, not deadlock on the first call");
2795
2796 assert_eq!(
2799 streamed_result_ids,
2800 vec!["tc1".to_string(), "tc2".to_string()]
2801 );
2802 let final_response = final_response.expect("stream should yield a final response");
2803 assert_eq!(
2804 tool_result_ids(final_response.messages().expect("history")),
2805 vec!["tc1".to_string(), "tc2".to_string()]
2806 );
2807 }
2808
2809 #[tokio::test]
2815 async fn stream_executes_tools_concurrently_under_concurrency() {
2816 let barrier = Arc::new(tokio::sync::Barrier::new(2));
2817 let model = MockCompletionModel::from_stream_turns([
2818 vec![
2819 MockStreamEvent::tool_call("b1", "barrier_tool", json!({})),
2820 MockStreamEvent::tool_call("b2", "barrier_tool", json!({})),
2821 MockStreamEvent::final_response_with_total_tokens(0),
2822 ],
2823 vec![
2824 MockStreamEvent::text("done"),
2825 MockStreamEvent::final_response_with_total_tokens(0),
2826 ],
2827 ]);
2828 let stream = AgentBuilder::new(model)
2829 .tool(MockBarrierTool::new(barrier))
2830 .build()
2831 .runner("hit the barrier twice")
2832 .max_turns(3)
2833 .tool_concurrency(2)
2834 .stream()
2835 .await;
2836
2837 tokio::time::timeout(
2838 std::time::Duration::from_secs(5),
2839 drive_to_final_response(stream),
2840 )
2841 .await
2842 .expect("streamed tools must run concurrently, not deadlock at the barrier");
2843 }
2844
2845 #[tokio::test]
2852 async fn stream_emits_model_tool_calls_then_atomic_execution_items() {
2853 async fn markers(concurrency: usize) -> Vec<&'static str> {
2854 let model = MockCompletionModel::from_stream_turns([
2855 vec![
2856 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
2857 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
2858 MockStreamEvent::final_response_with_total_tokens(0),
2859 ],
2860 vec![
2861 MockStreamEvent::text("done"),
2862 MockStreamEvent::final_response_with_total_tokens(0),
2863 ],
2864 ]);
2865 let mut stream = AgentBuilder::new(model)
2866 .tool(MockAddTool)
2867 .build()
2868 .runner("add two pairs")
2869 .max_turns(3)
2870 .tool_concurrency(concurrency)
2871 .stream()
2872 .await;
2873 let mut markers = Vec::new();
2874 while let Some(item) = stream.next().await {
2875 match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
2876 MultiTurnStreamItem::StreamAssistantItem(
2877 StreamedAssistantContent::ToolCall { .. },
2878 ) => markers.push("model-call"),
2879 MultiTurnStreamItem::ToolExecutionStart { .. } => markers.push("exec-start"),
2880 MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
2881 ..
2882 }) => markers.push("result"),
2883 _ => {}
2884 }
2885 }
2886 markers
2887 }
2888
2889 let expected = vec![
2892 "model-call",
2893 "model-call",
2894 "exec-start",
2895 "result",
2896 "exec-start",
2897 "result",
2898 ];
2899 assert_eq!(markers(1).await, expected);
2900 assert_eq!(markers(4).await, expected);
2901 }
2902
2903 struct TerminateAfterSiblingStartedHook {
2907 sibling_started: Arc<tokio::sync::Notify>,
2908 }
2909 impl<M: CompletionModel> AgentHook<M> for TerminateAfterSiblingStartedHook {
2910 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
2911 if let StepEvent::ToolResult { args, .. } = event
2912 && serde_json::from_str::<serde_json::Value>(args)
2913 .ok()
2914 .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
2915 == Some(1)
2916 {
2917 self.sibling_started.notified().await;
2918 return Flow::terminate("stop after a tool result");
2919 }
2920 Flow::cont()
2921 }
2922 }
2923
2924 #[derive(Clone)]
2932 struct DrainProbeTool {
2933 started: Arc<AtomicU32>,
2934 completed: Arc<AtomicU32>,
2935 slow_started: Arc<tokio::sync::Notify>,
2936 }
2937
2938 impl Tool for DrainProbeTool {
2939 const NAME: &'static str = "add";
2940 type Error = MockToolError;
2941 type Args = serde_json::Value;
2942 type Output = i32;
2943
2944 fn description(&self) -> String {
2945 MockAddTool.description()
2946 }
2947
2948 fn parameters(&self) -> serde_json::Value {
2949 MockAddTool.parameters()
2950 }
2951
2952 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
2953 self.started.fetch_add(1, SeqCst);
2954 if args.get("x").and_then(serde_json::Value::as_i64) == Some(2) {
2955 self.slow_started.notify_one();
2958 for _ in 0..8 {
2959 tokio::task::yield_now().await;
2960 }
2961 }
2962 self.completed.fetch_add(1, SeqCst);
2963 Ok(0)
2964 }
2965 }
2966
2967 #[tokio::test]
2974 async fn stream_concurrent_tool_result_terminate_drains_in_flight_siblings() {
2975 let started = Arc::new(AtomicU32::new(0));
2976 let completed = Arc::new(AtomicU32::new(0));
2977 let slow_started = Arc::new(tokio::sync::Notify::new());
2978 let model = MockCompletionModel::from_stream_turns([
2979 vec![
2980 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
2981 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
2982 MockStreamEvent::final_response_with_total_tokens(0),
2983 ],
2984 vec![
2985 MockStreamEvent::text("done"),
2986 MockStreamEvent::final_response_with_total_tokens(0),
2987 ],
2988 ]);
2989 let mut stream = AgentBuilder::new(model)
2990 .tool(DrainProbeTool {
2991 started: started.clone(),
2992 completed: completed.clone(),
2993 slow_started: slow_started.clone(),
2994 })
2995 .build()
2996 .runner("add two pairs")
2997 .max_turns(3)
2998 .tool_concurrency(2)
2999 .add_hook(TerminateAfterSiblingStartedHook {
3000 sibling_started: slow_started,
3001 })
3002 .stream()
3003 .await;
3004
3005 let (saw_error, saw_final_response) =
3006 tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3007 let mut saw_error = false;
3008 let mut saw_final_response = false;
3009 while let Some(item) = stream.next().await {
3010 match item {
3011 Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final_response = true,
3012 Ok(_) => {}
3013 Err(StreamingError::Prompt(_)) => saw_error = true,
3014 Err(other) => panic!("unexpected streaming error: {other}"),
3015 }
3016 }
3017 (saw_error, saw_final_response)
3018 })
3019 .await
3020 .expect("draining the concurrent tools must not hang");
3021
3022 assert!(
3023 saw_error,
3024 "a terminate hook on the concurrent path must surface a StreamingError::Prompt"
3025 );
3026 assert!(
3027 !saw_final_response,
3028 "a terminated run must not yield a final response"
3029 );
3030 assert_eq!(
3033 started.load(SeqCst),
3034 2,
3035 "both tools started (both in flight)"
3036 );
3037 assert_eq!(
3038 completed.load(SeqCst),
3039 2,
3040 "the in-flight sibling must be drained to completion, not cancelled"
3041 );
3042 }
3043
3044 struct OrderedTerminateHook {
3049 gate: Arc<tokio::sync::Notify>,
3050 }
3051
3052 impl<M: CompletionModel> AgentHook<M> for OrderedTerminateHook {
3053 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3054 if let StepEvent::ToolCall { args, .. } = event {
3055 let x = serde_json::from_str::<serde_json::Value>(args)
3056 .ok()
3057 .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64));
3058 match x {
3059 Some(2) => {
3060 self.gate.notify_one();
3061 return Flow::Terminate {
3062 reason: "terminated-by-tc2".to_string(),
3063 };
3064 }
3065 Some(1) => {
3066 self.gate.notified().await;
3067 return Flow::Terminate {
3068 reason: "terminated-by-tc1".to_string(),
3069 };
3070 }
3071 _ => {}
3072 }
3073 }
3074 Flow::cont()
3075 }
3076 }
3077
3078 fn two_terminating_tools_blocking_model() -> MockCompletionModel {
3079 MockCompletionModel::from_turns([
3080 MockTurn::from_contents([
3081 tool_call_content("tc1", json!({"x": 1, "y": 1})),
3082 tool_call_content("tc2", json!({"x": 2, "y": 2})),
3083 ])
3084 .expect("two tool calls is non-empty"),
3085 MockTurn::text("unreachable"),
3086 ])
3087 }
3088
3089 fn two_terminating_tools_streaming_model() -> MockCompletionModel {
3090 MockCompletionModel::from_stream_turns([
3091 vec![
3092 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3093 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3094 MockStreamEvent::final_response_with_total_tokens(0),
3095 ],
3096 vec![
3097 MockStreamEvent::text("unreachable"),
3098 MockStreamEvent::final_response_with_total_tokens(0),
3099 ],
3100 ])
3101 }
3102
3103 #[tokio::test]
3109 async fn concurrent_simultaneous_tool_terminations_pick_call_order_on_both_drivers() {
3110 let run_err = tokio::time::timeout(
3111 std::time::Duration::from_secs(5),
3112 AgentBuilder::new(two_terminating_tools_blocking_model())
3113 .tool(MockAddTool)
3114 .build()
3115 .runner("go")
3116 .max_turns(3)
3117 .tool_concurrency(2)
3118 .add_hook(OrderedTerminateHook {
3119 gate: Arc::new(tokio::sync::Notify::new()),
3120 })
3121 .run(),
3122 )
3123 .await
3124 .expect("blocking run must not hang")
3125 .expect_err("the run must terminate");
3126
3127 let mut stream = AgentBuilder::new(two_terminating_tools_streaming_model())
3128 .tool(MockAddTool)
3129 .build()
3130 .runner("go")
3131 .max_turns(3)
3132 .tool_concurrency(2)
3133 .add_hook(OrderedTerminateHook {
3134 gate: Arc::new(tokio::sync::Notify::new()),
3135 })
3136 .stream()
3137 .await;
3138
3139 let stream_err = tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3140 while let Some(item) = stream.next().await {
3141 if let Err(err) = item {
3142 return Some(err);
3143 }
3144 }
3145 None
3146 })
3147 .await
3148 .expect("streamed run must not hang")
3149 .expect("the stream must surface a terminate error");
3150
3151 let run_msg = run_err.to_string();
3152 let stream_msg = stream_err.to_string();
3153 assert!(
3154 run_msg.contains("terminated-by-tc1"),
3155 "blocking run should surface the first-called tool's reason, got: {run_msg}"
3156 );
3157 assert!(
3158 stream_msg.contains("terminated-by-tc1"),
3159 "stream should surface the first-called tool's reason, got: {stream_msg}"
3160 );
3161 assert!(
3162 !run_msg.contains("terminated-by-tc2") && !stream_msg.contains("terminated-by-tc2"),
3163 "neither driver should surface the later-completing tool's reason"
3164 );
3165 }
3166
3167 struct TerminateOnFirstToolHook;
3170 impl<M: CompletionModel> AgentHook<M> for TerminateOnFirstToolHook {
3171 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3172 if let StepEvent::ToolCall { args, .. } = event
3173 && serde_json::from_str::<serde_json::Value>(args)
3174 .ok()
3175 .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3176 == Some(1)
3177 {
3178 return Flow::Terminate {
3179 reason: "stop".to_string(),
3180 };
3181 }
3182 Flow::cont()
3183 }
3184 }
3185
3186 #[tokio::test]
3193 async fn default_concurrency_terminate_skips_remaining_tools_on_both_drivers() {
3194 let blocking_calls = Arc::new(AtomicU32::new(0));
3195 AgentBuilder::new(two_terminating_tools_blocking_model())
3196 .tool(CountingAddTool {
3197 calls: blocking_calls.clone(),
3198 })
3199 .build()
3200 .runner("go")
3201 .max_turns(3)
3202 .add_hook(TerminateOnFirstToolHook)
3203 .run()
3204 .await
3205 .expect_err("the run terminates");
3206 assert_eq!(
3207 blocking_calls.load(SeqCst),
3208 0,
3209 "fail-fast: blocking run() must not start the second tool after the first terminates"
3210 );
3211
3212 let streaming_calls = Arc::new(AtomicU32::new(0));
3213 let mut stream = AgentBuilder::new(two_terminating_tools_streaming_model())
3214 .tool(CountingAddTool {
3215 calls: streaming_calls.clone(),
3216 })
3217 .build()
3218 .runner("go")
3219 .max_turns(3)
3220 .add_hook(TerminateOnFirstToolHook)
3221 .stream()
3222 .await;
3223 let mut saw_error = false;
3224 while let Some(item) = stream.next().await {
3225 if let Err(err) = item {
3226 saw_error = true;
3227 assert!(
3228 err.to_string().contains("stop"),
3229 "stream() should surface the terminate reason, got: {err}"
3230 );
3231 break;
3232 }
3233 }
3234 assert!(saw_error, "stream() must surface the terminate error");
3235 assert_eq!(
3236 streaming_calls.load(SeqCst),
3237 0,
3238 "fail-fast: stream() must not start the second tool after the first terminates"
3239 );
3240 }
3241
3242 #[derive(Clone)]
3248 struct RecordingArgsTool {
3249 called: Arc<Mutex<Vec<i64>>>,
3250 sibling_started: Arc<tokio::sync::Notify>,
3251 }
3252
3253 impl Tool for RecordingArgsTool {
3254 const NAME: &'static str = "add";
3255 type Error = MockToolError;
3256 type Args = serde_json::Value;
3257 type Output = i32;
3258
3259 fn description(&self) -> String {
3260 MockAddTool.description()
3261 }
3262
3263 fn parameters(&self) -> serde_json::Value {
3264 MockAddTool.parameters()
3265 }
3266
3267 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
3268 let x = args.get("x").and_then(serde_json::Value::as_i64);
3269 if let Some(x) = x {
3270 self.called.lock().expect("called").push(x);
3271 }
3272 if x == Some(1) {
3273 self.sibling_started.notify_one();
3276 for _ in 0..8 {
3277 tokio::task::yield_now().await;
3278 }
3279 }
3280 Ok(0)
3281 }
3282 }
3283
3284 fn three_tools_first_terminates_streaming_model() -> MockCompletionModel {
3285 MockCompletionModel::from_stream_turns([
3286 vec![
3287 MockStreamEvent::tool_call("tc0", "add", json!({"x": 0, "y": 0})),
3292 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3293 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3294 MockStreamEvent::final_response_with_total_tokens(0),
3295 ],
3296 vec![
3297 MockStreamEvent::text("unreachable"),
3298 MockStreamEvent::final_response_with_total_tokens(0),
3299 ],
3300 ])
3301 }
3302
3303 struct TerminateOnArgZeroAfterSiblingHook {
3307 sibling_started: Arc<tokio::sync::Notify>,
3308 }
3309 impl<M: CompletionModel> AgentHook<M> for TerminateOnArgZeroAfterSiblingHook {
3310 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3311 if let StepEvent::ToolCall { args, .. } = event
3312 && serde_json::from_str::<serde_json::Value>(args)
3313 .ok()
3314 .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3315 == Some(0)
3316 {
3317 self.sibling_started.notified().await;
3318 return Flow::terminate("stop");
3319 }
3320 Flow::cont()
3321 }
3322 }
3323
3324 #[tokio::test]
3334 async fn concurrent_terminate_drops_beyond_window_sibling_but_drains_in_flight() {
3335 let called = Arc::new(Mutex::new(Vec::new()));
3336 let sibling_started = Arc::new(tokio::sync::Notify::new());
3337 let mut stream = AgentBuilder::new(three_tools_first_terminates_streaming_model())
3338 .tool(RecordingArgsTool {
3339 called: called.clone(),
3340 sibling_started: sibling_started.clone(),
3341 })
3342 .build()
3343 .runner("go")
3344 .max_turns(3)
3345 .tool_concurrency(2)
3346 .add_hook(TerminateOnArgZeroAfterSiblingHook { sibling_started })
3347 .stream()
3348 .await;
3349
3350 let (saw_error, saw_final) =
3351 tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3352 let mut saw_error = false;
3353 let mut saw_final = false;
3354 while let Some(item) = stream.next().await {
3355 match item {
3356 Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3357 Ok(_) => {}
3358 Err(_) => saw_error = true,
3359 }
3360 }
3361 (saw_error, saw_final)
3362 })
3363 .await
3364 .expect("the concurrent tool drive must not hang");
3365
3366 assert!(saw_error, "the terminated run must surface an error");
3367 assert!(
3368 !saw_final,
3369 "a terminated run must not yield a final response"
3370 );
3371 let called = called.lock().expect("called").clone();
3372 assert!(
3373 called.contains(&1),
3374 "the in-flight sibling (x==1) must be drained to completion; called args: {called:?}"
3375 );
3376 assert!(
3377 !called.contains(&2),
3378 "the not-yet-started sibling beyond the concurrency window (x==2) must be \
3379 dropped, not executed; called args: {called:?}"
3380 );
3381 assert!(
3382 !called.contains(&0),
3383 "the terminator's own body never runs (its ToolCall hook terminated); \
3384 called args: {called:?}"
3385 );
3386 }
3387
3388 #[derive(Clone)]
3392 struct SignalOnRunTool {
3393 a_ran: Arc<AtomicU32>,
3394 a_done: Arc<tokio::sync::Notify>,
3395 }
3396 impl Tool for SignalOnRunTool {
3397 const NAME: &'static str = "add";
3398 type Error = MockToolError;
3399 type Args = serde_json::Value;
3400 type Output = i32;
3401 fn description(&self) -> String {
3402 MockAddTool.description()
3403 }
3404
3405 fn parameters(&self) -> serde_json::Value {
3406 MockAddTool.parameters()
3407 }
3408 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
3409 if args.get("x").and_then(serde_json::Value::as_i64) == Some(1) {
3410 self.a_ran.fetch_add(1, SeqCst);
3411 self.a_done.notify_one();
3412 }
3413 Ok(0)
3414 }
3415 }
3416
3417 struct TerminateAfterSiblingDoneHook {
3421 a_done: Arc<tokio::sync::Notify>,
3422 }
3423 impl<M: CompletionModel> AgentHook<M> for TerminateAfterSiblingDoneHook {
3424 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3425 if let StepEvent::ToolCall { args, .. } = event
3426 && serde_json::from_str::<serde_json::Value>(args)
3427 .ok()
3428 .and_then(|v| v.get("x").and_then(serde_json::Value::as_i64))
3429 == Some(2)
3430 {
3431 self.a_done.notified().await;
3432 return Flow::terminate("stop");
3433 }
3434 Flow::cont()
3435 }
3436 }
3437
3438 #[tokio::test]
3445 async fn concurrent_termination_surfaces_no_execution_items() {
3446 let a_ran = Arc::new(AtomicU32::new(0));
3447 let a_done = Arc::new(tokio::sync::Notify::new());
3448 let model = MockCompletionModel::from_stream_turns([
3449 vec![
3450 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 1})),
3451 MockStreamEvent::tool_call("tc2", "add", json!({"x": 2, "y": 2})),
3452 MockStreamEvent::final_response_with_total_tokens(0),
3453 ],
3454 vec![
3455 MockStreamEvent::text("unreachable"),
3456 MockStreamEvent::final_response_with_total_tokens(0),
3457 ],
3458 ]);
3459 let mut stream = AgentBuilder::new(model)
3460 .tool(SignalOnRunTool {
3461 a_ran: a_ran.clone(),
3462 a_done: a_done.clone(),
3463 })
3464 .build()
3465 .runner("go")
3466 .max_turns(3)
3467 .tool_concurrency(2)
3468 .add_hook(TerminateAfterSiblingDoneHook {
3469 a_done: a_done.clone(),
3470 })
3471 .stream()
3472 .await;
3473
3474 let (exec_starts, results, saw_error, saw_final) =
3475 tokio::time::timeout(std::time::Duration::from_secs(5), async move {
3476 let (mut exec_starts, mut results, mut saw_error, mut saw_final) =
3477 (0, 0, false, false);
3478 while let Some(item) = stream.next().await {
3479 match item {
3480 Ok(MultiTurnStreamItem::ToolExecutionStart { .. }) => exec_starts += 1,
3481 Ok(MultiTurnStreamItem::StreamUserItem(
3482 StreamedUserContent::ToolResult { .. },
3483 )) => results += 1,
3484 Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3485 Ok(_) => {}
3486 Err(_) => saw_error = true,
3487 }
3488 }
3489 (exec_starts, results, saw_error, saw_final)
3490 })
3491 .await
3492 .expect("the concurrent tool drive must not hang");
3493
3494 assert!(saw_error, "the terminated run must surface an error");
3495 assert!(
3496 !saw_final,
3497 "a terminated run must not yield a final response"
3498 );
3499 assert_eq!(
3500 exec_starts, 0,
3501 "a terminated batch surfaces no ToolExecutionStart (no orphan start events)"
3502 );
3503 assert_eq!(
3504 results, 0,
3505 "a terminated batch surfaces no successful ToolResult"
3506 );
3507 assert_eq!(
3508 a_ran.load(SeqCst),
3509 1,
3510 "the fast sibling did run (its side effect happened), but its result was suppressed"
3511 );
3512 }
3513
3514 #[tokio::test]
3519 async fn stream_tool_execution_start_carries_effective_rewritten_args() {
3520 let model = MockCompletionModel::from_stream_turns([
3521 vec![
3522 MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
3523 MockStreamEvent::final_response_with_total_tokens(0),
3524 ],
3525 vec![
3526 MockStreamEvent::text("done"),
3527 MockStreamEvent::final_response_with_total_tokens(0),
3528 ],
3529 ]);
3530 let mut stream = AgentBuilder::new(model)
3531 .tool(MockAddTool)
3532 .add_hook(RewriteToolArgsHook(json!({"x": 2, "y": 40})))
3533 .build()
3534 .runner("go")
3535 .max_turns(3)
3536 .stream()
3537 .await;
3538
3539 let mut model_args = None;
3540 let mut exec_args = None;
3541 while let Some(item) = stream.next().await {
3542 match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
3543 MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
3544 tool_call,
3545 ..
3546 }) => model_args = Some(tool_call.function.arguments),
3547 MultiTurnStreamItem::ToolExecutionStart { tool_call, .. } => {
3548 exec_args = Some(tool_call.function.arguments)
3549 }
3550 _ => {}
3551 }
3552 }
3553 assert_eq!(
3554 model_args,
3555 Some(json!({"x": 2, "y": 3})),
3556 "the model tool-call event carries the model's original arguments"
3557 );
3558 assert_eq!(
3559 exec_args,
3560 Some(json!({"x": 2, "y": 40})),
3561 "the execution-start event carries the hook-rewritten (effective) arguments"
3562 );
3563 }
3564
3565 #[tokio::test]
3569 async fn stream_hook_skip_surfaces_result_without_execution_start() {
3570 struct SkipHook;
3571 impl<M: CompletionModel> AgentHook<M> for SkipHook {
3572 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3573 if let StepEvent::ToolCall { .. } = event {
3574 Flow::skip("blocked by policy")
3575 } else {
3576 Flow::cont()
3577 }
3578 }
3579 }
3580
3581 let calls = Arc::new(AtomicU32::new(0));
3582 let model = MockCompletionModel::from_stream_turns([
3583 vec![
3584 MockStreamEvent::tool_call("tc1", "add", json!({"x": 1, "y": 2})),
3585 MockStreamEvent::final_response_with_total_tokens(0),
3586 ],
3587 vec![
3588 MockStreamEvent::text("done"),
3589 MockStreamEvent::final_response_with_total_tokens(0),
3590 ],
3591 ]);
3592 let stream = AgentBuilder::new(model)
3593 .tool(CountingAddTool {
3594 calls: calls.clone(),
3595 })
3596 .add_hook(SkipHook)
3597 .build()
3598 .runner("go")
3599 .max_turns(3)
3600 .stream()
3601 .await;
3602
3603 let mut exec_starts = 0;
3604 let mut results = 0;
3605 let mut final_response = None;
3606 let mut stream = stream;
3607 while let Some(item) = stream.next().await {
3608 match item.unwrap_or_else(|err| panic!("stream item errored: {err}")) {
3609 MultiTurnStreamItem::ToolExecutionStart { .. } => exec_starts += 1,
3610 MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult { .. }) => {
3611 results += 1
3612 }
3613 MultiTurnStreamItem::FinalResponse(resp) => final_response = Some(resp),
3614 _ => {}
3615 }
3616 }
3617
3618 assert_eq!(calls.load(SeqCst), 0, "a skipped tool's body never runs");
3619 assert_eq!(
3620 exec_starts, 0,
3621 "a hook-skipped tool produces no execution-start"
3622 );
3623 assert_eq!(
3624 results, 1,
3625 "the skip result is still surfaced to the consumer"
3626 );
3627 let final_response = final_response.expect("stream should yield a final response");
3628 let history = final_response.messages().expect("history");
3630 assert!(
3631 history.iter().any(|m| serde_json::to_string(m)
3632 .map(|s| s.contains("blocked by policy"))
3633 .unwrap_or(false)),
3634 "the skip result is committed to history"
3635 );
3636 }
3637
3638 #[tokio::test]
3641 async fn required_with_empty_active_tools_errors_locally_without_provider_call() {
3642 struct EmptyActiveToolsHook;
3643 impl<M: CompletionModel> AgentHook<M> for EmptyActiveToolsHook {
3644 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3645 if let StepEvent::CompletionCall { .. } = event {
3646 Flow::patch_request(RequestPatch::new().active_tools(Vec::<String>::new()))
3647 } else {
3648 Flow::cont()
3649 }
3650 }
3651 }
3652
3653 let model = MockCompletionModel::from_turns([MockTurn::text("unreachable")]);
3654 let probe = model.clone();
3655 let err = AgentBuilder::new(model)
3656 .tool(MockAddTool)
3657 .tool_choice(ToolChoice::Required)
3658 .add_hook(EmptyActiveToolsHook)
3659 .build()
3660 .runner("go")
3661 .run()
3662 .await
3663 .expect_err("Required with an empty active_tools filter must fail locally");
3664
3665 assert!(
3666 probe.requests().is_empty(),
3667 "the request must fail locally, with no provider round-trip"
3668 );
3669 let msg = err.to_string();
3670 assert!(
3671 msg.contains("Required"),
3672 "error should mention Required: {msg}"
3673 );
3674 assert!(
3675 msg.contains("active_tools"),
3676 "error should name active_tools: {msg}"
3677 );
3678 }
3679
3680 #[tokio::test]
3683 async fn specific_naming_filtered_out_tool_errors_locally_without_provider_call() {
3684 struct FilterToAddHook;
3685 impl<M: CompletionModel> AgentHook<M> for FilterToAddHook {
3686 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3687 if let StepEvent::CompletionCall { .. } = event {
3688 Flow::patch_request(RequestPatch::new().active_tools(["add"]))
3689 } else {
3690 Flow::cont()
3691 }
3692 }
3693 }
3694
3695 let model = MockCompletionModel::from_turns([MockTurn::text("unreachable")]);
3696 let probe = model.clone();
3697 let err = AgentBuilder::new(model)
3698 .tool(MockAddTool)
3699 .tool(MockSubtractTool)
3700 .tool_choice(ToolChoice::Specific {
3701 function_names: vec!["subtract".to_string()],
3702 })
3703 .add_hook(FilterToAddHook)
3704 .build()
3705 .runner("go")
3706 .run()
3707 .await
3708 .expect_err("Specific naming a filtered-out tool must fail locally");
3709
3710 assert!(
3711 probe.requests().is_empty(),
3712 "the request must fail locally, with no provider round-trip"
3713 );
3714 let msg = err.to_string();
3715 assert!(
3716 msg.contains("subtract"),
3717 "error should name the missing tool: {msg}"
3718 );
3719 assert!(
3720 msg.contains("active_tools"),
3721 "error should name active_tools: {msg}"
3722 );
3723 }
3724
3725 #[tokio::test]
3730 async fn tool_concurrency_zero_is_clamped_and_does_not_hang() {
3731 let model = MockCompletionModel::from_turns([
3732 MockTurn::tool_call("tc1", "add", json!({"x": 1, "y": 2})),
3733 MockTurn::text("done"),
3734 ]);
3735 let run = AgentBuilder::new(model)
3736 .tool(MockAddTool)
3737 .build()
3738 .runner("add")
3739 .max_turns(3)
3740 .tool_concurrency(0)
3741 .run();
3742
3743 let response = tokio::time::timeout(std::time::Duration::from_secs(5), run)
3744 .await
3745 .expect("tool_concurrency(0) must clamp to 1, not hang on buffer_unordered(0)")
3746 .expect("run should succeed");
3747 assert_eq!(response.output, "done");
3748 }
3749
3750 #[derive(Clone)]
3752 struct CountingAddTool {
3753 calls: Arc<AtomicU32>,
3754 }
3755
3756 impl Tool for CountingAddTool {
3757 const NAME: &'static str = "add";
3758 type Error = MockToolError;
3759 type Args = MockOperationArgs;
3760 type Output = i32;
3761
3762 fn description(&self) -> String {
3763 MockAddTool.description()
3764 }
3765
3766 fn parameters(&self) -> serde_json::Value {
3767 MockAddTool.parameters()
3768 }
3769
3770 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
3771 self.calls.fetch_add(1, SeqCst);
3772 Ok(0)
3773 }
3774 }
3775
3776 struct FailOnToolCallHook;
3777 impl<M: CompletionModel> AgentHook<M> for FailOnToolCallHook {
3778 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3779 if let StepEvent::ToolCall { .. } = event {
3780 Flow::fail()
3783 } else {
3784 Flow::cont()
3785 }
3786 }
3787 }
3788
3789 #[tokio::test]
3793 async fn tool_call_fail_is_fail_closed() {
3794 let calls = Arc::new(AtomicU32::new(0));
3795 let model = MockCompletionModel::from_turns([MockTurn::tool_call(
3796 "tc1",
3797 "add",
3798 json!({"x": 1, "y": 2}),
3799 )]);
3800 let agent = AgentBuilder::new(model)
3801 .tool(CountingAddTool {
3802 calls: calls.clone(),
3803 })
3804 .build();
3805
3806 let err = agent
3807 .runner("add")
3808 .max_turns(2)
3809 .add_hook(FailOnToolCallHook)
3810 .run()
3811 .await
3812 .expect_err("fail-closed: the run must error rather than execute the tool");
3813
3814 assert!(matches!(err, PromptError::PromptCancelled { .. }));
3815 assert_eq!(
3816 calls.load(SeqCst),
3817 0,
3818 "tool must not execute when a hook returns Fail for a tool call"
3819 );
3820 }
3821
3822 #[derive(Clone, Default)]
3823 struct ToolOnlyHook {
3824 text_delta_calls: Arc<AtomicU32>,
3825 other_calls: Arc<AtomicU32>,
3826 }
3827
3828 impl<M: CompletionModel> AgentHook<M> for ToolOnlyHook {
3829 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3830 match event.kind() {
3831 StepEventKind::TextDelta => {
3832 self.text_delta_calls.fetch_add(1, SeqCst);
3833 }
3834 _ => {
3835 self.other_calls.fetch_add(1, SeqCst);
3836 }
3837 }
3838 Flow::cont()
3839 }
3840
3841 fn observes(&self, kind: StepEventKind) -> bool {
3842 kind != StepEventKind::TextDelta
3843 }
3844 }
3845
3846 #[tokio::test]
3850 async fn observes_gates_text_delta_dispatch() {
3851 let model = MockCompletionModel::from_stream_turns([vec![
3852 MockStreamEvent::text("hel"),
3853 MockStreamEvent::text("lo"),
3854 MockStreamEvent::final_response_with_total_tokens(0),
3855 ]]);
3856 let hook = ToolOnlyHook::default();
3857 let mut stream = AgentBuilder::new(model)
3858 .build()
3859 .runner("hi")
3860 .add_hook(hook.clone())
3861 .stream()
3862 .await;
3863 while stream.next().await.is_some() {}
3864
3865 assert_eq!(
3866 hook.text_delta_calls.load(SeqCst),
3867 0,
3868 "a hook that does not observe TextDelta must not be dispatched for it"
3869 );
3870 assert!(
3871 hook.other_calls.load(SeqCst) > 0,
3872 "the hook should still receive the events it observes"
3873 );
3874 }
3875
3876 struct TerminateOn(StepEventKind);
3879
3880 impl<M: CompletionModel> AgentHook<M> for TerminateOn {
3881 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
3882 if event.kind() == self.0 {
3883 Flow::terminate("stop here")
3884 } else {
3885 Flow::cont()
3886 }
3887 }
3888 }
3889
3890 #[tokio::test]
3894 async fn run_terminates_from_each_shared_event() {
3895 for kind in [
3896 StepEventKind::CompletionCall,
3897 StepEventKind::CompletionResponse,
3898 StepEventKind::ToolCall,
3899 StepEventKind::ToolResult,
3900 ] {
3901 let err = AgentBuilder::new(blocking_model())
3902 .tool(MockAddTool)
3903 .build()
3904 .runner("add 2 and 3")
3905 .max_turns(3)
3906 .add_hook(TerminateOn(kind))
3907 .run()
3908 .await
3909 .expect_err(&format!("terminate at {kind:?} must cancel the run"));
3910 assert!(
3911 matches!(err, PromptError::PromptCancelled { .. }),
3912 "terminate at {kind:?} should cancel the run, got {err:?}"
3913 );
3914 }
3915 }
3916
3917 #[tokio::test]
3921 async fn stream_terminates_from_each_shared_event() {
3922 for kind in [
3923 StepEventKind::CompletionCall,
3924 StepEventKind::ToolCall,
3925 StepEventKind::ToolResult,
3926 ] {
3927 let mut stream = AgentBuilder::new(streaming_model())
3928 .tool(MockAddTool)
3929 .build()
3930 .runner("add 2 and 3")
3931 .max_turns(3)
3932 .add_hook(TerminateOn(kind))
3933 .stream()
3934 .await;
3935
3936 let mut saw_error = false;
3937 let mut saw_final = false;
3938 while let Some(item) = stream.next().await {
3939 match item {
3940 Ok(MultiTurnStreamItem::FinalResponse(_)) => saw_final = true,
3941 Err(_) => saw_error = true,
3942 _ => {}
3943 }
3944 }
3945 assert!(saw_error, "terminate at {kind:?} must yield a stream error");
3946 assert!(
3947 !saw_final,
3948 "terminate at {kind:?} must not also produce a final response"
3949 );
3950 }
3951 }
3952
3953 #[tokio::test]
3957 async fn multi_hook_stack_parity_across_run_and_stream() {
3958 let a_block = RecordingHook::default();
3959 let b_block = RecordingHook::default();
3960 let blocking = AgentBuilder::new(blocking_model())
3961 .tool(MockAddTool)
3962 .build()
3963 .runner("add 2 and 3")
3964 .max_turns(3)
3965 .add_hook(a_block.clone())
3966 .add_hook(b_block.clone())
3967 .run()
3968 .await
3969 .expect("blocking run should succeed");
3970
3971 let a_stream = RecordingHook::default();
3972 let b_stream = RecordingHook::default();
3973 let mut stream = AgentBuilder::new(streaming_model())
3974 .tool(MockAddTool)
3975 .build()
3976 .runner("add 2 and 3")
3977 .max_turns(3)
3978 .add_hook(a_stream.clone())
3979 .add_hook(b_stream.clone())
3980 .stream()
3981 .await;
3982 while stream.next().await.is_some() {}
3983
3984 assert_eq!(a_block.shared_events(), b_block.shared_events());
3986 assert_eq!(a_stream.shared_events(), b_stream.shared_events());
3987 assert_eq!(a_block.shared_events(), a_stream.shared_events());
3989 assert_eq!(
3990 a_block.shared_events(),
3991 vec![
3992 StepEventKind::CompletionCall,
3993 StepEventKind::ToolCall,
3994 StepEventKind::ToolResult,
3995 StepEventKind::CompletionCall,
3996 ]
3997 );
3998 assert_eq!(blocking.output, "the answer is 5");
3999 }
4000
4001 struct RepairInvalidToHook(&'static str);
4003
4004 impl<M: CompletionModel> AgentHook<M> for RepairInvalidToHook {
4005 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4006 if let StepEvent::InvalidToolCall(_) = event {
4007 Flow::repair(self.0)
4008 } else {
4009 Flow::cont()
4010 }
4011 }
4012 }
4013
4014 #[tokio::test]
4018 async fn invalid_tool_call_repair_parity_across_run_and_stream() {
4019 let blocking_model = MockCompletionModel::from_turns([
4020 MockTurn::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4021 MockTurn::text("the answer is 5"),
4022 ]);
4023 let blocking_hook = RecordingHook::default();
4024 let blocking = AgentBuilder::new(blocking_model)
4025 .tool(MockAddTool)
4026 .build()
4027 .runner("add 2 and 3")
4028 .max_turns(3)
4029 .add_hook(blocking_hook.clone())
4030 .add_hook(RepairInvalidToHook("add"))
4031 .run()
4032 .await
4033 .expect("blocking run should recover via repair");
4034
4035 let streaming_model = MockCompletionModel::from_stream_turns([
4041 vec![
4042 MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4043 MockStreamEvent::final_response_with_total_tokens(0),
4044 ],
4045 vec![
4046 MockStreamEvent::text("the answer is 5"),
4047 MockStreamEvent::final_response_with_total_tokens(0),
4048 ],
4049 ]);
4050 let streaming_hook = RecordingHook::default();
4051 let mut stream = AgentBuilder::new(streaming_model)
4052 .tool(MockAddTool)
4053 .build()
4054 .runner("add 2 and 3")
4055 .max_turns(3)
4056 .add_hook(streaming_hook.clone())
4057 .add_hook(RepairInvalidToHook("add"))
4058 .stream()
4059 .await;
4060 let mut final_response = None;
4061 while let Some(item) = stream.next().await {
4062 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4063 item.map_err(|err| panic!("stream item errored: {err}"))
4064 {
4065 final_response = Some(resp);
4066 }
4067 }
4068 let final_response =
4069 final_response.expect("stream should recover and yield a final response");
4070
4071 assert_eq!(blocking.output, "the answer is 5");
4073 assert_eq!(final_response.output(), blocking.output);
4074
4075 assert_eq!(
4078 blocking_hook.shared_events(),
4079 streaming_hook.shared_events()
4080 );
4081 assert!(
4082 blocking_hook
4083 .shared_events()
4084 .contains(&StepEventKind::InvalidToolCall),
4085 "the hook must observe the invalid tool call"
4086 );
4087 assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4088 assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
4089
4090 let blocking_messages = blocking.messages.expect("blocking messages");
4092 let streaming_messages = final_response
4093 .messages()
4094 .expect("streaming history")
4095 .to_vec();
4096 assert_eq!(
4097 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4098 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4099 );
4100 }
4101
4102 #[derive(Clone)]
4124 struct ScriptedToolCall {
4125 id: &'static str,
4126 name: &'static str,
4127 args: serde_json::Value,
4128 }
4129
4130 #[derive(Clone)]
4133 enum ScriptedTurn {
4134 Text(&'static str),
4136 ToolCalls(Vec<ScriptedToolCall>),
4138 }
4139
4140 #[derive(Clone, Copy)]
4146 enum StreamShape {
4147 Complete,
4149 Chunked,
4152 }
4153
4154 impl ScriptedTurn {
4155 fn as_blocking_turn(&self) -> MockTurn {
4156 match self {
4157 ScriptedTurn::Text(text) => MockTurn::text(*text),
4158 ScriptedTurn::ToolCalls(calls) => {
4159 MockTurn::from_contents(calls.iter().map(|call| {
4160 AssistantContent::ToolCall(ToolCall::new(
4161 call.id.to_string(),
4162 ToolFunction::new(call.name.to_string(), call.args.clone()),
4163 ))
4164 }))
4165 .expect("a scripted tool-call turn has at least one call")
4166 }
4167 }
4168 }
4169
4170 fn as_stream_events(&self, shape: StreamShape) -> Vec<MockStreamEvent> {
4171 let mut events = Vec::new();
4172 match self {
4173 ScriptedTurn::Text(text) => events.push(MockStreamEvent::text(*text)),
4174 ScriptedTurn::ToolCalls(calls) => {
4175 for call in calls {
4176 if let StreamShape::Chunked = shape {
4177 let internal = format!("ic-{}", call.id);
4181 let args = serde_json::to_string(&call.args)
4182 .expect("scripted args serialize to json");
4183 events.push(MockStreamEvent::tool_call_name_delta(
4184 call.id, &internal, call.name,
4185 ));
4186 events.push(MockStreamEvent::tool_call_arguments_delta(
4187 call.id, &internal, &args,
4188 ));
4189 }
4190 events.push(MockStreamEvent::tool_call(
4191 call.id,
4192 call.name,
4193 call.args.clone(),
4194 ));
4195 }
4196 }
4197 }
4198 events.push(MockStreamEvent::final_response_with_total_tokens(0));
4199 events
4200 }
4201 }
4202
4203 struct ParityOutcome {
4206 output: String,
4207 messages: Vec<Message>,
4208 shared_events: Vec<StepEventKind>,
4209 tool_results: Vec<String>,
4210 }
4211
4212 async fn run_blocking_scenario(prompt: &'static str, turns: &[ScriptedTurn]) -> ParityOutcome {
4213 let model =
4214 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4215 let hook = RecordingHook::default();
4216 let response = AgentBuilder::new(model)
4217 .tool(MockAddTool)
4218 .build()
4219 .runner(prompt)
4220 .max_turns(8)
4221 .add_hook(hook.clone())
4222 .run()
4223 .await
4224 .expect("blocking scenario should succeed");
4225 ParityOutcome {
4226 output: response.output,
4227 messages: response.messages.expect("blocking messages"),
4228 shared_events: hook.shared_events(),
4229 tool_results: hook.tool_results(),
4230 }
4231 }
4232
4233 async fn run_streaming_scenario(
4234 prompt: &'static str,
4235 turns: &[ScriptedTurn],
4236 shape: StreamShape,
4237 ) -> ParityOutcome {
4238 let model = MockCompletionModel::from_stream_turns(
4239 turns.iter().map(|turn| turn.as_stream_events(shape)),
4240 );
4241 let hook = RecordingHook::default();
4242 let mut stream = AgentBuilder::new(model)
4243 .tool(MockAddTool)
4244 .build()
4245 .runner(prompt)
4246 .max_turns(8)
4247 .add_hook(hook.clone())
4248 .stream()
4249 .await;
4250 let mut final_response = None;
4251 while let Some(item) = stream.next().await {
4252 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4253 item.map_err(|err| panic!("stream item errored: {err}"))
4254 {
4255 final_response = Some(resp);
4256 }
4257 }
4258 let final_response =
4259 final_response.expect("streaming scenario should yield a final response");
4260 ParityOutcome {
4261 output: final_response.output().to_string(),
4262 messages: final_response
4263 .messages()
4264 .expect("streaming history")
4265 .to_vec(),
4266 shared_events: hook.shared_events(),
4267 tool_results: hook.tool_results(),
4268 }
4269 }
4270
4271 fn assert_outcomes_match(blocking: &ParityOutcome, streaming: &ParityOutcome, label: &str) {
4272 assert_eq!(
4273 blocking.output, streaming.output,
4274 "{label}: final output diverged"
4275 );
4276 assert_eq!(
4277 blocking.shared_events, streaming.shared_events,
4278 "{label}: hook event sequence diverged"
4279 );
4280 assert_eq!(
4281 blocking.tool_results, streaming.tool_results,
4282 "{label}: tool-result content diverged"
4283 );
4284 assert_eq!(
4285 serde_json::to_value(&blocking.messages).expect("serialize blocking"),
4286 serde_json::to_value(&streaming.messages).expect("serialize streaming"),
4287 "{label}: message history diverged"
4288 );
4289 }
4290
4291 async fn assert_run_stream_parity(prompt: &'static str, turns: &[ScriptedTurn]) {
4296 let blocking = run_blocking_scenario(prompt, turns).await;
4297 for (shape, label) in [
4298 (StreamShape::Complete, "complete-stream"),
4299 (StreamShape::Chunked, "chunked-stream"),
4300 ] {
4301 let streaming = run_streaming_scenario(prompt, turns, shape).await;
4302 assert_outcomes_match(&blocking, &streaming, label);
4303 }
4304 }
4305
4306 fn add_call(id: &'static str, x: i64, y: i64) -> ScriptedToolCall {
4307 ScriptedToolCall {
4308 id,
4309 name: "add",
4310 args: json!({ "x": x, "y": y }),
4311 }
4312 }
4313
4314 #[tokio::test]
4315 async fn parity_text_only_run() {
4316 assert_run_stream_parity("just say hi", &[ScriptedTurn::Text("hi there")]).await;
4317 }
4318
4319 #[tokio::test]
4320 async fn parity_single_tool_then_text() {
4321 assert_run_stream_parity(
4322 "add 2 and 3",
4323 &[
4324 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4325 ScriptedTurn::Text("the answer is 5"),
4326 ],
4327 )
4328 .await;
4329 }
4330
4331 #[tokio::test]
4332 async fn parity_multiple_tools_in_one_turn() {
4333 assert_run_stream_parity(
4334 "add two pairs",
4335 &[
4336 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3), add_call("tc2", 10, 20)]),
4337 ScriptedTurn::Text("done"),
4338 ],
4339 )
4340 .await;
4341 }
4342
4343 #[tokio::test]
4344 async fn parity_multi_turn_sequential_tools() {
4345 assert_run_stream_parity(
4346 "chain two additions",
4347 &[
4348 ScriptedTurn::ToolCalls(vec![add_call("tc1", 1, 1)]),
4349 ScriptedTurn::ToolCalls(vec![add_call("tc2", 2, 2)]),
4350 ScriptedTurn::Text("chained"),
4351 ],
4352 )
4353 .await;
4354 }
4355
4356 struct SkipInvalidHook(&'static str);
4359
4360 impl<M: CompletionModel> AgentHook<M> for SkipInvalidHook {
4361 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4362 if let StepEvent::InvalidToolCall(_) = event {
4363 Flow::skip(self.0)
4364 } else {
4365 Flow::cont()
4366 }
4367 }
4368 }
4369
4370 #[tokio::test]
4375 async fn invalid_tool_call_skip_parity_across_run_and_stream() {
4376 let blocking_model = MockCompletionModel::from_turns([
4377 MockTurn::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4378 MockTurn::text("acknowledged"),
4379 ]);
4380 let blocking_hook = RecordingHook::default();
4381 let blocking = AgentBuilder::new(blocking_model)
4382 .tool(MockAddTool)
4383 .build()
4384 .runner("do the thing")
4385 .max_turns(3)
4386 .add_hook(blocking_hook.clone())
4387 .add_hook(SkipInvalidHook("tool not permitted"))
4388 .run()
4389 .await
4390 .expect("blocking run should recover via skip");
4391
4392 let streaming_model = MockCompletionModel::from_stream_turns([
4395 vec![
4396 MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4397 MockStreamEvent::final_response_with_total_tokens(0),
4398 ],
4399 vec![
4400 MockStreamEvent::text("acknowledged"),
4401 MockStreamEvent::final_response_with_total_tokens(0),
4402 ],
4403 ]);
4404 let streaming_hook = RecordingHook::default();
4405 let mut stream = AgentBuilder::new(streaming_model)
4406 .tool(MockAddTool)
4407 .build()
4408 .runner("do the thing")
4409 .max_turns(3)
4410 .add_hook(streaming_hook.clone())
4411 .add_hook(SkipInvalidHook("tool not permitted"))
4412 .stream()
4413 .await;
4414 let mut final_response = None;
4415 while let Some(item) = stream.next().await {
4416 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4417 item.map_err(|err| panic!("stream item errored: {err}"))
4418 {
4419 final_response = Some(resp);
4420 }
4421 }
4422 let final_response =
4423 final_response.expect("stream should recover and yield a final response");
4424
4425 assert_eq!(blocking.output, "acknowledged");
4426 assert_eq!(final_response.output(), blocking.output);
4427 assert_eq!(
4428 blocking_hook.shared_events(),
4429 streaming_hook.shared_events()
4430 );
4431 assert!(
4432 blocking_hook
4433 .shared_events()
4434 .contains(&StepEventKind::InvalidToolCall),
4435 "the hook must observe the invalid tool call"
4436 );
4437
4438 let blocking_messages = blocking.messages.expect("blocking messages");
4439 let streaming_messages = final_response
4440 .messages()
4441 .expect("streaming history")
4442 .to_vec();
4443 assert_eq!(
4444 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4445 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4446 );
4447 assert!(
4450 tool_result_text_in_history(&blocking_messages, "tool not permitted"),
4451 "the verbatim invalid-tool skip reason must be the tool result content"
4452 );
4453 }
4454
4455 #[tokio::test]
4462 async fn recovered_turn_suppresses_response_finish_hook_on_both_drivers() {
4463 let blocking_model = MockCompletionModel::from_turns([
4467 MockTurn::from_contents([
4468 AssistantContent::text("let me compute that"),
4469 AssistantContent::ToolCall(ToolCall::new(
4470 "tc1".to_string(),
4471 ToolFunction::new("default_api".to_string(), json!({"x": 2, "y": 3})),
4472 )),
4473 ])
4474 .expect("a text + tool-call turn is valid"),
4475 MockTurn::text("the answer is 5"),
4476 ]);
4477 let blocking_hook = RecordingHook::default();
4478 let blocking = AgentBuilder::new(blocking_model)
4479 .tool(MockAddTool)
4480 .build()
4481 .runner("compute")
4482 .max_turns(3)
4483 .add_hook(blocking_hook.clone())
4484 .add_hook(RepairInvalidToHook("add"))
4485 .run()
4486 .await
4487 .expect("blocking run should recover via repair");
4488
4489 let streaming_model = MockCompletionModel::from_stream_turns([
4490 vec![
4491 MockStreamEvent::text("let me compute that"),
4492 MockStreamEvent::tool_call("tc1", "default_api", json!({"x": 2, "y": 3})),
4493 MockStreamEvent::final_response_with_total_tokens(0),
4494 ],
4495 vec![
4496 MockStreamEvent::text("the answer is 5"),
4497 MockStreamEvent::final_response_with_total_tokens(0),
4498 ],
4499 ]);
4500 let streaming_hook = RecordingHook::default();
4501 let mut stream = AgentBuilder::new(streaming_model)
4502 .tool(MockAddTool)
4503 .build()
4504 .runner("compute")
4505 .max_turns(3)
4506 .add_hook(streaming_hook.clone())
4507 .add_hook(RepairInvalidToHook("add"))
4508 .stream()
4509 .await;
4510 while stream.next().await.is_some() {}
4511
4512 assert_eq!(blocking.output, "the answer is 5");
4514
4515 assert_eq!(
4518 blocking_hook.count(StepEventKind::CompletionResponse),
4519 1,
4520 "the recovered turn must not fire CompletionResponse"
4521 );
4522 assert_eq!(
4525 streaming_hook.count(StepEventKind::StreamResponseFinish),
4526 1,
4527 "the recovered turn must not fire StreamResponseFinish"
4528 );
4529 assert_eq!(
4532 blocking_hook.count(StepEventKind::CompletionResponse),
4533 streaming_hook.count(StepEventKind::StreamResponseFinish),
4534 );
4535
4536 assert_eq!(
4543 blocking_hook.count(StepEventKind::ModelTurnFinished),
4544 1,
4545 "the recovered turn must not fire ModelTurnFinished"
4546 );
4547 assert_eq!(
4548 streaming_hook.count(StepEventKind::ModelTurnFinished),
4549 1,
4550 "the recovered turn must not fire ModelTurnFinished on the streaming surface either"
4551 );
4552 assert_eq!(
4555 blocking_hook.count(StepEventKind::ModelTurnFinished),
4556 streaming_hook.count(StepEventKind::ModelTurnFinished),
4557 );
4558 }
4559
4560 #[tokio::test]
4565 async fn runner_add_hook_appends_to_agent_default_hooks() {
4566 let agent_hook = RecordingHook::default();
4567 let runner_hook = RecordingHook::default();
4568
4569 AgentBuilder::new(blocking_model())
4573 .tool(MockAddTool)
4574 .add_hook(agent_hook.clone())
4575 .build()
4576 .runner("add 2 and 3")
4577 .max_turns(3)
4578 .add_hook(runner_hook.clone())
4579 .run()
4580 .await
4581 .expect("run should succeed");
4582
4583 assert!(
4584 agent_hook.count(StepEventKind::CompletionCall) >= 1,
4585 "the agent-default hook must still observe the run after a runner-level add_hook"
4586 );
4587 assert!(
4588 runner_hook.count(StepEventKind::CompletionCall) >= 1,
4589 "the runner-level hook must also observe the run"
4590 );
4591 assert_eq!(
4594 agent_hook.count(StepEventKind::CompletionCall),
4595 runner_hook.count(StepEventKind::CompletionCall),
4596 "add_hook appends (both hooks observe every turn); it does not replace"
4597 );
4598 }
4599
4600 struct SkipToolCallHook(&'static str);
4602
4603 impl<M: CompletionModel> AgentHook<M> for SkipToolCallHook {
4604 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4605 if let StepEvent::ToolCall { .. } = event {
4606 Flow::skip(self.0)
4607 } else {
4608 Flow::cont()
4609 }
4610 }
4611 }
4612
4613 #[tokio::test]
4619 async fn valid_tool_call_skip_parity_across_run_and_stream() {
4620 let turns = [
4621 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4622 ScriptedTurn::Text("acknowledged"),
4623 ];
4624
4625 let blocking_model =
4626 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4627 let blocking_hook = RecordingHook::default();
4628 let blocking = AgentBuilder::new(blocking_model)
4629 .tool(MockAddTool)
4630 .build()
4631 .runner("add 2 and 3")
4632 .max_turns(3)
4633 .add_hook(blocking_hook.clone())
4634 .add_hook(SkipToolCallHook("skipped by policy"))
4635 .run()
4636 .await
4637 .expect("blocking run should succeed with a skipped tool call");
4638
4639 let streaming_model = MockCompletionModel::from_stream_turns(
4640 turns
4641 .iter()
4642 .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4643 );
4644 let streaming_hook = RecordingHook::default();
4645 let mut stream = AgentBuilder::new(streaming_model)
4646 .tool(MockAddTool)
4647 .build()
4648 .runner("add 2 and 3")
4649 .max_turns(3)
4650 .add_hook(streaming_hook.clone())
4651 .add_hook(SkipToolCallHook("skipped by policy"))
4652 .stream()
4653 .await;
4654 let mut final_response = None;
4655 while let Some(item) = stream.next().await {
4656 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4657 item.map_err(|err| panic!("stream item errored: {err}"))
4658 {
4659 final_response = Some(resp);
4660 }
4661 }
4662 let final_response = final_response.expect("stream should yield a final response");
4663
4664 assert_eq!(blocking.output, "acknowledged");
4665 assert_eq!(final_response.output(), blocking.output);
4666 assert_eq!(
4667 blocking_hook.shared_events(),
4668 streaming_hook.shared_events()
4669 );
4670 assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4674 assert_eq!(
4675 blocking_hook.tool_results(),
4676 vec!["skipped by policy".to_string()],
4677 "a skipped tool fires a ToolResult hook with the verbatim skip reason"
4678 );
4679
4680 let blocking_messages = blocking.messages.expect("blocking messages");
4681 let streaming_messages = final_response
4682 .messages()
4683 .expect("streaming history")
4684 .to_vec();
4685 assert_eq!(
4686 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4687 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4688 );
4689 assert!(
4692 tool_result_text_in_history(&blocking_messages, "skipped by policy"),
4693 "the verbatim skip reason must be the tool result content in the history"
4694 );
4695 }
4696
4697 struct RewriteToolArgsHook(serde_json::Value);
4701
4702 impl<M: CompletionModel> AgentHook<M> for RewriteToolArgsHook {
4703 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4704 if let StepEvent::ToolCall { .. } = event {
4705 Flow::rewrite_args(self.0.clone())
4706 } else {
4707 Flow::cont()
4708 }
4709 }
4710 }
4711
4712 #[test]
4716 fn rewrite_args_resolves_to_proceed_with_for_tool_call() {
4717 let args = json!({"x": 1, "y": 2});
4718 match super::flow_into_tool_call(Flow::rewrite_args(args.clone())) {
4719 super::ToolCallDecision::ProceedWith(replacement) => assert_eq!(replacement, args),
4720 _ => panic!("RewriteArgs should resolve to ProceedWith for a tool call"),
4721 }
4722 assert_eq!(
4723 super::flow_name(&Flow::rewrite_args(json!({}))),
4724 "RewriteArgs"
4725 );
4726 assert_eq!(
4728 Flow::try_rewrite_args(&json!({"x": 1, "y": 2})).expect("serializes"),
4729 Flow::rewrite_args(json!({"x": 1, "y": 2})),
4730 );
4731 }
4732
4733 #[test]
4736 fn rewrite_args_is_fail_closed_off_the_tool_call_event() {
4737 assert!(matches!(
4739 super::flow_into_invalid(Flow::rewrite_args(json!({}))),
4740 super::InvalidDecision::Terminate(_)
4741 ));
4742 assert!(super::observe_flow(Flow::rewrite_args(json!({}))).is_some());
4744 }
4745
4746 #[tokio::test]
4753 async fn valid_tool_call_rewrite_args_parity_across_run_and_stream() {
4754 let turns = [
4757 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4758 ScriptedTurn::Text("acknowledged"),
4759 ];
4760 let replacement = json!({"x": 2, "y": 40});
4761
4762 let blocking_model =
4763 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4764 let blocking_hook = RecordingHook::default();
4765 let blocking = AgentBuilder::new(blocking_model)
4766 .tool(MockAddTool)
4767 .build()
4768 .runner("add 2 and 3")
4769 .max_turns(3)
4770 .add_hook(blocking_hook.clone())
4771 .add_hook(RewriteToolArgsHook(replacement.clone()))
4772 .run()
4773 .await
4774 .expect("blocking run should succeed with rewritten tool arguments");
4775
4776 let streaming_model = MockCompletionModel::from_stream_turns(
4777 turns
4778 .iter()
4779 .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4780 );
4781 let streaming_hook = RecordingHook::default();
4782 let mut stream = AgentBuilder::new(streaming_model)
4783 .tool(MockAddTool)
4784 .build()
4785 .runner("add 2 and 3")
4786 .max_turns(3)
4787 .add_hook(streaming_hook.clone())
4788 .add_hook(RewriteToolArgsHook(replacement))
4789 .stream()
4790 .await;
4791 let mut final_response = None;
4792 while let Some(item) = stream.next().await {
4793 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4794 item.map_err(|err| panic!("stream item errored: {err}"))
4795 {
4796 final_response = Some(resp);
4797 }
4798 }
4799 let final_response = final_response.expect("stream should yield a final response");
4800
4801 assert_eq!(blocking_hook.tool_results(), vec!["42".to_string()]);
4804 assert_eq!(blocking.output, "acknowledged");
4805 assert_eq!(final_response.output(), blocking.output);
4806 assert_eq!(
4807 blocking_hook.shared_events(),
4808 streaming_hook.shared_events()
4809 );
4810 assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4811 }
4812
4813 struct RewriteToolResultHook(&'static str);
4817
4818 impl<M: CompletionModel> AgentHook<M> for RewriteToolResultHook {
4819 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4820 if let StepEvent::ToolResult { .. } = event {
4821 Flow::rewrite_result(self.0)
4822 } else {
4823 Flow::cont()
4824 }
4825 }
4826 }
4827
4828 #[test]
4831 fn rewrite_result_resolves_to_replace_for_tool_result() {
4832 match super::flow_into_tool_result(Flow::rewrite_result("redacted")) {
4833 super::ToolResultDecision::Replace(result) => assert_eq!(result, "redacted"),
4834 _ => panic!("RewriteResult should resolve to Replace for a tool result"),
4835 }
4836 assert_eq!(
4837 super::flow_name(&Flow::rewrite_result("x")),
4838 "RewriteResult"
4839 );
4840 }
4841
4842 #[test]
4846 fn rewrite_result_is_fail_closed_off_the_tool_result_event() {
4847 assert!(matches!(
4849 super::flow_into_invalid(Flow::rewrite_result("x")),
4850 super::InvalidDecision::Terminate(_)
4851 ));
4852 assert!(super::observe_flow(Flow::rewrite_result("x")).is_some());
4854 assert!(matches!(
4857 super::flow_into_tool_result(Flow::rewrite_args(json!({}))),
4858 super::ToolResultDecision::Terminate(_)
4859 ));
4860 }
4861
4862 #[tokio::test]
4869 async fn valid_tool_result_rewrite_parity_across_run_and_stream() {
4870 let turns = [
4873 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4874 ScriptedTurn::Text("acknowledged"),
4875 ];
4876
4877 let blocking_model =
4878 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4879 let blocking_hook = RecordingHook::default();
4880 let blocking = AgentBuilder::new(blocking_model)
4881 .tool(MockAddTool)
4882 .build()
4883 .runner("add 2 and 3")
4884 .max_turns(3)
4885 .add_hook(blocking_hook.clone())
4886 .add_hook(RewriteToolResultHook("redacted-result"))
4887 .run()
4888 .await
4889 .expect("blocking run should succeed with a rewritten tool result");
4890
4891 let streaming_model = MockCompletionModel::from_stream_turns(
4892 turns
4893 .iter()
4894 .map(|turn| turn.as_stream_events(StreamShape::Complete)),
4895 );
4896 let streaming_hook = RecordingHook::default();
4897 let mut stream = AgentBuilder::new(streaming_model)
4898 .tool(MockAddTool)
4899 .build()
4900 .runner("add 2 and 3")
4901 .max_turns(3)
4902 .add_hook(streaming_hook.clone())
4903 .add_hook(RewriteToolResultHook("redacted-result"))
4904 .stream()
4905 .await;
4906 let mut final_response = None;
4907 while let Some(item) = stream.next().await {
4908 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
4909 item.map_err(|err| panic!("stream item errored: {err}"))
4910 {
4911 final_response = Some(resp);
4912 }
4913 }
4914 let final_response = final_response.expect("stream should yield a final response");
4915
4916 assert_eq!(blocking.output, "acknowledged");
4917 assert_eq!(final_response.output(), blocking.output);
4918
4919 assert_eq!(blocking_hook.tool_results(), vec!["5".to_string()]);
4922 assert_eq!(blocking_hook.tool_results(), streaming_hook.tool_results());
4923
4924 let blocking_messages = blocking.messages.expect("blocking messages");
4927 let streaming_messages = final_response
4928 .messages()
4929 .expect("streaming history")
4930 .to_vec();
4931 assert_eq!(
4932 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
4933 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
4934 );
4935 assert!(
4936 tool_result_text_in_history(&blocking_messages, "redacted-result"),
4937 "the model-visible tool result must be the hook's replacement"
4938 );
4939 assert!(
4940 !tool_result_text_in_history(&blocking_messages, "5"),
4941 "the tool's original output must not reach the model after a rewrite"
4942 );
4943 }
4944
4945 #[tokio::test]
4951 async fn rewrite_result_is_delivered_verbatim_not_reparsed() {
4952 const IMAGE_JSON: &str = r#"{"type":"image","data":"abc","mimeType":"image/png"}"#;
4953
4954 let turns = [
4955 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
4956 ScriptedTurn::Text("done"),
4957 ];
4958 let model =
4959 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
4960 let result = AgentBuilder::new(model)
4961 .tool(MockAddTool)
4962 .build()
4963 .runner("add 2 and 3")
4964 .max_turns(3)
4965 .add_hook(RewriteToolResultHook(IMAGE_JSON))
4966 .run()
4967 .await
4968 .expect("run should succeed with a JSON-shaped rewritten result");
4969
4970 let messages = result.messages.expect("messages");
4971 assert!(
4972 tool_result_text_in_history(&messages, IMAGE_JSON),
4973 "the JSON-shaped replacement must reach history verbatim as text, not be \
4974 re-parsed into a structured/image content block"
4975 );
4976 }
4977
4978 struct PatchRequestHook;
4982
4983 impl<M: CompletionModel> AgentHook<M> for PatchRequestHook {
4984 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
4985 if let StepEvent::CompletionCall { .. } = event {
4986 Flow::patch_request(
4987 RequestPatch::new()
4988 .preamble(OVERRIDE_PREAMBLE)
4989 .temperature(0.25)
4990 .max_tokens(OVERRIDE_MAX_TOKENS)
4991 .tool_choice(ToolChoice::Required)
4992 .active_tools(["add"])
4993 .additional_params(json!({"injected": true})),
4994 )
4995 } else {
4996 Flow::cont()
4997 }
4998 }
4999 }
5000
5001 const OVERRIDE_PREAMBLE: &str = "overridden: critical-step instructions";
5002 const OVERRIDE_MAX_TOKENS: u64 = 512;
5003
5004 #[test]
5007 fn patch_request_resolves_to_patch_for_completion_call() {
5008 let patch = RequestPatch::new()
5009 .temperature(0.25)
5010 .tool_choice(ToolChoice::Required);
5011 match super::flow_into_completion_call(Flow::patch_request(patch.clone())) {
5012 super::CompletionCallDecision::Patch(got) => assert_eq!(got, patch),
5013 _ => panic!("PatchRequest should resolve to Patch for a completion call"),
5014 }
5015 assert_eq!(
5016 super::flow_name(&Flow::patch_request(RequestPatch::new())),
5017 "PatchRequest"
5018 );
5019 }
5020
5021 #[test]
5024 fn patch_request_is_fail_closed_off_the_completion_call_event() {
5025 let patch = || Flow::patch_request(RequestPatch::new());
5026 assert!(matches!(
5027 super::flow_into_invalid(patch()),
5028 super::InvalidDecision::Terminate(_)
5029 ));
5030 assert!(matches!(
5031 super::flow_into_tool_call(patch()),
5032 super::ToolCallDecision::Terminate(_)
5033 ));
5034 assert!(matches!(
5035 super::flow_into_tool_result(patch()),
5036 super::ToolResultDecision::Terminate(_)
5037 ));
5038 assert!(super::observe_flow(patch()).is_some());
5039 assert!(matches!(
5041 super::flow_into_completion_call(Flow::skip("x")),
5042 super::CompletionCallDecision::Terminate(_)
5043 ));
5044 }
5045
5046 #[tokio::test]
5051 async fn patch_request_parity_across_run_and_stream() {
5052 fn assert_request(req: &crate::completion::CompletionRequest) {
5053 assert_eq!(
5054 req.temperature,
5055 Some(0.25),
5056 "override temperature wins over the agent's 0.9"
5057 );
5058 assert_eq!(
5059 req.max_tokens,
5060 Some(OVERRIDE_MAX_TOKENS),
5061 "override max_tokens wins over the agent's 64"
5062 );
5063 let system = req.chat_history.iter().find_map(|m| match m {
5065 Message::System { content } => Some(content.as_str()),
5066 _ => None,
5067 });
5068 assert_eq!(
5069 system,
5070 Some(OVERRIDE_PREAMBLE),
5071 "override preamble wins over the agent's baseline and is the leading system message"
5072 );
5073 assert!(matches!(req.tool_choice, Some(ToolChoice::Required)));
5074 let tool_names: Vec<&str> = req.tools.iter().map(|t| t.name.as_str()).collect();
5075 assert_eq!(
5076 tool_names,
5077 ["add"],
5078 "active_tools narrows the advertised set to `add` (drops `subtract`)"
5079 );
5080 let params = req.additional_params.as_ref().expect("additional_params");
5083 assert_eq!(
5084 params.get("baseline").and_then(|v| v.as_str()),
5085 Some("keep")
5086 );
5087 assert_eq!(params.get("injected").and_then(|v| v.as_bool()), Some(true));
5088 }
5089
5090 let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5091 let blocking_probe = blocking_model.clone();
5092 let blocking = AgentBuilder::new(blocking_model)
5093 .tool(MockAddTool)
5094 .tool(MockSubtractTool)
5095 .preamble("baseline preamble")
5096 .temperature(0.9)
5097 .max_tokens(64)
5098 .additional_params(json!({"baseline": "keep"}))
5099 .add_hook(PatchRequestHook)
5100 .build()
5101 .runner("go")
5102 .max_turns(2)
5103 .run()
5104 .await
5105 .expect("blocking run should succeed");
5106 assert_eq!(blocking.output, "done");
5107 let blocking_requests = blocking_probe.requests();
5108 assert_eq!(blocking_requests.len(), 1);
5109 assert_request(&blocking_requests[0]);
5110
5111 let streaming_model = MockCompletionModel::from_stream_turns([
5112 ScriptedTurn::Text("done").as_stream_events(StreamShape::Complete)
5113 ]);
5114 let streaming_probe = streaming_model.clone();
5115 let mut stream = AgentBuilder::new(streaming_model)
5116 .tool(MockAddTool)
5117 .tool(MockSubtractTool)
5118 .preamble("baseline preamble")
5119 .temperature(0.9)
5120 .max_tokens(64)
5121 .additional_params(json!({"baseline": "keep"}))
5122 .add_hook(PatchRequestHook)
5123 .build()
5124 .runner("go")
5125 .max_turns(2)
5126 .stream()
5127 .await;
5128 while let Some(item) = stream.next().await {
5129 let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5130 }
5131 let streaming_requests = streaming_probe.requests();
5132 assert_eq!(streaming_requests.len(), 1);
5133 assert_request(&streaming_requests[0]);
5134 }
5135
5136 fn hook_doc(id: &str, text: &str) -> crate::completion::Document {
5139 crate::completion::Document {
5140 id: id.to_string(),
5141 text: text.to_string(),
5142 additional_props: Default::default(),
5143 }
5144 }
5145
5146 struct ExtraContextHook {
5148 id: &'static str,
5149 text: &'static str,
5150 }
5151
5152 impl<M: CompletionModel> AgentHook<M> for ExtraContextHook {
5153 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5154 if let StepEvent::CompletionCall { .. } = event {
5155 Flow::patch_request(RequestPatch::new().context(hook_doc(self.id, self.text)))
5156 } else {
5157 Flow::cont()
5158 }
5159 }
5160 }
5161
5162 struct ExtraContextTurnOneHook;
5165
5166 impl<M: CompletionModel> AgentHook<M> for ExtraContextTurnOneHook {
5167 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5168 if let StepEvent::CompletionCall { turn, .. } = event
5169 && turn == 1
5170 {
5171 return Flow::patch_request(
5172 RequestPatch::new().context(hook_doc("turn-one", "only turn 1")),
5173 );
5174 }
5175 Flow::cont()
5176 }
5177 }
5178
5179 fn one_text_stream_turn(text: &'static str) -> Vec<MockStreamEvent> {
5180 vec![
5181 MockStreamEvent::text(text),
5182 MockStreamEvent::final_response_with_total_tokens(0),
5183 ]
5184 }
5185
5186 #[tokio::test]
5189 async fn extra_context_appears_after_static_context_on_both_surfaces() {
5190 fn assert_docs(req: &crate::completion::CompletionRequest) {
5191 let ids: Vec<&str> = req.documents.iter().map(|d| d.id.as_str()).collect();
5192 let static_pos = ids
5193 .iter()
5194 .position(|id| id.starts_with("static_doc"))
5195 .expect("static context document present");
5196 let extra_pos = ids
5197 .iter()
5198 .position(|id| *id == "hook-doc")
5199 .expect("hook extra_context document present");
5200 assert!(
5201 static_pos < extra_pos,
5202 "static context precedes hook extras: {ids:?}"
5203 );
5204 assert!(
5205 req.documents.iter().any(|d| d.text == "injected"),
5206 "the hook document's text is present"
5207 );
5208 }
5209
5210 let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5211 let blocking_probe = blocking_model.clone();
5212 AgentBuilder::new(blocking_model)
5213 .context("static context text")
5214 .add_hook(ExtraContextHook {
5215 id: "hook-doc",
5216 text: "injected",
5217 })
5218 .build()
5219 .runner("go")
5220 .run()
5221 .await
5222 .expect("blocking run should succeed");
5223 assert_docs(blocking_probe.requests().first().expect("one request"));
5224
5225 let streaming_model =
5226 MockCompletionModel::from_stream_turns([one_text_stream_turn("done")]);
5227 let streaming_probe = streaming_model.clone();
5228 let mut stream = AgentBuilder::new(streaming_model)
5229 .context("static context text")
5230 .add_hook(ExtraContextHook {
5231 id: "hook-doc",
5232 text: "injected",
5233 })
5234 .build()
5235 .runner("go")
5236 .stream()
5237 .await;
5238 while let Some(item) = stream.next().await {
5239 let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5240 }
5241 assert_docs(streaming_probe.requests().first().expect("one request"));
5242 }
5243
5244 #[tokio::test]
5246 async fn multiple_hooks_extra_context_append_in_registration_order() {
5247 let model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5248 let probe = model.clone();
5249 AgentBuilder::new(model)
5250 .add_hook(ExtraContextHook {
5251 id: "first",
5252 text: "1",
5253 })
5254 .add_hook(ExtraContextHook {
5255 id: "second",
5256 text: "2",
5257 })
5258 .build()
5259 .runner("go")
5260 .run()
5261 .await
5262 .expect("run should succeed");
5263 let requests = probe.requests();
5264 let req = requests.first().expect("one request");
5265 let ids: Vec<&str> = req.documents.iter().map(|d| d.id.as_str()).collect();
5266 assert_eq!(
5267 ids,
5268 vec!["first", "second"],
5269 "hook extras append in registration order"
5270 );
5271 }
5272
5273 #[tokio::test]
5276 async fn extra_context_is_per_turn_non_sticky() {
5277 fn assert_turns(requests: &[crate::completion::CompletionRequest]) {
5278 assert_eq!(requests.len(), 2, "two model turns");
5279 let turn1 = requests.first().expect("turn 1");
5280 let turn2 = requests.get(1).expect("turn 2");
5281 assert!(
5282 turn1.documents.iter().any(|d| d.id == "turn-one"),
5283 "turn 1 carries the injected document"
5284 );
5285 assert!(
5286 turn2.documents.iter().all(|d| d.id != "turn-one"),
5287 "turn 2 does not inherit turn 1's per-turn document"
5288 );
5289 }
5290
5291 let blocking_probe = blocking_model();
5292 let probe = blocking_probe.clone();
5293 AgentBuilder::new(blocking_probe)
5294 .tool(MockAddTool)
5295 .add_hook(ExtraContextTurnOneHook)
5296 .build()
5297 .runner("add 2 and 3")
5298 .max_turns(3)
5299 .run()
5300 .await
5301 .expect("blocking run should succeed");
5302 assert_turns(&probe.requests());
5303
5304 let streaming = streaming_model();
5305 let stream_probe = streaming.clone();
5306 let mut stream = AgentBuilder::new(streaming)
5307 .tool(MockAddTool)
5308 .add_hook(ExtraContextTurnOneHook)
5309 .build()
5310 .runner("add 2 and 3")
5311 .max_turns(3)
5312 .stream()
5313 .await;
5314 while let Some(item) = stream.next().await {
5315 let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5316 }
5317 assert_turns(&stream_probe.requests());
5318 }
5319
5320 #[tokio::test]
5323 async fn history_patch_changes_sent_messages_not_transcript_on_both_surfaces() {
5324 const SENTINEL: &str = "COMPACTED-HISTORY-SENTINEL";
5325
5326 struct HistoryOverrideHook;
5327 impl<M: CompletionModel> AgentHook<M> for HistoryOverrideHook {
5328 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5329 if let StepEvent::CompletionCall { .. } = event {
5330 Flow::patch_request(RequestPatch::new().history([Message::user(SENTINEL)]))
5331 } else {
5332 Flow::cont()
5333 }
5334 }
5335 }
5336
5337 fn request_has_sentinel(req: &crate::completion::CompletionRequest) -> bool {
5338 req.chat_history.iter().any(|m| match m {
5339 Message::User { content } => content
5340 .iter()
5341 .any(|c| matches!(c, UserContent::Text(text) if text.text.contains(SENTINEL))),
5342 _ => false,
5343 })
5344 }
5345
5346 fn messages_have_sentinel(messages: &[Message]) -> bool {
5347 messages.iter().any(|m| match m {
5348 Message::User { content } => content
5349 .iter()
5350 .any(|c| matches!(c, UserContent::Text(text) if text.text.contains(SENTINEL))),
5351 _ => false,
5352 })
5353 }
5354
5355 let blocking_model = MockCompletionModel::from_turns([MockTurn::text("done")]);
5356 let blocking_probe = blocking_model.clone();
5357 let blocking = AgentBuilder::new(blocking_model)
5358 .add_hook(HistoryOverrideHook)
5359 .build()
5360 .runner("real prompt")
5361 .run()
5362 .await
5363 .expect("blocking run should succeed");
5364 assert!(
5365 request_has_sentinel(blocking_probe.requests().first().expect("one request")),
5366 "the overridden history reaches the provider"
5367 );
5368 assert!(
5369 !messages_have_sentinel(blocking.messages.as_deref().unwrap_or_default()),
5370 "the persisted transcript is untouched by the per-turn history override"
5371 );
5372
5373 let streaming_model =
5374 MockCompletionModel::from_stream_turns([one_text_stream_turn("done")]);
5375 let streaming_probe = streaming_model.clone();
5376 let stream = AgentBuilder::new(streaming_model)
5377 .add_hook(HistoryOverrideHook)
5378 .build()
5379 .runner("real prompt")
5380 .stream()
5381 .await;
5382 let final_response = drive_to_final_response(stream).await;
5383 assert!(
5384 request_has_sentinel(streaming_probe.requests().first().expect("one request")),
5385 "the overridden history reaches the provider on the streaming surface too"
5386 );
5387 assert!(
5388 !messages_have_sentinel(final_response.messages().expect("history")),
5389 "the persisted transcript is untouched by the per-turn history override on \
5390 the streaming surface too"
5391 );
5392 }
5393
5394 #[tokio::test]
5397 async fn model_turn_finished_fires_once_per_accepted_turn_including_tool_only() {
5398 let blocking_hook = RecordingHook::default();
5399 AgentBuilder::new(blocking_model())
5400 .tool(MockAddTool)
5401 .add_hook(blocking_hook.clone())
5402 .build()
5403 .runner("add 2 and 3")
5404 .max_turns(3)
5405 .run()
5406 .await
5407 .expect("blocking run should succeed");
5408 assert_eq!(
5409 blocking_hook.count(StepEventKind::ModelTurnFinished),
5410 2,
5411 "one ModelTurnFinished per accepted turn (tool turn + text turn)"
5412 );
5413
5414 let streaming_hook = RecordingHook::default();
5415 let mut stream = AgentBuilder::new(streaming_model())
5416 .tool(MockAddTool)
5417 .add_hook(streaming_hook.clone())
5418 .build()
5419 .runner("add 2 and 3")
5420 .max_turns(3)
5421 .stream()
5422 .await;
5423 while let Some(item) = stream.next().await {
5424 let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5425 }
5426 assert_eq!(
5427 streaming_hook.count(StepEventKind::ModelTurnFinished),
5428 2,
5429 "ModelTurnFinished fires once per turn on the streaming surface too"
5430 );
5431 assert_eq!(
5435 streaming_hook.count(StepEventKind::StreamResponseFinish),
5436 1,
5437 "the tool-only turn fires no StreamResponseFinish"
5438 );
5439 }
5440
5441 #[derive(Clone, Default)]
5443 struct CaptureFirstTurnContent {
5444 kinds: Arc<Mutex<Option<Vec<&'static str>>>>,
5445 }
5446
5447 impl<M: CompletionModel> AgentHook<M> for CaptureFirstTurnContent {
5448 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5449 if let StepEvent::ModelTurnFinished { turn, content, .. } = event
5450 && turn == 1
5451 {
5452 let kinds = content
5453 .iter()
5454 .map(|c| match c {
5455 AssistantContent::Reasoning(_) => "reasoning",
5456 AssistantContent::Text(_) => "text",
5457 AssistantContent::ToolCall(_) => "tool_call",
5458 _ => "other",
5459 })
5460 .collect();
5461 *self.kinds.lock().expect("kinds") = Some(kinds);
5462 }
5463 Flow::cont()
5464 }
5465 }
5466
5467 #[tokio::test]
5474 async fn streaming_model_turn_finished_carries_canonical_committed_content() {
5475 let model = MockCompletionModel::from_stream_turns([
5476 vec![
5477 MockStreamEvent::reasoning("think"),
5478 MockStreamEvent::tool_call("tc1", "add", json!({"x": 2, "y": 3})),
5479 MockStreamEvent::text("answer"),
5480 MockStreamEvent::final_response_with_total_tokens(0),
5481 ],
5482 vec![
5483 MockStreamEvent::text("done"),
5484 MockStreamEvent::final_response_with_total_tokens(0),
5485 ],
5486 ]);
5487 let hook = CaptureFirstTurnContent::default();
5488 let stream = AgentBuilder::new(model)
5489 .tool(MockAddTool)
5490 .add_hook(hook.clone())
5491 .build()
5492 .runner("go")
5493 .max_turns(3)
5494 .stream()
5495 .await;
5496 let _ = drive_to_final_response(stream).await;
5497
5498 assert_eq!(
5499 hook.kinds.lock().expect("kinds").clone(),
5500 Some(vec!["reasoning", "text", "tool_call"]),
5501 "ModelTurnFinished carries the canonical reasoning->text->tool ordering \
5502 from StreamedTurn::finish, not the raw stream.choice emission order"
5503 );
5504 }
5505
5506 #[tokio::test]
5509 async fn chained_rewrites_compose_across_hooks() {
5510 struct SetArg {
5512 key: &'static str,
5513 value: i64,
5514 }
5515 impl<M: CompletionModel> AgentHook<M> for SetArg {
5516 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5517 if let StepEvent::ToolCall { args, .. } = event {
5518 let mut parsed: serde_json::Value =
5519 serde_json::from_str(args).unwrap_or_else(|_| json!({}));
5520 parsed[self.key] = json!(self.value);
5521 Flow::rewrite_args(parsed)
5522 } else {
5523 Flow::cont()
5524 }
5525 }
5526 }
5527
5528 struct WrapResult(&'static str);
5530 impl<M: CompletionModel> AgentHook<M> for WrapResult {
5531 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5532 if let StepEvent::ToolResult { result, .. } = event {
5533 Flow::rewrite_result(format!("{}({})", self.0, result))
5534 } else {
5535 Flow::cont()
5536 }
5537 }
5538 }
5539
5540 let recorder = RecordingHook::default();
5545 let blocking = AgentBuilder::new(blocking_model())
5546 .tool(MockAddTool)
5547 .add_hook(SetArg {
5548 key: "y",
5549 value: 40,
5550 })
5551 .add_hook(SetArg {
5552 key: "x",
5553 value: 100,
5554 })
5555 .add_hook(WrapResult("A"))
5556 .add_hook(WrapResult("B"))
5557 .add_hook(recorder.clone())
5558 .build()
5559 .runner("add 2 and 3")
5560 .max_turns(3)
5561 .run()
5562 .await
5563 .expect("blocking run should succeed");
5564 assert_eq!(blocking.output, "the answer is 5");
5565 assert_eq!(
5566 recorder.tool_results(),
5567 vec!["B(A(140))".to_string()],
5568 "arg rewrites compose (100+40=140) and result rewrites nest B(A(...))"
5569 );
5570
5571 let stream_recorder = RecordingHook::default();
5573 let mut stream = AgentBuilder::new(streaming_model())
5574 .tool(MockAddTool)
5575 .add_hook(SetArg {
5576 key: "y",
5577 value: 40,
5578 })
5579 .add_hook(SetArg {
5580 key: "x",
5581 value: 100,
5582 })
5583 .add_hook(WrapResult("A"))
5584 .add_hook(WrapResult("B"))
5585 .add_hook(stream_recorder.clone())
5586 .build()
5587 .runner("add 2 and 3")
5588 .max_turns(3)
5589 .stream()
5590 .await;
5591 while let Some(item) = stream.next().await {
5592 let _ = item.map_err(|err| panic!("stream item errored: {err}"));
5593 }
5594 assert_eq!(
5595 stream_recorder.tool_results(),
5596 vec!["B(A(140))".to_string()],
5597 "chained rewrites compose identically on the streaming surface"
5598 );
5599 }
5600
5601 #[derive(serde::Deserialize, schemars::JsonSchema)]
5602 #[allow(dead_code)]
5603 struct Answer {
5604 answer: String,
5605 }
5606
5607 struct FinalResultTool;
5611
5612 impl Tool for FinalResultTool {
5613 const NAME: &'static str = "final_result";
5614 type Error = MockToolError;
5615 type Args = serde_json::Value;
5616 type Output = String;
5617
5618 fn description(&self) -> String {
5619 "A real tool sharing the default output-tool name".to_string()
5620 }
5621
5622 fn parameters(&self) -> serde_json::Value {
5623 json!({ "type": "object", "properties": {} })
5624 }
5625
5626 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
5627 Ok("real final_result output".to_string())
5628 }
5629 }
5630
5631 struct ActiveToolsAddOnly;
5634
5635 impl<M: CompletionModel> AgentHook<M> for ActiveToolsAddOnly {
5636 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5637 if let StepEvent::CompletionCall { .. } = event {
5638 Flow::patch_request(RequestPatch::new().active_tools(["add"]))
5639 } else {
5640 Flow::cont()
5641 }
5642 }
5643 }
5644
5645 #[tokio::test]
5654 async fn active_tools_filter_does_not_let_output_tool_collide_with_a_filtered_real_tool() {
5655 let model = MockCompletionModel::from_turns([MockTurn::tool_call(
5661 "out1",
5662 "final_result_1",
5663 json!({ "answer": "done" }),
5664 )]);
5665 let probe = model.clone();
5666 let response = AgentBuilder::new(model)
5667 .tool(MockAddTool)
5668 .tool(FinalResultTool)
5669 .output_schema::<Answer>()
5670 .output_mode(OutputMode::Tool)
5671 .add_hook(ActiveToolsAddOnly)
5672 .build()
5673 .runner("go")
5674 .max_turns(2)
5675 .run()
5676 .await
5677 .expect("run should finalize via the picked output tool `final_result_1`");
5678 assert!(
5679 response.output.contains("done"),
5680 "the intercepted output-tool call should produce the structured result, \
5681 got {:?}",
5682 response.output
5683 );
5684
5685 let requests = probe.requests();
5686 assert!(
5687 !requests.is_empty(),
5688 "the first model request should be captured"
5689 );
5690 let tool_names: Vec<&str> = requests[0].tools.iter().map(|t| t.name.as_str()).collect();
5691 assert!(
5692 tool_names.contains(&"add"),
5693 "active_tools keeps `add` advertised, saw {tool_names:?}"
5694 );
5695 assert!(
5696 tool_names.contains(&"final_result_1"),
5697 "the synthetic output tool must avoid the filtered real `final_result` name, \
5698 saw {tool_names:?}"
5699 );
5700 assert!(
5701 !tool_names.contains(&"final_result"),
5702 "the real `final_result` is filtered out and the output tool must not reuse \
5703 its name, saw {tool_names:?}"
5704 );
5705 }
5706
5707 #[derive(Clone, Default)]
5710 struct CaptureOutputToolInModelTurn {
5711 saw_output_tool_call: Arc<Mutex<bool>>,
5712 }
5713
5714 impl<M: CompletionModel> AgentHook<M> for CaptureOutputToolInModelTurn {
5715 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5716 if let StepEvent::ModelTurnFinished { content, .. } = event
5717 && content.iter().any(|c| {
5718 matches!(c, AssistantContent::ToolCall(tc) if tc.function.name == "final_result")
5719 })
5720 {
5721 *self.saw_output_tool_call.lock().expect("lock") = true;
5722 }
5723 Flow::cont()
5724 }
5725 }
5726
5727 #[tokio::test]
5733 async fn model_turn_finished_content_carries_output_tool_call_in_tool_mode() {
5734 let hook = CaptureOutputToolInModelTurn::default();
5736 let response = AgentBuilder::new(MockCompletionModel::from_turns([MockTurn::tool_call(
5737 "out1",
5738 "final_result",
5739 json!({ "answer": "done" }),
5740 )]))
5741 .output_schema::<Answer>()
5742 .output_mode(OutputMode::Tool)
5743 .add_hook(hook.clone())
5744 .build()
5745 .runner("go")
5746 .max_turns(2)
5747 .run()
5748 .await
5749 .expect("run should finalize via the output tool");
5750 assert!(
5751 *hook.saw_output_tool_call.lock().expect("lock"),
5752 "ModelTurnFinished.content must carry the model-emitted output-tool call (blocking)"
5753 );
5754 assert!(
5755 response.output.contains("done"),
5756 "the run finalizes with the structured output, not the raw tool call: {:?}",
5757 response.output
5758 );
5759
5760 let s_hook = CaptureOutputToolInModelTurn::default();
5762 let mut stream = AgentBuilder::new(MockCompletionModel::from_stream_turns([vec![
5763 MockStreamEvent::tool_call("out1", "final_result", json!({ "answer": "done" })),
5764 MockStreamEvent::final_response_with_total_tokens(0),
5765 ]]))
5766 .output_schema::<Answer>()
5767 .output_mode(OutputMode::Tool)
5768 .add_hook(s_hook.clone())
5769 .build()
5770 .runner("go")
5771 .max_turns(2)
5772 .stream()
5773 .await;
5774 while stream.next().await.is_some() {}
5775 assert!(
5776 *s_hook.saw_output_tool_call.lock().expect("lock"),
5777 "ModelTurnFinished.content must carry the model-emitted output-tool call (streaming)"
5778 );
5779 }
5780
5781 #[tokio::test]
5787 async fn output_tool_finalization_emits_no_complete_tool_call_stream_item() {
5788 let mut stream = AgentBuilder::new(MockCompletionModel::from_stream_turns([vec![
5789 MockStreamEvent::tool_call("out1", "final_result", json!({ "answer": "done" })),
5790 MockStreamEvent::final_response_with_total_tokens(0),
5791 ]]))
5792 .output_schema::<Answer>()
5793 .output_mode(OutputMode::Tool)
5794 .build()
5795 .runner("go")
5796 .max_turns(2)
5797 .stream()
5798 .await;
5799
5800 let mut saw_complete_output_tool_call = false;
5801 let mut final_has_output = false;
5802 while let Some(item) = stream.next().await {
5803 match item.expect("stream item") {
5804 MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall {
5805 tool_call,
5806 ..
5807 }) if tool_call.function.name == "final_result" => {
5808 saw_complete_output_tool_call = true;
5809 }
5810 MultiTurnStreamItem::FinalResponse(res) => {
5811 final_has_output = res.output().contains("done");
5812 }
5813 _ => {}
5814 }
5815 }
5816 assert!(
5817 !saw_complete_output_tool_call,
5818 "the output-tool call finalizes the run, so no complete \
5819 StreamAssistantItem::ToolCall item must be emitted for it"
5820 );
5821 assert!(
5822 final_has_output,
5823 "the structured output must be surfaced via the FinalResponse"
5824 );
5825 }
5826
5827 enum Decision {
5836 Approve,
5838 Deny(&'static str),
5840 Edit(serde_json::Value),
5842 Abort(&'static str),
5844 }
5845
5846 #[derive(Clone)]
5850 struct HumanApprovalHook {
5851 decisions: Arc<Mutex<std::collections::VecDeque<Decision>>>,
5852 reviewed: Arc<Mutex<Vec<String>>>,
5853 }
5854
5855 impl HumanApprovalHook {
5856 fn new(decisions: impl IntoIterator<Item = Decision>) -> Self {
5857 Self {
5858 decisions: Arc::new(Mutex::new(decisions.into_iter().collect())),
5859 reviewed: Arc::new(Mutex::new(Vec::new())),
5860 }
5861 }
5862
5863 fn reviewed(&self) -> Vec<String> {
5865 self.reviewed.lock().unwrap().clone()
5866 }
5867 }
5868
5869 impl<M: CompletionModel> AgentHook<M> for HumanApprovalHook {
5870 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
5871 let StepEvent::ToolCall {
5872 tool_name, args, ..
5873 } = event
5874 else {
5875 return Flow::cont();
5876 };
5877 self.reviewed
5878 .lock()
5879 .unwrap()
5880 .push(format!("{tool_name}({args})"));
5881 let decision = self.decisions.lock().unwrap().pop_front();
5882 match decision {
5883 Some(Decision::Approve) => Flow::cont(),
5884 Some(Decision::Deny(reason)) => Flow::skip(reason),
5885 Some(Decision::Edit(args)) => Flow::rewrite_args(args),
5886 Some(Decision::Abort(reason)) => Flow::terminate(reason),
5887 None => Flow::skip("denied: no scripted decision (fail-closed)"),
5890 }
5891 }
5892 }
5893
5894 #[tokio::test]
5900 async fn human_in_the_loop_approve_deny_edit_parity_across_run_and_stream() {
5901 let turns = [
5903 ScriptedTurn::ToolCalls(vec![
5904 add_call("tc1", 2, 3), add_call("tc2", 10, 20), add_call("tc3", 1, 1), ]),
5908 ScriptedTurn::Text("done"),
5909 ];
5910 let denial = "denied by reviewer: amount too large";
5911 let decisions = || {
5912 vec![
5913 Decision::Approve,
5914 Decision::Deny(denial),
5915 Decision::Edit(json!({"x": 1, "y": 100})),
5916 ]
5917 };
5918
5919 let blocking_model =
5920 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
5921 let blocking_recorder = RecordingHook::default();
5922 let blocking_approver = HumanApprovalHook::new(decisions());
5923 let blocking = AgentBuilder::new(blocking_model)
5924 .tool(MockAddTool)
5925 .build()
5926 .runner("carry out the plan")
5927 .max_turns(3)
5928 .add_hook(blocking_recorder.clone())
5929 .add_hook(blocking_approver.clone())
5930 .run()
5931 .await
5932 .expect("blocking HITL run should succeed");
5933
5934 let streaming_model = MockCompletionModel::from_stream_turns(
5935 turns
5936 .iter()
5937 .map(|turn| turn.as_stream_events(StreamShape::Complete)),
5938 );
5939 let streaming_recorder = RecordingHook::default();
5940 let streaming_approver = HumanApprovalHook::new(decisions());
5941 let mut stream = AgentBuilder::new(streaming_model)
5942 .tool(MockAddTool)
5943 .build()
5944 .runner("carry out the plan")
5945 .max_turns(3)
5946 .add_hook(streaming_recorder.clone())
5947 .add_hook(streaming_approver.clone())
5948 .stream()
5949 .await;
5950 let mut final_response = None;
5951 while let Some(item) = stream.next().await {
5952 if let Ok(MultiTurnStreamItem::FinalResponse(resp)) =
5953 item.map_err(|err| panic!("stream item errored: {err}"))
5954 {
5955 final_response = Some(resp);
5956 }
5957 }
5958 let final_response = final_response.expect("stream should yield a final response");
5959
5960 assert_eq!(
5965 blocking_recorder.tool_results(),
5966 vec![
5967 "5".to_string(),
5968 "denied by reviewer: amount too large".to_string(),
5969 "101".to_string()
5970 ]
5971 );
5972 assert_eq!(
5973 blocking_recorder.tool_results(),
5974 streaming_recorder.tool_results()
5975 );
5976
5977 assert!(
5981 !blocking_recorder.tool_results().contains(&"30".to_string()),
5982 "the denied call must not have executed"
5983 );
5984
5985 let reviewed = blocking_approver.reviewed();
5989 assert_eq!(reviewed.len(), 3);
5990 assert_eq!(reviewed, streaming_approver.reviewed());
5991 assert!(
5992 reviewed[0].contains('2') && reviewed[0].contains('3'),
5993 "first reviewed call should be add(2, 3): {reviewed:?}"
5994 );
5995 assert!(
5996 reviewed[1].contains("10") && reviewed[1].contains("20"),
5997 "the denied (second) call should be add(10, 20): {reviewed:?}"
5998 );
5999
6000 assert_eq!(blocking.output, "done");
6001 assert_eq!(final_response.output(), blocking.output);
6002 assert_eq!(
6003 blocking_recorder.shared_events(),
6004 streaming_recorder.shared_events()
6005 );
6006
6007 let blocking_messages = blocking.messages.expect("blocking messages");
6011 let streaming_messages = final_response
6012 .messages()
6013 .expect("streaming history")
6014 .to_vec();
6015 assert_eq!(
6016 serde_json::to_value(&blocking_messages).expect("serialize blocking"),
6017 serde_json::to_value(&streaming_messages).expect("serialize streaming"),
6018 );
6019 assert!(
6020 tool_result_text_in_history(&blocking_messages, denial),
6021 "the denial reason must be the denied call's tool result in the history"
6022 );
6023 assert!(
6024 tool_result_text_in_history(&blocking_messages, "101"),
6025 "the edited call must have executed with the rewritten arguments"
6026 );
6027 }
6028
6029 #[tokio::test]
6033 async fn human_in_the_loop_abort_terminates_the_run() {
6034 let turns = [
6035 ScriptedTurn::ToolCalls(vec![add_call("tc1", 2, 3)]),
6036 ScriptedTurn::Text("unreachable"),
6037 ];
6038 const ABORT_REASON: &str = "aborted by the human reviewer";
6039
6040 let blocking_model =
6042 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
6043 let err = AgentBuilder::new(blocking_model)
6044 .tool(MockAddTool)
6045 .build()
6046 .runner("do the sensitive thing")
6047 .max_turns(3)
6048 .add_hook(HumanApprovalHook::new([Decision::Abort(ABORT_REASON)]))
6049 .run()
6050 .await
6051 .expect_err("an aborted tool call should terminate the blocking run");
6052 assert!(
6053 format!("{err}").contains(ABORT_REASON),
6054 "the abort reason should surface in the blocking error, got: {err}"
6055 );
6056
6057 let streaming_model = MockCompletionModel::from_stream_turns(
6060 turns
6061 .iter()
6062 .map(|turn| turn.as_stream_events(StreamShape::Complete)),
6063 );
6064 let mut stream = AgentBuilder::new(streaming_model)
6065 .tool(MockAddTool)
6066 .build()
6067 .runner("do the sensitive thing")
6068 .max_turns(3)
6069 .add_hook(HumanApprovalHook::new([Decision::Abort(ABORT_REASON)]))
6070 .stream()
6071 .await;
6072 let mut stream_error = None;
6073 while let Some(item) = stream.next().await {
6074 match item {
6075 Err(err) => stream_error = Some(format!("{err}")),
6076 Ok(MultiTurnStreamItem::FinalResponse(resp)) => {
6077 panic!("aborted stream must not finalize, got: {}", resp.output())
6078 }
6079 Ok(_) => {}
6080 }
6081 }
6082 let stream_error = stream_error.expect("an aborted tool call should error the stream");
6083 assert!(
6084 stream_error.contains(ABORT_REASON),
6085 "the abort reason should surface in the streaming error, got: {stream_error}"
6086 );
6087 }
6088
6089 #[derive(Clone)]
6094 struct PolicyHook {
6095 auto_approve: std::collections::HashSet<&'static str>,
6096 evaluated: Arc<Mutex<Vec<String>>>,
6098 cache: Arc<Mutex<std::collections::HashMap<String, bool>>>,
6100 }
6101
6102 impl PolicyHook {
6103 fn new(auto_approve: impl IntoIterator<Item = &'static str>) -> Self {
6104 Self {
6105 auto_approve: auto_approve.into_iter().collect(),
6106 evaluated: Arc::new(Mutex::new(Vec::new())),
6107 cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
6108 }
6109 }
6110
6111 fn evaluated(&self) -> Vec<String> {
6112 self.evaluated.lock().unwrap().clone()
6113 }
6114 }
6115
6116 impl<M: CompletionModel> AgentHook<M> for PolicyHook {
6117 async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
6118 let StepEvent::ToolCall { tool_name, .. } = event else {
6119 return Flow::cont();
6120 };
6121 let cached = self.cache.lock().unwrap().get(tool_name).copied();
6122 let approved = match cached {
6123 Some(decision) => decision, None => {
6125 self.evaluated.lock().unwrap().push(tool_name.to_string());
6126 let decision = self.auto_approve.contains(tool_name);
6127 self.cache
6128 .lock()
6129 .unwrap()
6130 .insert(tool_name.to_string(), decision);
6131 decision
6132 }
6133 };
6134 if approved {
6135 Flow::cont()
6136 } else {
6137 Flow::skip(format!("denied by policy: `{tool_name}` not allowed"))
6138 }
6139 }
6140 }
6141
6142 #[tokio::test]
6146 async fn approval_policy_allow_list_with_sticky_decisions() {
6147 let turns = [
6149 ScriptedTurn::ToolCalls(vec![
6150 add_call("c1", 2, 3),
6151 ScriptedToolCall {
6152 id: "c2",
6153 name: "subtract",
6154 args: json!({ "x": 10, "y": 4 }),
6155 },
6156 add_call("c3", 2, 3),
6157 ]),
6158 ScriptedTurn::Text("done"),
6159 ];
6160
6161 let model =
6162 MockCompletionModel::from_turns(turns.iter().map(ScriptedTurn::as_blocking_turn));
6163 let recorder = RecordingHook::default();
6164 let policy = PolicyHook::new(["add"]);
6165 let out = AgentBuilder::new(model)
6166 .tool(MockAddTool)
6167 .tool(MockSubtractTool)
6168 .build()
6169 .runner("go")
6170 .max_turns(3)
6171 .add_hook(recorder.clone())
6172 .add_hook(policy.clone())
6173 .run()
6174 .await
6175 .expect("policy run should succeed");
6176
6177 assert_eq!(out.output, "done");
6178 assert_eq!(
6182 recorder.tool_results(),
6183 vec![
6184 "5".to_string(),
6185 "denied by policy: `subtract` not allowed".to_string(),
6186 "5".to_string()
6187 ]
6188 );
6189 assert_eq!(
6192 policy.evaluated(),
6193 vec!["add".to_string(), "subtract".to_string()]
6194 );
6195 let messages = out.messages.expect("messages");
6196 assert!(
6197 tool_result_text_in_history(&messages, "denied by policy: `subtract` not allowed"),
6198 "the policy denial reason must reach the model as the subtract tool result"
6199 );
6200 }
6201}