1use std::{
8 collections::BTreeSet,
9 sync::{
10 Arc, Mutex, MutexGuard,
11 atomic::{AtomicUsize, Ordering},
12 },
13 time::{Duration, Instant},
14};
15
16use futures::StreamExt;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::{
21 agent::{
22 AgentBuilder, AgentHook, CompletionCallAction, CompletionCallEvent,
23 CompletionResponseEvent, HookContext, InvalidToolCallAction, MultiTurnStreamItem,
24 NoToolConfig, ObservationAction, OutputMode, RequestPatch, StreamingError,
25 ToolCall as ToolCallEvent, ToolCallAction, ToolResultAction, ToolResultEvent,
26 run::{AgentRun, AgentRunStep, ModelTurn, ModelTurnOutcome},
27 },
28 completion::{
29 AssistantContent, CompletionError, CompletionModel, Message, Prompt, PromptError,
30 ToolDefinition,
31 },
32 streaming::StreamingPrompt,
33 tool::{Tool, ToolContext},
34};
35use rig_core::message::{ToolChoice, UserContent};
36
37#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum ScenarioError {
41 #[error(transparent)]
43 Prompt(#[from] PromptError),
44 #[error(transparent)]
46 Completion(#[from] CompletionError),
47 #[error(transparent)]
49 Streaming(#[from] StreamingError),
50 #[error(transparent)]
52 Json(#[from] serde_json::Error),
53 #[error(transparent)]
55 Extraction(#[from] crate::extractor::ExtractionError),
56 #[error("{scenario} conformance failed: {details}")]
58 Contract {
59 scenario: &'static str,
61 details: String,
63 },
64}
65
66impl ScenarioError {
67 fn contract(scenario: &'static str, details: impl Into<String>) -> Self {
68 Self::Contract {
69 scenario,
70 details: details.into(),
71 }
72 }
73}
74
75pub fn validate_unknown_tool_failure(
78 error: &PromptError,
79 expected_tool: &str,
80 expected_allowed_tools: &[&str],
81) -> Result<(), ScenarioError> {
82 const SCENARIO: &str = "unknown_tool_failure";
83 let PromptError::UnknownToolCall {
84 tool_name,
85 allowed_tools,
86 chat_history,
87 ..
88 } = error
89 else {
90 return Err(ScenarioError::contract(
91 SCENARIO,
92 format!("expected UnknownToolCall, observed {error:?}"),
93 ));
94 };
95 let expected_allowed = expected_allowed_tools
96 .iter()
97 .map(|name| (*name).to_string())
98 .collect::<Vec<_>>();
99 let history_has_call = chat_history.iter().any(|message| {
103 matches!(
104 message,
105 Message::Assistant { content, .. }
106 if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
107 )
108 });
109 if tool_name != expected_tool || allowed_tools != &expected_allowed || !history_has_call {
110 return Err(ScenarioError::contract(
111 SCENARIO,
112 format!(
113 "tool={tool_name:?}, allowed={allowed_tools:?}, expected_tool={expected_tool:?}, expected_allowed={expected_allowed:?}, history_has_call={history_has_call}, history={chat_history:?}"
114 ),
115 ));
116 }
117 Ok(())
118}
119
120pub fn validate_cancelled_failure(
123 error: &PromptError,
124 expected_reason: &str,
125 expected_tool: &str,
126) -> Result<(), ScenarioError> {
127 const SCENARIO: &str = "cancelled_failure";
128 let PromptError::PromptCancelled {
129 chat_history,
130 reason,
131 } = error
132 else {
133 return Err(ScenarioError::contract(
134 SCENARIO,
135 format!("expected PromptCancelled, observed {error:?}"),
136 ));
137 };
138 let history_has_call = chat_history.iter().any(|message| {
139 matches!(
140 message,
141 Message::Assistant { content, .. }
142 if content.iter().any(|item| matches!(
143 item,
144 AssistantContent::ToolCall(call) if call.function.name == expected_tool
145 ))
146 )
147 });
148 if reason != expected_reason || !history_has_call {
149 return Err(ScenarioError::contract(
150 SCENARIO,
151 format!(
152 "reason={reason:?}, expected={expected_reason:?}, history_has_call={history_has_call}, history={chat_history:?}"
153 ),
154 ));
155 }
156 Ok(())
157}
158
159pub fn validate_max_turns_failure(
162 error: &PromptError,
163 expected_max_turns: usize,
164) -> Result<(), ScenarioError> {
165 const SCENARIO: &str = "max_turns_failure";
166 let PromptError::MaxTurnsError {
167 max_turns,
168 chat_history,
169 prompt,
170 } = error
171 else {
172 return Err(ScenarioError::contract(
173 SCENARIO,
174 format!("expected MaxTurnsError, observed {error:?}"),
175 ));
176 };
177 let pending_prompt_retained = matches!(
178 prompt.as_ref(),
179 Message::User { content } if content.iter().next().is_some()
180 );
181 if *max_turns != expected_max_turns || chat_history.is_empty() || !pending_prompt_retained {
182 return Err(ScenarioError::contract(
183 SCENARIO,
184 format!(
185 "max_turns={max_turns}, expected={expected_max_turns}, history={chat_history:?}, pending_prompt_retained={pending_prompt_retained}, pending_prompt={prompt:?}"
186 ),
187 ));
188 }
189 Ok(())
190}
191
192pub fn decode_structured_output<T>(
195 scenario: &'static str,
196 response: &str,
197) -> Result<T, ScenarioError>
198where
199 T: serde::de::DeserializeOwned,
200{
201 serde_json::from_str(response).map_err(|error| {
202 ScenarioError::contract(
203 scenario,
204 format!("structured output did not decode: {error}; response={response:?}"),
205 )
206 })
207}
208
209pub fn validate_protocol_hygiene(
212 scenario: &'static str,
213 visible_output: &str,
214 messages: &[Message],
215 forbidden_markers: &[&str],
216) -> Result<(), ScenarioError> {
217 let serialized = serde_json::to_string(messages)?;
218 let leaked = forbidden_markers
219 .iter()
220 .filter(|marker| visible_output.contains(**marker) || serialized.contains(**marker))
221 .copied()
222 .collect::<Vec<_>>();
223 if !leaked.is_empty() {
224 return Err(ScenarioError::contract(
225 scenario,
226 format!(
227 "protocol markers leaked: {leaked:?}; output={visible_output:?}, history={messages:?}"
228 ),
229 ));
230 }
231 Ok(())
232}
233
234pub fn validate_rewritten_arguments(
237 scenario: &'static str,
238 observations: &[serde_json::Value],
239 expected_fields: &serde_json::Value,
240) -> Result<(), ScenarioError> {
241 let Some(expected) = expected_fields.as_object() else {
242 return Err(ScenarioError::contract(
243 scenario,
244 "expected rewritten fields must be a JSON object",
245 ));
246 };
247 if observations.is_empty() {
248 return Err(ScenarioError::contract(
249 scenario,
250 "the rewritten tool was never invoked",
251 ));
252 }
253 for observation in observations {
254 let Some(actual) = observation.as_object() else {
255 return Err(ScenarioError::contract(
256 scenario,
257 format!("observed rewritten arguments were not an object: {observation:?}"),
258 ));
259 };
260 for (key, expected_value) in expected {
261 if actual.get(key) != Some(expected_value) {
262 return Err(ScenarioError::contract(
263 scenario,
264 format!(
265 "rewritten field {key:?} expected {expected_value:?}, observed {observation:?}"
266 ),
267 ));
268 }
269 }
270 }
271 Ok(())
272}
273
274pub fn validate_result_redaction(
277 scenario: &'static str,
278 tool_produced_secret: bool,
279 visible_output: &str,
280 secret: &str,
281) -> Result<(), ScenarioError> {
282 if !tool_produced_secret || visible_output.is_empty() || visible_output.contains(secret) {
283 return Err(ScenarioError::contract(
284 scenario,
285 format!(
286 "produced_secret={tool_produced_secret}, secret_visible={}, output={visible_output:?}",
287 visible_output.contains(secret)
288 ),
289 ));
290 }
291 Ok(())
292}
293
294pub fn validate_extraction_fields(
296 scenario: &'static str,
297 first_name: Option<&str>,
298 last_name: Option<&str>,
299 job: Option<&str>,
300 usage: crate::completion::Usage,
301) -> Result<(), ScenarioError> {
302 let fields_match = first_name.is_some_and(|value| value.eq_ignore_ascii_case("Ada"))
303 && last_name.is_some_and(|value| value.eq_ignore_ascii_case("Lovelace"))
304 && job.is_some_and(|value| value.to_ascii_lowercase().contains("mathematician"));
305 if !fields_match || !usage.has_values() {
306 return Err(ScenarioError::contract(
307 scenario,
308 format!(
309 "first_name={first_name:?}, last_name={last_name:?}, job={job:?}, usage={usage:?}"
310 ),
311 ));
312 }
313 Ok(())
314}
315
316#[derive(Debug, Clone)]
318pub struct ScenarioReport {
319 pub name: &'static str,
321 pub tool_calls: usize,
323 pub prompt_tokens: u64,
325 pub generated_tokens: u64,
327 pub history_messages: usize,
329 pub duration: Duration,
331 pub response: String,
333}
334
335#[derive(Debug, thiserror::Error)]
337#[error("model-conformance tool failed")]
338pub struct ConformanceToolError;
339
340const FORCE_TOOLS_PREAMBLE: &str = "You are a calculator assistant. You MUST use the provided tools for every arithmetic operation instead of computing results yourself. Once you have all the tool results you need, reply with the final numeric answer in plain text.";
341const PARALLEL_PROMPT: &str = "Compute 3 + 4 and 10 - 2. You MUST call the add tool and the subtract tool together in your first response, as two parallel function calls, then report both results.";
342const PING_OUTPUT: &str = "pong-crimson-7423";
343const MOTTO_OUTPUT: &str = "steady hands\ncalm waters";
344
345fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
346 mutex
347 .lock()
348 .unwrap_or_else(|poisoned| poisoned.into_inner())
349}
350
351fn tool_result_values(message: &Message) -> Vec<serde_json::Value> {
352 let Message::User { content } = message else {
353 return Vec::new();
354 };
355 content
356 .iter()
357 .filter_map(|item| match item {
358 UserContent::ToolResult(result) => Some(result),
359 _ => None,
360 })
361 .flat_map(|result| result.content.iter())
362 .filter_map(|content| match content {
363 rig_core::message::ToolResultContent::Text(text) => {
364 Some(serde_json::Value::String(text.text.clone()))
365 }
366 rig_core::message::ToolResultContent::Json { value } => Some(value.clone()),
367 rig_core::message::ToolResultContent::Image(_) => None,
368 })
369 .collect()
370}
371
372fn validate_tool_correlation(
373 scenario: &'static str,
374 messages: &[Message],
375) -> Result<(), ScenarioError> {
376 let mut calls = Vec::new();
377 let mut results = Vec::new();
378 for message in messages {
379 match message {
380 Message::Assistant { content, .. } => {
381 calls.extend(content.iter().filter_map(|item| match item {
382 AssistantContent::ToolCall(call) => {
383 Some((call.id.as_str(), call.call_id.as_deref()))
384 }
385 _ => None,
386 }));
387 }
388 Message::User { content } => {
389 results.extend(content.iter().filter_map(|item| match item {
390 UserContent::ToolResult(result) => {
391 Some((result.id.as_str(), result.call_id.as_deref()))
392 }
393 _ => None,
394 }));
395 }
396 Message::System { .. } => {}
397 }
398 }
399 if calls.is_empty() {
400 return Err(ScenarioError::contract(
401 scenario,
402 format!("history has no assistant tool calls: {messages:?}"),
403 ));
404 }
405 for (id, call_id) in &calls {
406 let matches = results
407 .iter()
408 .filter(|(result_id, result_call_id)| result_id == id && call_id == result_call_id)
409 .count();
410 if matches != 1 {
411 return Err(ScenarioError::contract(
412 scenario,
413 format!(
414 "tool call id={id:?} call_id={call_id:?} has {matches} correlated results; calls={calls:?}, results={results:?}"
415 ),
416 ));
417 }
418 }
419 if results.len() != calls.len() {
420 return Err(ScenarioError::contract(
421 scenario,
422 format!(
423 "history contains dangling calls or results: calls={calls:?}, results={results:?}"
424 ),
425 ));
426 }
427 Ok(())
428}
429
430#[derive(Debug, Deserialize, JsonSchema)]
431struct OperationArgs {
432 x: i64,
433 y: i64,
434}
435
436#[derive(Clone)]
437struct CountingAdd(Arc<AtomicUsize>);
438
439impl Tool for CountingAdd {
440 const NAME: &'static str = "add";
441 type Error = ConformanceToolError;
442 type Args = OperationArgs;
443 type Output = i64;
444
445 fn description(&self) -> String {
446 "Add x and y together".to_string()
447 }
448
449 fn parameters(&self) -> serde_json::Value {
450 serde_json::json!({
451 "type": "object",
452 "properties": {
453 "x": { "type": "number", "description": "The first operand" },
454 "y": { "type": "number", "description": "The second operand" }
455 },
456 "required": ["x", "y"]
457 })
458 }
459
460 async fn call(
461 &self,
462 _context: &mut ToolContext,
463 args: Self::Args,
464 ) -> Result<Self::Output, Self::Error> {
465 self.0.fetch_add(1, Ordering::SeqCst);
466 Ok(args.x + args.y)
467 }
468}
469
470#[derive(Clone)]
471struct CountingSum(Arc<AtomicUsize>);
472
473impl Tool for CountingSum {
474 const NAME: &'static str = "sum";
475 type Error = ConformanceToolError;
476 type Args = OperationArgs;
477 type Output = i64;
478
479 fn description(&self) -> String {
480 "Add x and y together (alias of add)".to_string()
481 }
482
483 fn parameters(&self) -> serde_json::Value {
484 CountingAdd(Arc::new(AtomicUsize::new(0))).parameters()
485 }
486
487 async fn call(
488 &self,
489 _context: &mut ToolContext,
490 args: Self::Args,
491 ) -> Result<Self::Output, Self::Error> {
492 self.0.fetch_add(1, Ordering::SeqCst);
493 Ok(args.x + args.y)
494 }
495}
496
497#[derive(Clone)]
498struct CountingSubtract(Arc<AtomicUsize>);
499
500impl Tool for CountingSubtract {
501 const NAME: &'static str = "subtract";
502 type Error = ConformanceToolError;
503 type Args = OperationArgs;
504 type Output = i64;
505
506 fn description(&self) -> String {
507 "Subtract y from x (i.e. x - y)".to_string()
508 }
509
510 fn parameters(&self) -> serde_json::Value {
511 serde_json::json!({
512 "type": "object",
513 "properties": {
514 "x": { "type": "number", "description": "The first operand" },
515 "y": { "type": "number", "description": "The second operand" }
516 },
517 "required": ["x", "y"]
518 })
519 }
520
521 async fn call(
522 &self,
523 _context: &mut ToolContext,
524 args: Self::Args,
525 ) -> Result<Self::Output, Self::Error> {
526 self.0.fetch_add(1, Ordering::SeqCst);
527 Ok(args.x - args.y)
528 }
529}
530
531#[derive(Clone)]
532struct RewriteArgument {
533 key: &'static str,
534 value: serde_json::Value,
535}
536
537impl AgentHook for RewriteArgument {
538 async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCallEvent<'_>) -> ToolCallAction {
539 if event.tool_name != CountingAdd::NAME {
540 return ToolCallAction::run();
541 }
542 let Ok(mut arguments) = serde_json::from_str::<serde_json::Value>(event.args) else {
543 return ToolCallAction::run();
544 };
545 let Some(object) = arguments.as_object_mut() else {
546 return ToolCallAction::run();
547 };
548 object.insert(self.key.to_string(), self.value.clone());
549 ToolCallAction::rewrite(arguments)
550 }
551}
552
553#[derive(Clone, Default)]
554struct ObserveArguments(Arc<Mutex<Vec<serde_json::Value>>>);
555
556impl AgentHook for ObserveArguments {
557 async fn on_tool_call(&self, _ctx: &HookContext, event: ToolCallEvent<'_>) -> ToolCallAction {
558 let value = serde_json::from_str(event.args)
559 .unwrap_or_else(|_| serde_json::Value::String(event.args.to_string()));
560 lock_recover(&self.0).push(value);
561 ToolCallAction::run()
562 }
563}
564
565#[derive(Clone)]
566struct ReplaceResult(&'static str);
567
568impl AgentHook for ReplaceResult {
569 async fn on_tool_result(
570 &self,
571 _ctx: &HookContext,
572 event: ToolResultEvent<'_>,
573 ) -> ToolResultAction {
574 if event.tool_name == CountingAdd::NAME {
575 ToolResultAction::rewrite(self.0)
576 } else {
577 ToolResultAction::keep()
578 }
579 }
580}
581
582#[derive(Clone)]
583struct WrapResult;
584
585impl AgentHook for WrapResult {
586 async fn on_tool_result(
587 &self,
588 _ctx: &HookContext,
589 event: ToolResultEvent<'_>,
590 ) -> ToolResultAction {
591 if event.tool_name == CountingAdd::NAME {
592 ToolResultAction::rewrite(format!("[{}]", event.presentation.render()))
593 } else {
594 ToolResultAction::keep()
595 }
596 }
597}
598
599#[derive(Clone)]
600struct FirstTurnPatch(RequestPatch);
601
602impl AgentHook for FirstTurnPatch {
603 async fn on_completion_call(
604 &self,
605 ctx: &HookContext,
606 _event: CompletionCallEvent<'_>,
607 ) -> CompletionCallAction {
608 if ctx.turn() == 1 {
609 CompletionCallAction::patch(self.0.clone())
610 } else {
611 CompletionCallAction::continue_run()
612 }
613 }
614}
615
616#[derive(Clone)]
617struct StopAfterResult(&'static str);
618
619impl AgentHook for StopAfterResult {
620 async fn on_tool_result(
621 &self,
622 _ctx: &HookContext,
623 event: ToolResultEvent<'_>,
624 ) -> ToolResultAction {
625 if event.tool_name == CountingAdd::NAME {
626 ToolResultAction::stop(self.0)
627 } else {
628 ToolResultAction::keep()
629 }
630 }
631}
632
633#[derive(Debug, Deserialize, JsonSchema)]
634struct EmptyArgs {}
635
636#[derive(Clone)]
637struct PingTool(Arc<AtomicUsize>);
638
639impl Tool for PingTool {
640 const NAME: &'static str = "ping";
641 type Error = ConformanceToolError;
642 type Args = EmptyArgs;
643 type Output = String;
644
645 fn description(&self) -> String {
646 "Return the current ping marker. Takes no arguments.".to_string()
647 }
648
649 fn parameters(&self) -> serde_json::Value {
650 serde_json::json!({ "type": "object", "properties": {}, "required": [] })
651 }
652
653 async fn call(
654 &self,
655 _context: &mut ToolContext,
656 _args: Self::Args,
657 ) -> Result<Self::Output, Self::Error> {
658 self.0.fetch_add(1, Ordering::SeqCst);
659 Ok(PING_OUTPUT.to_string())
660 }
661}
662
663#[derive(Clone)]
664struct MottoTool(Arc<AtomicUsize>);
665
666impl Tool for MottoTool {
667 const NAME: &'static str = "fetch_motto";
668 type Error = ConformanceToolError;
669 type Args = EmptyArgs;
670 type Output = String;
671
672 fn description(&self) -> String {
673 "Fetch the two-line workshop motto.".to_string()
674 }
675
676 fn parameters(&self) -> serde_json::Value {
677 serde_json::json!({ "type": "object", "properties": {}, "required": [] })
678 }
679
680 async fn call(
681 &self,
682 _context: &mut ToolContext,
683 _args: Self::Args,
684 ) -> Result<Self::Output, Self::Error> {
685 self.0.fetch_add(1, Ordering::SeqCst);
686 Ok(MOTTO_OUTPUT.to_string())
687 }
688}
689
690#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
691struct ConfigOutput {
692 service: String,
693 max_retries: u64,
694}
695
696#[derive(Clone)]
697struct ConfigTool(Arc<AtomicUsize>);
698
699impl Tool for ConfigTool {
700 const NAME: &'static str = "fetch_config";
701 type Error = ConformanceToolError;
702 type Args = EmptyArgs;
703 type Output = ConfigOutput;
704
705 fn description(&self) -> String {
706 "Fetch the service configuration object.".to_string()
707 }
708
709 fn parameters(&self) -> serde_json::Value {
710 serde_json::json!({ "type": "object", "properties": {}, "required": [] })
711 }
712
713 async fn call(
714 &self,
715 _context: &mut ToolContext,
716 _args: Self::Args,
717 ) -> Result<Self::Output, Self::Error> {
718 self.0.fetch_add(1, Ordering::SeqCst);
719 Ok(ConfigOutput {
720 service: "cassette-lab".to_string(),
721 max_retries: 3,
722 })
723 }
724}
725
726#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
727#[serde(rename_all = "snake_case")]
728enum ComplexMode {
729 Careful,
730 Fast,
731}
732
733#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
734struct ComplexProfile {
735 name: String,
736 tags: Vec<String>,
737}
738
739#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
740struct ComplexArgs {
741 profile: ComplexProfile,
742 mode: ComplexMode,
743 note: Option<String>,
744 quote: String,
745}
746
747#[derive(Debug, Deserialize, Serialize, JsonSchema)]
748struct ExtractedPerson {
749 #[schemars(required)]
750 first_name: Option<String>,
751 #[schemars(required)]
752 last_name: Option<String>,
753 #[schemars(required)]
754 job: Option<String>,
755}
756
757#[derive(Clone)]
758struct CaptureComplexTool {
759 calls: Arc<AtomicUsize>,
760 captured: Arc<Mutex<Option<ComplexArgs>>>,
761}
762
763impl Tool for CaptureComplexTool {
764 const NAME: &'static str = "store_profile";
765 type Error = ConformanceToolError;
766 type Args = ComplexArgs;
767 type Output = ComplexArgs;
768
769 fn description(&self) -> String {
770 "Store one profile with its nested tags, mode, optional note, and exact quoted text."
771 .to_string()
772 }
773
774 fn parameters(&self) -> serde_json::Value {
775 serde_json::to_value(schemars::schema_for!(ComplexArgs)).unwrap_or_default()
776 }
777
778 async fn call(
779 &self,
780 _context: &mut ToolContext,
781 args: Self::Args,
782 ) -> Result<Self::Output, Self::Error> {
783 self.calls.fetch_add(1, Ordering::SeqCst);
784 *lock_recover(&self.captured) = Some(args.clone());
785 Ok(args)
786 }
787}
788
789fn has_tool_roundtrip(messages: Option<&[Message]>) -> bool {
790 let saw_call = messages.is_some_and(|messages| {
791 messages.iter().any(|message| {
792 matches!(
793 message,
794 Message::Assistant { content, .. }
795 if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
796 )
797 })
798 });
799 let saw_result = messages.is_some_and(|messages| {
800 messages.iter().any(|message| {
801 matches!(
802 message,
803 Message::User { content }
804 if content.iter().any(|item| matches!(item, UserContent::ToolResult(_)))
805 )
806 })
807 });
808 saw_call && saw_result
809}
810
811#[derive(Debug, Deserialize, JsonSchema)]
812struct RepeatArgs {
813 text: String,
815 times: Option<u32>,
817}
818
819#[derive(Clone)]
820struct RepeatTool {
821 calls: Arc<AtomicUsize>,
822}
823
824impl Tool for RepeatTool {
825 const NAME: &'static str = "repeat_text";
826 type Error = ConformanceToolError;
827 type Args = RepeatArgs;
828 type Output = String;
829
830 fn description(&self) -> String {
831 "Repeat `text`. `times` is optional and defaults to 2.".to_string()
832 }
833
834 fn parameters(&self) -> serde_json::Value {
835 serde_json::to_value(schemars::schema_for!(RepeatArgs)).unwrap_or_default()
836 }
837
838 async fn call(
839 &self,
840 _context: &mut ToolContext,
841 args: Self::Args,
842 ) -> Result<Self::Output, Self::Error> {
843 self.calls.fetch_add(1, Ordering::SeqCst);
844 Ok(vec![args.text.as_str(); args.times.unwrap_or(2) as usize].join(" "))
845 }
846}
847
848#[derive(Debug, Deserialize, JsonSchema)]
849struct BinOpArgs {
850 a: i64,
851 b: i64,
852}
853
854#[derive(Debug, Deserialize, JsonSchema)]
855struct ArithmeticResult {
856 answer: i64,
857 explanation: Option<String>,
858}
859
860#[derive(Clone)]
861struct AddTool(Arc<AtomicUsize>);
862
863impl Tool for AddTool {
864 const NAME: &'static str = "add";
865 type Error = ConformanceToolError;
866 type Args = BinOpArgs;
867 type Output = i64;
868
869 fn description(&self) -> String {
870 "Add two integers a and b.".to_string()
871 }
872
873 fn parameters(&self) -> serde_json::Value {
874 serde_json::to_value(schemars::schema_for!(BinOpArgs)).unwrap_or_default()
875 }
876
877 async fn call(
878 &self,
879 _context: &mut ToolContext,
880 args: Self::Args,
881 ) -> Result<Self::Output, Self::Error> {
882 self.0.fetch_add(1, Ordering::SeqCst);
883 Ok(args.a + args.b)
884 }
885}
886
887#[derive(Clone)]
888struct MultiplyTool(Arc<AtomicUsize>);
889
890impl Tool for MultiplyTool {
891 const NAME: &'static str = "multiply";
892 type Error = ConformanceToolError;
893 type Args = BinOpArgs;
894 type Output = i64;
895
896 fn description(&self) -> String {
897 "Multiply two integers a and b.".to_string()
898 }
899
900 fn parameters(&self) -> serde_json::Value {
901 serde_json::to_value(schemars::schema_for!(BinOpArgs)).unwrap_or_default()
902 }
903
904 async fn call(
905 &self,
906 _context: &mut ToolContext,
907 args: Self::Args,
908 ) -> Result<Self::Output, Self::Error> {
909 self.0.fetch_add(1, Ordering::SeqCst);
910 Ok(args.a * args.b)
911 }
912}
913
914fn report_from_response(
915 name: &'static str,
916 started: Instant,
917 tool_calls: usize,
918 response: crate::agent::PromptResponse,
919) -> Result<ScenarioReport, ScenarioError> {
920 if let Some(messages) = response.messages.as_deref() {
921 validate_protocol_hygiene(
922 name,
923 &response.output,
924 messages,
925 &[
926 "<tool_call>",
927 "</tool_call>",
928 "<tool_response>",
929 "</tool_response>",
930 "<|im_start|>",
931 "<|im_end|>",
932 "<think>",
933 "</think>",
934 ],
935 )?;
936 }
937 Ok(ScenarioReport {
938 name,
939 tool_calls,
940 prompt_tokens: response.usage.input_tokens,
941 generated_tokens: response.usage.output_tokens,
942 history_messages: response.messages.as_ref().map_or(0, Vec::len),
943 duration: started.elapsed(),
944 response: response.output,
945 })
946}
947
948fn value_matches_integer(value: &serde_json::Value, expected: i64) -> bool {
949 value.as_i64() == Some(expected)
950 || value
951 .as_str()
952 .and_then(|text| text.trim().parse::<i64>().ok())
953 == Some(expected)
954}
955
956pub async fn parallel_tools<M, F>(
962 model: M,
963 configure: F,
964 tool_concurrency: Option<usize>,
965) -> Result<ScenarioReport, ScenarioError>
966where
967 M: CompletionModel + 'static,
968 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
969{
970 let add_calls = Arc::new(AtomicUsize::new(0));
971 let subtract_calls = Arc::new(AtomicUsize::new(0));
972 let started = Instant::now();
973 let agent = configure(AgentBuilder::new(model))
974 .preamble(FORCE_TOOLS_PREAMBLE)
975 .temperature(0.0)
976 .tool(CountingAdd(add_calls.clone()))
977 .tool(CountingSubtract(subtract_calls.clone()))
978 .default_max_turns(3)
979 .build();
980 let request = agent.prompt(PARALLEL_PROMPT).max_turns(3);
981 let response = match tool_concurrency {
982 Some(concurrency) => {
983 request
984 .tool_concurrency(concurrency)
985 .extended_details()
986 .await?
987 }
988 None => request.extended_details().await?,
989 };
990 let scenario = if tool_concurrency == Some(1) {
991 "parallel_tools_serial_execution"
992 } else {
993 "parallel_tools"
994 };
995 let messages = response.messages.as_deref().ok_or_else(|| {
996 ScenarioError::contract(scenario, "extended run omitted accumulated message history")
997 })?;
998 validate_tool_correlation(scenario, messages)?;
999
1000 let Some((call_index, calls)) = messages.iter().enumerate().find_map(|(index, message)| {
1001 let Message::Assistant { content, .. } = message else {
1002 return None;
1003 };
1004 let calls = content
1005 .iter()
1006 .filter_map(|item| match item {
1007 AssistantContent::ToolCall(call) => Some(call),
1008 _ => None,
1009 })
1010 .collect::<Vec<_>>();
1011 (calls.len() == 2).then_some((index, calls))
1012 }) else {
1013 return Err(ScenarioError::contract(
1014 scenario,
1015 format!("no assistant turn contained exactly two tool calls: {messages:?}"),
1016 ));
1017 };
1018 let mut names = calls
1019 .iter()
1020 .map(|call| call.function.name.as_str())
1021 .collect::<Vec<_>>();
1022 names.sort_unstable();
1023 if names != ["add", "subtract"] {
1024 return Err(ScenarioError::contract(
1025 scenario,
1026 format!("parallel turn called {names:?}, expected add and subtract"),
1027 ));
1028 }
1029 let results_message = messages.get(call_index + 1).ok_or_else(|| {
1030 ScenarioError::contract(
1031 scenario,
1032 "parallel call turn has no following result message",
1033 )
1034 })?;
1035 let values = tool_result_values(results_message);
1036 if !values.iter().any(|value| value_matches_integer(value, 7))
1037 || !values.iter().any(|value| value_matches_integer(value, 8))
1038 || values.len() != 2
1039 {
1040 return Err(ScenarioError::contract(
1041 scenario,
1042 format!("parallel result message did not contain exactly 7 and 8: {values:?}"),
1043 ));
1044 }
1045 let add = add_calls.load(Ordering::SeqCst);
1046 let subtract = subtract_calls.load(Ordering::SeqCst);
1047 if add != 1 || subtract != 1 {
1048 return Err(ScenarioError::contract(
1049 scenario,
1050 format!("execution counts were add={add}, subtract={subtract}, expected one each"),
1051 ));
1052 }
1053 report_from_response(scenario, started, add + subtract, response)
1054}
1055
1056pub async fn zero_argument_tool<M, F>(
1058 model: M,
1059 configure: F,
1060) -> Result<ScenarioReport, ScenarioError>
1061where
1062 M: CompletionModel + 'static,
1063 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1064{
1065 const SCENARIO: &str = "zero_argument_tool";
1066 let calls = Arc::new(AtomicUsize::new(0));
1067 let started = Instant::now();
1068 let agent = configure(AgentBuilder::new(model))
1069 .preamble("You must use the provided tools. Report tool outputs exactly as returned.")
1070 .temperature(0.0)
1071 .tool(PingTool(calls.clone()))
1072 .default_max_turns(2)
1073 .build();
1074 let response = agent
1075 .prompt("Call the ping tool, then report the exact marker it returns.")
1076 .max_turns(2)
1077 .extended_details()
1078 .await?;
1079 let messages = response.messages.as_deref().ok_or_else(|| {
1080 ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1081 })?;
1082 validate_tool_correlation(SCENARIO, messages)?;
1083 let values = messages
1084 .iter()
1085 .flat_map(tool_result_values)
1086 .collect::<Vec<_>>();
1087 if calls.load(Ordering::SeqCst) != 1
1088 || !values
1089 .iter()
1090 .any(|value| value.as_str() == Some(PING_OUTPUT))
1091 || !response.output.contains(PING_OUTPUT)
1092 {
1093 return Err(ScenarioError::contract(
1094 SCENARIO,
1095 format!(
1096 "calls={}, results={values:?}, response={:?}",
1097 calls.load(Ordering::SeqCst),
1098 response.output
1099 ),
1100 ));
1101 }
1102 report_from_response(SCENARIO, started, 1, response)
1103}
1104
1105pub async fn tool_output_serialization<M, F>(
1108 model: M,
1109 configure: F,
1110) -> Result<ScenarioReport, ScenarioError>
1111where
1112 M: CompletionModel + 'static,
1113 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1114{
1115 const SCENARIO: &str = "tool_output_serialization";
1116 let started = Instant::now();
1117 let motto_calls = Arc::new(AtomicUsize::new(0));
1118 let config_calls = Arc::new(AtomicUsize::new(0));
1119 let agent = configure(AgentBuilder::new(model))
1120 .preamble("You must use the provided tools before answering.")
1121 .temperature(0.0)
1122 .tool(MottoTool(motto_calls.clone()))
1123 .tool(ConfigTool(config_calls.clone()))
1124 .default_max_turns(3)
1125 .build();
1126 let response = agent
1127 .prompt("Call fetch_motto and fetch_config, then summarize both outputs in one sentence.")
1128 .max_turns(3)
1129 .extended_details()
1130 .await?;
1131 let messages = response.messages.as_deref().ok_or_else(|| {
1132 ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1133 })?;
1134 validate_tool_correlation(SCENARIO, messages)?;
1135 let values = messages
1136 .iter()
1137 .flat_map(tool_result_values)
1138 .collect::<Vec<_>>();
1139 let expected_config = serde_json::to_value(ConfigOutput {
1140 service: "cassette-lab".to_string(),
1141 max_retries: 3,
1142 })?;
1143 let motto_ok = values
1144 .iter()
1145 .any(|value| value.as_str() == Some(MOTTO_OUTPUT));
1146 let config_ok = values.iter().any(|value| {
1147 value == &expected_config
1148 || value
1149 .as_str()
1150 .and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
1151 .as_ref()
1152 == Some(&expected_config)
1153 });
1154 let motto_count = motto_calls.load(Ordering::SeqCst);
1155 let config_count = config_calls.load(Ordering::SeqCst);
1156 if !motto_ok || !config_ok || motto_count != 1 || config_count != 1 {
1157 return Err(ScenarioError::contract(
1158 SCENARIO,
1159 format!(
1160 "expected one verbatim motto and one semantic config JSON; motto_calls={motto_count}, config_calls={config_count}, values={values:?}"
1161 ),
1162 ));
1163 }
1164 report_from_response(SCENARIO, started, motto_count + config_count, response)
1165}
1166
1167pub async fn complex_tool_arguments<M, F>(
1170 model: M,
1171 configure: F,
1172) -> Result<ScenarioReport, ScenarioError>
1173where
1174 M: CompletionModel + 'static,
1175 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1176{
1177 const SCENARIO: &str = "complex_tool_arguments";
1178 let expected = ComplexArgs {
1179 profile: ComplexProfile {
1180 name: "Zoë \"Z\"".to_string(),
1181 tags: vec!["rust".to_string(), "東京".to_string()],
1182 },
1183 mode: ComplexMode::Careful,
1184 note: Some("line one\nline two".to_string()),
1185 quote: "path C:\\tmp and \"quoted\"".to_string(),
1186 };
1187 let calls = Arc::new(AtomicUsize::new(0));
1188 let captured = Arc::new(Mutex::new(None));
1189 let started = Instant::now();
1190 let agent = configure(AgentBuilder::new(model))
1191 .preamble("Use store_profile exactly once with every value supplied by the user.")
1192 .temperature(0.0)
1193 .tool(CaptureComplexTool {
1194 calls: calls.clone(),
1195 captured: captured.clone(),
1196 })
1197 .default_max_turns(3)
1198 .build();
1199 let response = agent
1200 .prompt(
1201 "Call store_profile with profile.name exactly `Zoë \\\"Z\\\"`, profile.tags exactly [`rust`, `東京`], mode `careful`, note containing the two lines `line one` and `line two` separated by a newline, and quote exactly `path C:\\\\tmp and \\\"quoted\\\"`. Then confirm it was stored.",
1202 )
1203 .max_turns(3)
1204 .extended_details()
1205 .await?;
1206 let observed = lock_recover(&captured).clone();
1207 if calls.load(Ordering::SeqCst) != 1 || observed.as_ref() != Some(&expected) {
1208 return Err(ScenarioError::contract(
1209 SCENARIO,
1210 format!(
1211 "calls={}, expected={expected:?}, observed={observed:?}, response={:?}",
1212 calls.load(Ordering::SeqCst),
1213 response.output
1214 ),
1215 ));
1216 }
1217 let messages = response.messages.as_deref().ok_or_else(|| {
1218 ScenarioError::contract(SCENARIO, "extended run omitted accumulated message history")
1219 })?;
1220 validate_tool_correlation(SCENARIO, messages)?;
1221 report_from_response(SCENARIO, started, 1, response)
1222}
1223
1224pub async fn buffered_streaming_text_parity<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1227where
1228 M: CompletionModel + Clone + 'static,
1229{
1230 const SCENARIO: &str = "buffered_streaming_text_parity";
1231 const PROMPT: &str = "Answer with exactly the single word Paris.";
1232 let started = Instant::now();
1233 let request = || {
1234 model
1235 .completion_request(PROMPT)
1236 .temperature(0.0)
1237 .max_tokens(32)
1238 .build()
1239 };
1240 let buffered = model.completion(request()).await?;
1241 let buffered_text = buffered
1242 .choice
1243 .iter()
1244 .filter_map(|item| match item {
1245 AssistantContent::Text(text) => Some(text.text.as_str()),
1246 _ => None,
1247 })
1248 .collect::<String>();
1249
1250 let mut stream = model.stream(request()).await?;
1251 let mut streamed_text = String::new();
1252 let mut streamed_usage = None;
1253 while let Some(item) = stream.next().await {
1254 match item? {
1255 crate::streaming::StreamedAssistantContent::Text(text) => {
1256 streamed_text.push_str(&text.text);
1257 }
1258 crate::streaming::StreamedAssistantContent::Final(response) => {
1259 streamed_usage = Some(crate::completion::GetTokenUsage::token_usage(&response));
1260 }
1261 crate::streaming::StreamedAssistantContent::ToolCall { .. }
1262 | crate::streaming::StreamedAssistantContent::ToolCallDelta { .. }
1263 | crate::streaming::StreamedAssistantContent::Reasoning(_)
1264 | crate::streaming::StreamedAssistantContent::ReasoningDelta { .. }
1265 | crate::streaming::StreamedAssistantContent::Unknown(_) => {}
1266 }
1267 }
1268 let usage = streamed_usage.ok_or_else(|| {
1269 ScenarioError::contract(SCENARIO, "raw stream omitted its final response metadata")
1270 })?;
1271 let normalize = |text: &str| {
1272 text.trim()
1273 .trim_matches(|character: char| !character.is_alphanumeric())
1274 .to_string()
1275 };
1276 let buffered_answer = normalize(&buffered_text);
1277 let streamed_answer = normalize(&streamed_text);
1278 if !buffered_answer.eq_ignore_ascii_case("Paris")
1279 || !streamed_answer.eq_ignore_ascii_case("Paris")
1280 || !buffered.usage.has_values()
1281 || !usage.has_values()
1282 {
1283 return Err(ScenarioError::contract(
1284 SCENARIO,
1285 format!(
1286 "buffered={buffered_text:?}, streamed={streamed_text:?}, buffered_usage={:?}, streamed_usage={usage:?}",
1287 buffered.usage
1288 ),
1289 ));
1290 }
1291 Ok(ScenarioReport {
1292 name: SCENARIO,
1293 tool_calls: 0,
1294 prompt_tokens: usage.input_tokens,
1295 generated_tokens: usage.output_tokens,
1296 history_messages: 0,
1297 duration: started.elapsed(),
1298 response: streamed_text,
1299 })
1300}
1301
1302pub async fn structured_extraction<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1305where
1306 M: CompletionModel + 'static,
1307{
1308 const SCENARIO: &str = "structured_extraction";
1309 const INPUT: &str = "Hello, my name is Ada Lovelace and I work as a mathematician.";
1310 let started = Instant::now();
1311 let response = crate::extractor::ExtractorBuilder::<M, ExtractedPerson>::new(model)
1312 .max_tokens(384)
1313 .retries(0)
1314 .build()
1315 .extract_with_usage(INPUT)
1316 .await?;
1317 validate_extraction_fields(
1318 SCENARIO,
1319 response.data.first_name.as_deref(),
1320 response.data.last_name.as_deref(),
1321 response.data.job.as_deref(),
1322 response.usage,
1323 )?;
1324 Ok(ScenarioReport {
1325 name: SCENARIO,
1326 tool_calls: 1,
1327 prompt_tokens: response.usage.input_tokens,
1328 generated_tokens: response.usage.output_tokens,
1329 history_messages: 0,
1330 duration: started.elapsed(),
1331 response: format!(
1332 "{} {} — {}",
1333 response.data.first_name.as_deref().unwrap_or_default(),
1334 response.data.last_name.as_deref().unwrap_or_default(),
1335 response.data.job.as_deref().unwrap_or_default()
1336 ),
1337 })
1338}
1339
1340fn restricted_recovery_run(
1341 prompt: &str,
1342 turn: ModelTurn,
1343 retries: usize,
1344) -> Result<AgentRun, ScenarioError> {
1345 const SCENARIO: &str = "invalid_tool_recovery";
1346 let mut run = AgentRun::new(prompt)
1347 .max_turns(2)
1348 .max_invalid_tool_call_retries(retries);
1349 if !matches!(run.next_step()?, AgentRunStep::CallModel { .. }) {
1350 return Err(ScenarioError::contract(
1351 SCENARIO,
1352 "fresh AgentRun did not request a model turn",
1353 ));
1354 }
1355 let outcome = run.model_response(turn)?;
1356 let ModelTurnOutcome::NeedsResolution(context) = outcome else {
1357 return Err(ScenarioError::contract(
1358 SCENARIO,
1359 format!("disallowed tool call did not require resolution: {outcome:?}"),
1360 ));
1361 };
1362 if context.tool_name != CountingAdd::NAME {
1363 return Err(ScenarioError::contract(
1364 SCENARIO,
1365 format!("expected rejected add call, observed {context:?}"),
1366 ));
1367 }
1368 Ok(run)
1369}
1370
1371pub async fn invalid_tool_recovery<M, F>(
1374 model: M,
1375 configure: F,
1376) -> Result<ScenarioReport, ScenarioError>
1377where
1378 M: CompletionModel + 'static,
1379 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1380{
1381 const SCENARIO: &str = "invalid_tool_recovery";
1382 const PROMPT: &str = "Call the add tool exactly once with x=2 and y=3. Do not call sum.";
1383 let started = Instant::now();
1384 let add_calls = Arc::new(AtomicUsize::new(0));
1385 let sum_calls = Arc::new(AtomicUsize::new(0));
1386 let agent = configure(AgentBuilder::new(model))
1387 .preamble(FORCE_TOOLS_PREAMBLE)
1388 .temperature(0.0)
1389 .tool(CountingAdd(add_calls.clone()))
1390 .tool(CountingSum(sum_calls.clone()))
1391 .tool_choice(ToolChoice::Required)
1392 .build();
1393 #[derive(Clone)]
1394 struct CaptureTurn(Arc<Mutex<Option<ModelTurn>>>);
1395
1396 impl AgentHook for CaptureTurn {
1397 async fn on_completion_response(
1398 &self,
1399 _ctx: &HookContext,
1400 event: CompletionResponseEvent<'_>,
1401 ) -> ObservationAction {
1402 *lock_recover(&self.0) = Some(ModelTurn::new(
1403 event.message_id.map(str::to_owned),
1404 event.content.clone(),
1405 event.usage,
1406 BTreeSet::new(),
1407 BTreeSet::new(),
1408 ));
1409 ObservationAction::stop("captured conformance model turn")
1410 }
1411 }
1412
1413 let captured = Arc::new(Mutex::new(None));
1414 let stopped = agent
1415 .runner(PROMPT)
1416 .add_hook(CaptureTurn(captured.clone()))
1417 .run()
1418 .await;
1419 if !matches!(stopped, Err(PromptError::PromptCancelled { .. })) {
1420 return Err(ScenarioError::contract(
1421 SCENARIO,
1422 format!("capture hook did not stop after the model response: {stopped:?}"),
1423 ));
1424 }
1425 let response = lock_recover(&captured).take().ok_or_else(|| {
1426 ScenarioError::contract(SCENARIO, "capture hook observed no model response")
1427 })?;
1428 let emitted = response
1429 .choice
1430 .iter()
1431 .filter(|item| {
1432 matches!(item, AssistantContent::ToolCall(call) if call.function.name == CountingAdd::NAME)
1433 })
1434 .count();
1435 if emitted != 1 {
1436 return Err(ScenarioError::contract(
1437 SCENARIO,
1438 format!(
1439 "model emitted {emitted} add calls, response={:?}",
1440 response.choice
1441 ),
1442 ));
1443 }
1444 let executable = BTreeSet::from([CountingAdd::NAME.to_string(), CountingSum::NAME.to_string()]);
1445 let allowed = BTreeSet::from([CountingSum::NAME.to_string()]);
1446 let turn = ModelTurn::new(
1447 response.message_id,
1448 response.choice,
1449 response.usage,
1450 executable,
1451 allowed,
1452 );
1453
1454 let mut fail = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1455 let error = match fail.resolve_invalid_tool_call(InvalidToolCallAction::fail()) {
1456 Err(error) => error,
1457 Ok(outcome) => {
1458 return Err(ScenarioError::contract(
1459 SCENARIO,
1460 format!("fail action unexpectedly returned {outcome:?}"),
1461 ));
1462 }
1463 };
1464 validate_unknown_tool_failure(&error, CountingAdd::NAME, &[CountingSum::NAME])?;
1465
1466 let mut retry = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1467 let error = match retry
1468 .resolve_invalid_tool_call(InvalidToolCallAction::retry("choose an allowed tool"))
1469 {
1470 Err(error) => error,
1471 Ok(outcome) => {
1472 return Err(ScenarioError::contract(
1473 SCENARIO,
1474 format!("exhausted retry unexpectedly returned {outcome:?}"),
1475 ));
1476 }
1477 };
1478 validate_unknown_tool_failure(&error, CountingAdd::NAME, &[CountingSum::NAME])?;
1479
1480 let mut rejected_repair = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1481 let error =
1482 match rejected_repair.resolve_invalid_tool_call(InvalidToolCallAction::repair("missing")) {
1483 Err(error) => error,
1484 Ok(outcome) => {
1485 return Err(ScenarioError::contract(
1486 SCENARIO,
1487 format!("disallowed repair unexpectedly returned {outcome:?}"),
1488 ));
1489 }
1490 };
1491 validate_unknown_tool_failure(&error, "missing", &[CountingSum::NAME])?;
1492
1493 let mut repaired = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1494 if !matches!(
1495 repaired.resolve_invalid_tool_call(InvalidToolCallAction::repair(CountingSum::NAME))?,
1496 ModelTurnOutcome::Continue { .. }
1497 ) {
1498 return Err(ScenarioError::contract(
1499 SCENARIO,
1500 "valid repair did not continue",
1501 ));
1502 }
1503 let AgentRunStep::CallTools { calls } = repaired.next_step()? else {
1504 return Err(ScenarioError::contract(
1505 SCENARIO,
1506 "valid repair did not produce pending tool execution",
1507 ));
1508 };
1509 let repaired_call = calls.first();
1510 if calls.len() != 1
1511 || !repaired_call.is_some_and(|call| {
1512 call.tool_call.function.name == CountingSum::NAME && call.preresolved_result.is_none()
1513 })
1514 {
1515 return Err(ScenarioError::contract(
1516 SCENARIO,
1517 format!("repaired pending calls were incorrect: {calls:?}"),
1518 ));
1519 }
1520
1521 let mut skipped = restricted_recovery_run(PROMPT, turn.clone(), 0)?;
1522 if !matches!(
1523 skipped.resolve_invalid_tool_call(InvalidToolCallAction::skip("disabled for this turn"))?,
1524 ModelTurnOutcome::Continue { .. }
1525 ) {
1526 return Err(ScenarioError::contract(SCENARIO, "skip did not continue"));
1527 }
1528 let AgentRunStep::CallTools { calls } = skipped.next_step()? else {
1529 return Err(ScenarioError::contract(
1530 SCENARIO,
1531 "skip did not produce a pre-resolved pending call",
1532 ));
1533 };
1534 let skipped_is_preresolved = match calls.first() {
1535 Some(call) => call.preresolved_result.is_some(),
1536 None => false,
1537 };
1538 if calls.len() != 1 || !skipped_is_preresolved {
1539 return Err(ScenarioError::contract(
1540 SCENARIO,
1541 format!("skipped pending calls were incorrect: {calls:?}"),
1542 ));
1543 }
1544 if add_calls.load(Ordering::SeqCst) != 0 || sum_calls.load(Ordering::SeqCst) != 0 {
1545 return Err(ScenarioError::contract(
1546 SCENARIO,
1547 "recovery scenario executed a tool body",
1548 ));
1549 }
1550
1551 Ok(ScenarioReport {
1552 name: SCENARIO,
1553 tool_calls: emitted,
1554 prompt_tokens: turn.usage.input_tokens,
1555 generated_tokens: turn.usage.output_tokens,
1556 history_messages: 2,
1557 duration: started.elapsed(),
1558 response: "fail, retry, repair, rejected repair, and skip passed".to_string(),
1559 })
1560}
1561
1562pub async fn hook_rewrites_and_request_patch<M, F>(
1565 model: M,
1566 configure: F,
1567) -> Result<ScenarioReport, ScenarioError>
1568where
1569 M: CompletionModel + 'static,
1570 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1571{
1572 const SCENARIO: &str = "hook_rewrites_and_request_patch";
1573 let started = Instant::now();
1574 let calls = Arc::new(AtomicUsize::new(0));
1575 let observed = ObserveArguments::default();
1576 let observed_probe = observed.clone();
1577 let agent = configure(AgentBuilder::new(model))
1578 .preamble("Use add for arithmetic and report only the tool result.")
1579 .temperature(0.0)
1580 .tool(CountingAdd(calls.clone()))
1581 .default_max_turns(3)
1582 .build();
1583 let response = agent
1584 .prompt("Use add once for x=1 and y=1, then report what the tool returns.")
1585 .max_turns(3)
1586 .add_hook(FirstTurnPatch(
1587 RequestPatch::new()
1588 .active_tools([CountingAdd::NAME])
1589 .tool_choice(ToolChoice::Required),
1590 ))
1591 .add_hook(RewriteArgument {
1592 key: "x",
1593 value: serde_json::json!(7),
1594 })
1595 .add_hook(RewriteArgument {
1596 key: "y",
1597 value: serde_json::json!(8),
1598 })
1599 .add_hook(observed)
1600 .add_hook(ReplaceResult("portable-redacted"))
1601 .add_hook(WrapResult)
1602 .extended_details()
1603 .await?;
1604 let observations = lock_recover(&observed_probe.0).clone();
1605 validate_rewritten_arguments(
1606 SCENARIO,
1607 &observations,
1608 &serde_json::json!({ "x": 7, "y": 8 }),
1609 )?;
1610 let messages = response.messages.as_deref().ok_or_else(|| {
1611 ScenarioError::contract(SCENARIO, "extended hook run omitted message history")
1612 })?;
1613 let results = messages
1614 .iter()
1615 .flat_map(tool_result_values)
1616 .collect::<Vec<_>>();
1617 if calls.load(Ordering::SeqCst) != 1
1618 || !results
1619 .iter()
1620 .any(|value| value == &serde_json::json!("[portable-redacted]"))
1621 || response.completion_calls.len() != 2
1622 {
1623 return Err(ScenarioError::contract(
1624 SCENARIO,
1625 format!(
1626 "calls={}, completion_calls={}, results={results:?}, output={:?}",
1627 calls.load(Ordering::SeqCst),
1628 response.completion_calls.len(),
1629 response.output
1630 ),
1631 ));
1632 }
1633 report_from_response(SCENARIO, started, 1, response)
1634}
1635
1636pub async fn cancellation_and_max_turns<M, F>(
1639 model: M,
1640 configure: F,
1641) -> Result<ScenarioReport, ScenarioError>
1642where
1643 M: CompletionModel + Clone + 'static,
1644 F: Fn(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1645{
1646 const SCENARIO: &str = "cancellation_and_max_turns";
1647 const REASON: &str = "portable result veto";
1648 let started = Instant::now();
1649 let cancelled_calls = Arc::new(AtomicUsize::new(0));
1650 let cancelled_agent = configure(AgentBuilder::new(model.clone()))
1651 .preamble("Use add for arithmetic; never calculate by hand.")
1652 .temperature(0.0)
1653 .tool(CountingAdd(cancelled_calls.clone()))
1654 .build();
1655 let cancelled = match cancelled_agent
1656 .prompt("Use add once to compute x=20 plus y=22.")
1657 .max_turns(2)
1658 .add_hook(StopAfterResult(REASON))
1659 .await
1660 {
1661 Err(error) => error,
1662 Ok(output) => {
1663 return Err(ScenarioError::contract(
1664 SCENARIO,
1665 format!("result cancellation unexpectedly completed: {output:?}"),
1666 ));
1667 }
1668 };
1669 validate_cancelled_failure(&cancelled, REASON, CountingAdd::NAME)?;
1670
1671 let max_turn_calls = Arc::new(AtomicUsize::new(0));
1672 let max_turn_agent = configure(AgentBuilder::new(model))
1673 .preamble("Use add for arithmetic; never calculate by hand.")
1674 .temperature(0.0)
1675 .tool(CountingAdd(max_turn_calls.clone()))
1676 .build();
1677 let max_turn = match max_turn_agent
1678 .prompt("Use add once to compute x=20 plus y=22, then report the result.")
1679 .max_turns(1)
1680 .await
1681 {
1682 Err(error) => error,
1683 Ok(output) => {
1684 return Err(ScenarioError::contract(
1685 SCENARIO,
1686 format!("one-turn budget unexpectedly completed: {output:?}"),
1687 ));
1688 }
1689 };
1690 validate_max_turns_failure(&max_turn, 1)?;
1691 let cancelled_count = cancelled_calls.load(Ordering::SeqCst);
1692 let max_turn_count = max_turn_calls.load(Ordering::SeqCst);
1693 if cancelled_count != 1 || max_turn_count != 1 {
1694 return Err(ScenarioError::contract(
1695 SCENARIO,
1696 format!("cancelled executions={cancelled_count}, max-turn executions={max_turn_count}"),
1697 ));
1698 }
1699 Ok(ScenarioReport {
1700 name: SCENARIO,
1701 tool_calls: cancelled_count + max_turn_count,
1702 prompt_tokens: 0,
1703 generated_tokens: 0,
1704 history_messages: 4,
1705 duration: started.elapsed(),
1706 response: "post-result cancellation and max-turn diagnostics passed".to_string(),
1707 })
1708}
1709
1710pub async fn optional_argument<M, F>(
1716 model: M,
1717 configure: F,
1718) -> Result<ScenarioReport, ScenarioError>
1719where
1720 M: CompletionModel + 'static,
1721 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1722{
1723 let calls = Arc::new(AtomicUsize::new(0));
1724 let started = Instant::now();
1725 let agent = configure(AgentBuilder::new(model))
1726 .preamble("Use the repeat_text tool whenever asked to repeat text.")
1727 .tool(RepeatTool {
1728 calls: calls.clone(),
1729 })
1730 .default_max_turns(4)
1731 .build();
1732 let result = agent
1733 .prompt(
1734 "Use the repeat_text tool to repeat the word \"banana\" 3 times, then show me the exact result.",
1735 )
1736 .extended_details()
1737 .await?;
1738 let response = result.output.clone();
1739 let tool_calls = calls.load(Ordering::SeqCst);
1740 if tool_calls == 0
1741 || response.matches("banana").count() < 1
1742 || !has_tool_roundtrip(result.messages.as_deref())
1743 {
1744 return Err(ScenarioError::contract(
1745 "optional_argument",
1746 format!("calls={tool_calls}, response={response:?}"),
1747 ));
1748 }
1749 report_from_response("optional_argument", started, tool_calls, result)
1750}
1751
1752pub async fn sequential_tools<M, F>(model: M, configure: F) -> Result<ScenarioReport, ScenarioError>
1754where
1755 M: CompletionModel + 'static,
1756 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1757{
1758 let add_calls = Arc::new(AtomicUsize::new(0));
1759 let multiply_calls = Arc::new(AtomicUsize::new(0));
1760 let started = Instant::now();
1761 let agent = configure(AgentBuilder::new(model))
1762 .preamble(
1763 "You are a calculator. Use the add and multiply tools for arithmetic; never compute by hand.",
1764 )
1765 .tool(AddTool(add_calls.clone()))
1766 .tool(MultiplyTool(multiply_calls.clone()))
1767 .default_max_turns(6)
1768 .build();
1769 let result = agent
1770 .prompt(
1771 "Compute (4 + 6) * 2. First call the add tool, then call the multiply tool on the result. Tell me the final number.",
1772 )
1773 .extended_details()
1774 .await?;
1775 let response = result.output.clone();
1776 let add = add_calls.load(Ordering::SeqCst);
1777 let multiply = multiply_calls.load(Ordering::SeqCst);
1778 if add == 0
1779 || multiply == 0
1780 || !response.contains("20")
1781 || !has_tool_roundtrip(result.messages.as_deref())
1782 {
1783 return Err(ScenarioError::contract(
1784 "sequential_tools",
1785 format!("add={add}, multiply={multiply}, response={response:?}"),
1786 ));
1787 }
1788 report_from_response("sequential_tools", started, add + multiply, result)
1789}
1790
1791pub async fn streaming_tool<M, F>(model: M, configure: F) -> Result<ScenarioReport, ScenarioError>
1793where
1794 M: CompletionModel + 'static,
1795 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1796{
1797 let calls = Arc::new(AtomicUsize::new(0));
1798 let started = Instant::now();
1799 let agent = configure(AgentBuilder::new(model))
1800 .preamble("Use the add tool for arithmetic; do not calculate by hand.")
1801 .tool(AddTool(calls.clone()))
1802 .default_max_turns(4)
1803 .build();
1804 let mut stream = agent
1805 .stream_prompt("Use add to calculate 17 + 25, then state the final number.")
1806 .max_turns(4)
1807 .await;
1808 let mut final_response = None;
1809 let mut final_count = 0_usize;
1810 let mut completion_usage = crate::completion::Usage::new();
1811 let mut streamed_call_ids = Vec::new();
1812 let mut streamed_result_ids = Vec::new();
1813 while let Some(item) = stream.next().await {
1814 match item? {
1815 MultiTurnStreamItem::StreamAssistantItem(
1816 crate::streaming::StreamedAssistantContent::ToolCall {
1817 internal_call_id, ..
1818 },
1819 ) => streamed_call_ids.push(internal_call_id),
1820 MultiTurnStreamItem::StreamUserItem(
1821 crate::streaming::StreamedUserContent::ToolResult {
1822 internal_call_id, ..
1823 },
1824 ) => streamed_result_ids.push(internal_call_id),
1825 MultiTurnStreamItem::CompletionCall(call) => completion_usage += call.usage,
1826 MultiTurnStreamItem::FinalResponse(response) => {
1827 final_count += 1;
1828 final_response = Some(response);
1829 }
1830 MultiTurnStreamItem::StreamAssistantItem(_)
1831 | MultiTurnStreamItem::ToolExecutionCommitted { .. }
1832 | MultiTurnStreamItem::ModelTurnRetried { .. } => {}
1833 }
1834 }
1835 let result = final_response.ok_or_else(|| {
1836 ScenarioError::contract("streaming_tool", "stream produced no final response")
1837 })?;
1838 let response = result.output.clone();
1839 let history_messages = result.messages.as_ref().map_or(0, Vec::len);
1840 let tool_calls = calls.load(Ordering::SeqCst);
1841 streamed_call_ids.sort();
1842 streamed_result_ids.sort();
1843 let correlated_stream =
1844 !streamed_call_ids.is_empty() && streamed_call_ids == streamed_result_ids;
1845 let correlated_history = result
1846 .messages
1847 .as_deref()
1848 .is_some_and(|messages| validate_tool_correlation("streaming_tool", messages).is_ok());
1849 if tool_calls == 0
1850 || !response.contains("42")
1851 || history_messages < 4
1852 || final_count != 1
1853 || !correlated_stream
1854 || !correlated_history
1855 || completion_usage != result.usage
1856 || result.completion_calls.is_empty()
1857 {
1858 return Err(ScenarioError::contract(
1859 "streaming_tool",
1860 format!(
1861 "calls={tool_calls}, final_count={final_count}, streamed_call_ids={streamed_call_ids:?}, streamed_result_ids={streamed_result_ids:?}, completion_usage={completion_usage:?}, final_usage={:?}, history_messages={history_messages}, response={response:?}",
1862 result.usage
1863 ),
1864 ));
1865 }
1866 if let Some(messages) = result.messages.as_deref() {
1867 validate_protocol_hygiene(
1868 "streaming_tool",
1869 &response,
1870 messages,
1871 &["<tool_call>", "</tool_call>", "<think>", "</think>"],
1872 )?;
1873 }
1874 Ok(ScenarioReport {
1875 name: "streaming_tool",
1876 tool_calls,
1877 prompt_tokens: result.usage.input_tokens,
1878 generated_tokens: result.usage.output_tokens,
1879 history_messages,
1880 duration: started.elapsed(),
1881 response,
1882 })
1883}
1884
1885pub async fn structured_after_tool<M, F>(
1887 model: M,
1888 configure: F,
1889) -> Result<ScenarioReport, ScenarioError>
1890where
1891 M: CompletionModel + 'static,
1892 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
1893{
1894 let calls = Arc::new(AtomicUsize::new(0));
1895 let started = Instant::now();
1896 let agent = configure(AgentBuilder::new(model))
1897 .preamble(
1898 "Use add for arithmetic, then finish by calling the structured output tool exactly once.",
1899 )
1900 .output_schema::<ArithmeticResult>()
1901 .output_mode(OutputMode::Tool)
1902 .tool(AddTool(calls.clone()))
1903 .default_max_turns(5)
1904 .build();
1905 let result = agent
1906 .prompt("Use add to calculate 19 + 23. Return answer=42 and a short optional explanation.")
1907 .extended_details()
1908 .await?;
1909 let response = result.output.clone();
1910 let parsed: ArithmeticResult = serde_json::from_str(&response)?;
1911 let tool_calls = calls.load(Ordering::SeqCst);
1912 if tool_calls == 0 || parsed.answer != 42 || !has_tool_roundtrip(result.messages.as_deref()) {
1913 return Err(ScenarioError::contract(
1914 "structured_after_tool",
1915 format!("calls={tool_calls}, response={response:?}"),
1916 ));
1917 }
1918 let _ = parsed.explanation;
1919 report_from_response("structured_after_tool", started, tool_calls + 1, result)
1920}
1921
1922pub async fn tool_choice_modes<M>(model: M) -> Result<ScenarioReport, ScenarioError>
1924where
1925 M: CompletionModel + 'static,
1926{
1927 let definition = |name: &str| ToolDefinition {
1928 name: name.to_string(),
1929 description: format!("Return the supplied integer using {name}."),
1930 parameters: serde_json::json!({
1931 "type": "object",
1932 "properties": {"value": {"type": "integer"}},
1933 "required": ["value"]
1934 }),
1935 };
1936 let tools = vec![definition("alpha"), definition("beta")];
1937 let started = Instant::now();
1938 let none = model
1939 .completion(
1940 model
1941 .completion_request("Answer with only the number 4. Do not call a function.")
1942 .tools(tools.clone())
1943 .tool_choice(ToolChoice::None)
1944 .temperature(0.0)
1945 .max_tokens(64)
1946 .build(),
1947 )
1948 .await?;
1949 if none
1950 .choice
1951 .iter()
1952 .any(|item| matches!(item, AssistantContent::ToolCall(_)))
1953 {
1954 return Err(ScenarioError::contract(
1955 "tool_choice_modes",
1956 "tool_choice none emitted a tool call",
1957 ));
1958 }
1959
1960 let required = model
1961 .completion(
1962 model
1963 .completion_request("Call alpha with value 7.")
1964 .tools(tools.clone())
1965 .tool_choice(ToolChoice::Required)
1966 .temperature(0.0)
1967 .max_tokens(96)
1968 .build(),
1969 )
1970 .await?;
1971 let required_calls = required
1972 .choice
1973 .iter()
1974 .filter(|item| matches!(item, AssistantContent::ToolCall(_)))
1975 .count();
1976 if required_calls == 0 {
1977 return Err(ScenarioError::contract(
1978 "tool_choice_modes",
1979 "tool_choice required emitted no tool call",
1980 ));
1981 }
1982
1983 let specific = model
1984 .completion(
1985 model
1986 .completion_request("Call beta with value 9.")
1987 .tools(tools)
1988 .tool_choice(ToolChoice::Specific {
1989 function_names: vec!["beta".to_string()],
1990 })
1991 .temperature(0.0)
1992 .max_tokens(96)
1993 .build(),
1994 )
1995 .await?;
1996 let specific_calls = specific
1997 .choice
1998 .iter()
1999 .filter_map(|item| match item {
2000 AssistantContent::ToolCall(call) => Some(call),
2001 _ => None,
2002 })
2003 .collect::<Vec<_>>();
2004 if specific_calls.is_empty()
2005 || specific_calls
2006 .iter()
2007 .any(|call| call.function.name != "beta")
2008 {
2009 return Err(ScenarioError::contract(
2010 "tool_choice_modes",
2011 "specific tool choice did not select only beta",
2012 ));
2013 }
2014
2015 Ok(ScenarioReport {
2016 name: "tool_choice_modes",
2017 tool_calls: required_calls + specific_calls.len(),
2018 prompt_tokens: none.usage.input_tokens
2019 + required.usage.input_tokens
2020 + specific.usage.input_tokens,
2021 generated_tokens: none.usage.output_tokens
2022 + required.usage.output_tokens
2023 + specific.usage.output_tokens,
2024 history_messages: 0,
2025 duration: started.elapsed(),
2026 response: "none, required, and specific modes passed".to_string(),
2027 })
2028}
2029
2030pub async fn streaming_structured_after_tool<M, F>(
2032 model: M,
2033 configure: F,
2034) -> Result<ScenarioReport, ScenarioError>
2035where
2036 M: CompletionModel + 'static,
2037 F: FnOnce(AgentBuilder<M, NoToolConfig>) -> AgentBuilder<M, NoToolConfig>,
2038{
2039 let calls = Arc::new(AtomicUsize::new(0));
2040 let started = Instant::now();
2041 let agent = configure(AgentBuilder::new(model))
2042 .preamble(
2043 "Use add for arithmetic, then finish by calling the structured output tool exactly once.",
2044 )
2045 .output_schema::<ArithmeticResult>()
2046 .output_mode(OutputMode::Tool)
2047 .tool(AddTool(calls.clone()))
2048 .default_max_turns(5)
2049 .build();
2050 let mut stream = agent
2051 .stream_prompt(
2052 "Use add to calculate 19 + 23. Return answer=42 and a short optional explanation.",
2053 )
2054 .max_turns(5)
2055 .await;
2056 let mut final_response = None;
2057 let mut final_count = 0_usize;
2058 while let Some(item) = stream.next().await {
2059 if let MultiTurnStreamItem::FinalResponse(response) = item? {
2060 final_count += 1;
2061 final_response = Some(response);
2062 }
2063 }
2064 let result = final_response.ok_or_else(|| {
2065 ScenarioError::contract(
2066 "streaming_structured_after_tool",
2067 "stream produced no final response",
2068 )
2069 })?;
2070 let parsed: ArithmeticResult = serde_json::from_str(&result.output)?;
2071 let calls = calls.load(Ordering::SeqCst);
2072 if calls == 0
2073 || final_count != 1
2074 || parsed.answer != 42
2075 || !has_tool_roundtrip(result.messages.as_deref())
2076 {
2077 return Err(ScenarioError::contract(
2078 "streaming_structured_after_tool",
2079 format!(
2080 "calls={calls}, final_count={final_count}, response={:?}",
2081 result.output
2082 ),
2083 ));
2084 }
2085 report_from_response(
2086 "streaming_structured_after_tool",
2087 started,
2088 calls + 1,
2089 result,
2090 )
2091}
2092
2093#[cfg(test)]
2094mod tests {
2095 use super::*;
2096 use crate::{
2097 completion::Usage,
2098 test_utils::{MockCompletionModel, MockResponse, MockStreamEvent, MockTurn},
2099 };
2100 use rig_core::{
2101 OneOrMany,
2102 message::{ToolCall, ToolFunction},
2103 };
2104
2105 fn tool_call(id: &str, name: &str, arguments: serde_json::Value) -> AssistantContent {
2106 AssistantContent::ToolCall(ToolCall::new(
2107 id.to_string(),
2108 ToolFunction::new(name.to_string(), arguments),
2109 ))
2110 }
2111
2112 fn usage(input: u64, output: u64) -> Usage {
2113 Usage {
2114 input_tokens: input,
2115 output_tokens: output,
2116 total_tokens: input + output,
2117 ..Usage::new()
2118 }
2119 }
2120
2121 fn fixture_contract(condition: bool, details: &str) -> Result<(), ScenarioError> {
2122 if condition {
2123 Ok(())
2124 } else {
2125 Err(ScenarioError::contract("test_fixture", details))
2126 }
2127 }
2128
2129 #[tokio::test]
2130 async fn parallel_contract_validates_batch_and_correlation() -> Result<(), ScenarioError> {
2131 let first = MockTurn::from_contents([
2132 tool_call("call_add", "add", serde_json::json!({"x": 3, "y": 4})),
2133 tool_call(
2134 "call_subtract",
2135 "subtract",
2136 serde_json::json!({"x": 10, "y": 2}),
2137 ),
2138 ])
2139 .map_err(|error| ScenarioError::contract("test_fixture", error.to_string()))?;
2140 let report = parallel_tools(
2141 MockCompletionModel::new([first, MockTurn::text("7 and 8")]),
2142 |builder| builder,
2143 Some(1),
2144 )
2145 .await?;
2146 fixture_contract(report.tool_calls == 2, "parallel tool-call count")?;
2147 fixture_contract(report.history_messages >= 4, "parallel history length")?;
2148 Ok(())
2149 }
2150
2151 #[tokio::test]
2152 async fn zero_argument_and_output_serialization_contracts_pass() -> Result<(), ScenarioError> {
2153 let zero = zero_argument_tool(
2154 MockCompletionModel::new([
2155 MockTurn::tool_call("ping_call", "ping", serde_json::json!({})),
2156 MockTurn::text(PING_OUTPUT),
2157 ]),
2158 |builder| builder,
2159 )
2160 .await?;
2161 fixture_contract(zero.tool_calls == 1, "zero-argument call count")?;
2162
2163 let first = MockTurn::from_contents([
2164 tool_call("motto_call", "fetch_motto", serde_json::json!({})),
2165 tool_call("config_call", "fetch_config", serde_json::json!({})),
2166 ])
2167 .map_err(|error| ScenarioError::contract("test_fixture", error.to_string()))?;
2168 let serialized = tool_output_serialization(
2169 MockCompletionModel::new([first, MockTurn::text("summary")]),
2170 |builder| builder,
2171 )
2172 .await?;
2173 fixture_contract(serialized.tool_calls == 2, "serialized-output call count")?;
2174 Ok(())
2175 }
2176
2177 #[tokio::test]
2178 async fn complex_arguments_preserve_nested_unicode_and_escapes() -> Result<(), ScenarioError> {
2179 let arguments = serde_json::json!({
2180 "profile": {"name": "Zoë \"Z\"", "tags": ["rust", "東京"]},
2181 "mode": "careful",
2182 "note": "line one\nline two",
2183 "quote": "path C:\\tmp and \"quoted\""
2184 });
2185 let report = complex_tool_arguments(
2186 MockCompletionModel::new([
2187 MockTurn::tool_call("profile_call", "store_profile", arguments),
2188 MockTurn::text("stored"),
2189 ]),
2190 |builder| builder,
2191 )
2192 .await?;
2193 fixture_contract(report.tool_calls == 1, "complex-argument call count")?;
2194 Ok(())
2195 }
2196
2197 #[tokio::test]
2198 async fn extraction_contract_requires_fields_and_usage() -> Result<(), ScenarioError> {
2199 let report = structured_extraction(MockCompletionModel::new([MockTurn::tool_call(
2200 "submit_call",
2201 "submit",
2202 serde_json::json!({
2203 "first_name": "Ada",
2204 "last_name": "Lovelace",
2205 "job": "mathematician"
2206 }),
2207 )
2208 .with_usage(usage(20, 5))]))
2209 .await?;
2210 fixture_contract(report.prompt_tokens == 20, "extraction input usage")?;
2211 fixture_contract(report.generated_tokens == 5, "extraction output usage")?;
2212 Ok(())
2213 }
2214
2215 #[tokio::test]
2216 async fn streaming_contract_checks_events_history_and_usage() -> Result<(), ScenarioError> {
2217 let model = MockCompletionModel::from_stream_turns([
2218 vec![
2219 MockStreamEvent::tool_call(
2220 "add_call",
2221 "add",
2222 serde_json::json!({"a": 17, "b": 25}),
2223 ),
2224 MockStreamEvent::FinalResponse(MockResponse::with_usage(usage(10, 2))),
2225 ],
2226 vec![
2227 MockStreamEvent::text("42"),
2228 MockStreamEvent::FinalResponse(MockResponse::with_usage(usage(14, 1))),
2229 ],
2230 ]);
2231 let report = streaming_tool(model, |builder| builder).await?;
2232 fixture_contract(report.prompt_tokens == 24, "streaming input usage")?;
2233 fixture_contract(report.generated_tokens == 3, "streaming output usage")?;
2234 Ok(())
2235 }
2236
2237 #[tokio::test]
2238 async fn invalid_recovery_paths_do_not_execute_tools() -> Result<(), ScenarioError> {
2239 let report = invalid_tool_recovery(
2240 MockCompletionModel::new([MockTurn::tool_call(
2241 "invalid-add",
2242 "add",
2243 serde_json::json!({ "x": 2, "y": 3 }),
2244 )]),
2245 |builder| builder,
2246 )
2247 .await?;
2248 fixture_contract(report.tool_calls == 1, "recovery source call count")?;
2249 Ok(())
2250 }
2251
2252 #[tokio::test]
2253 async fn hook_rewrites_chain_and_request_patch_is_turn_local() -> Result<(), ScenarioError> {
2254 let report = hook_rewrites_and_request_patch(
2255 MockCompletionModel::new([
2256 MockTurn::tool_call("hook-add", "add", serde_json::json!({ "x": 1, "y": 1 })),
2257 MockTurn::text("[portable-redacted]"),
2258 ]),
2259 |builder| builder,
2260 )
2261 .await?;
2262 fixture_contract(report.tool_calls == 1, "hook execution count")?;
2263 Ok(())
2264 }
2265
2266 #[tokio::test]
2267 async fn cancellation_and_max_turn_controls_retain_diagnostics() -> Result<(), ScenarioError> {
2268 let report = cancellation_and_max_turns(
2269 MockCompletionModel::new([
2270 MockTurn::tool_call("cancel-add", "add", serde_json::json!({ "x": 20, "y": 22 })),
2271 MockTurn::tool_call("budget-add", "add", serde_json::json!({ "x": 20, "y": 22 })),
2272 ]),
2273 |builder| builder,
2274 )
2275 .await?;
2276 fixture_contract(report.tool_calls == 2, "run-control execution count")?;
2277 Ok(())
2278 }
2279
2280 #[test]
2281 fn typed_validators_reject_bad_structured_output_and_protocol_leaks() {
2282 let invalid = decode_structured_output::<ConfigOutput>("invalid_json", "not json");
2283 assert!(matches!(invalid, Err(ScenarioError::Contract { .. })));
2284
2285 let messages = vec![Message::Assistant {
2286 id: None,
2287 content: OneOrMany::one(AssistantContent::text("visible <tool_call>")),
2288 }];
2289 let hygiene = validate_protocol_hygiene(
2290 "protocol_hygiene",
2291 "visible <tool_call>",
2292 &messages,
2293 &["<tool_call>"],
2294 );
2295 assert!(matches!(hygiene, Err(ScenarioError::Contract { .. })));
2296 }
2297
2298 #[test]
2299 fn invalid_tool_diagnostics_require_rejected_call_history() {
2300 let history = vec![Message::Assistant {
2301 id: None,
2302 content: OneOrMany::one(tool_call(
2303 "bad_call",
2304 "missing",
2305 serde_json::json!({"value": 1}),
2306 )),
2307 }];
2308 let error = PromptError::UnknownToolCall {
2309 tool_name: "missing".to_string(),
2310 available_tools: vec!["add".to_string()],
2311 allowed_tools: Vec::new(),
2312 chat_history: Box::new(history),
2313 };
2314 assert!(validate_unknown_tool_failure(&error, "missing", &[]).is_ok());
2315 assert!(validate_unknown_tool_failure(&error, "other", &[]).is_err());
2316 }
2317}