1pub mod streaming;
2
3use super::{Agent, hook::AgentHook, run::OutputMode, runner::AgentRunner};
4use rig_core::{
5 OneOrMany,
6 message::{AssistantContent, ToolResultContent, UserContent},
7 wasm_compat::{WasmBoxedFuture, WasmCompatSend},
8};
9
10use crate::{
11 completion::{CompletionModel, Message, PromptError, Usage},
12 tool::{ToolContext, ToolOutput},
13};
14use serde::{Deserialize, Serialize};
15use std::{future::IntoFuture, marker::PhantomData};
16
17macro_rules! forward_prompt_setters {
26 ($recv:ident) => {
27 pub fn tool_context(mut self, context: ToolContext) -> Self {
34 self.$recv = self.$recv.tool_context(context);
35 self
36 }
37
38 pub fn history<H, Item>(mut self, history: H) -> Self
40 where
41 H: IntoIterator<Item = Item>,
42 Item: Into<Message>,
43 {
44 self.$recv = self.$recv.history(history);
45 self
46 }
47
48 pub fn preamble(mut self, preamble: impl Into<String>) -> Self {
50 self.$recv = self.$recv.preamble(preamble);
51 self
52 }
53
54 pub fn without_preamble(mut self) -> Self {
56 self.$recv = self.$recv.without_preamble();
57 self
58 }
59
60 pub fn document(mut self, document: crate::completion::Document) -> Self {
62 self.$recv = self.$recv.document(document);
63 self
64 }
65
66 pub fn documents(
68 mut self,
69 documents: impl IntoIterator<Item = crate::completion::Document>,
70 ) -> Self {
71 self.$recv = self.$recv.documents(documents);
72 self
73 }
74
75 pub fn temperature(mut self, temperature: f64) -> Self {
77 self.$recv = self.$recv.temperature(temperature);
78 self
79 }
80
81 pub fn without_temperature(mut self) -> Self {
83 self.$recv = self.$recv.without_temperature();
84 self
85 }
86
87 pub fn max_tokens(mut self, max_tokens: u64) -> Self {
89 self.$recv = self.$recv.max_tokens(max_tokens);
90 self
91 }
92
93 pub fn without_max_tokens(mut self) -> Self {
95 self.$recv = self.$recv.without_max_tokens();
96 self
97 }
98
99 pub fn merge_additional_params(
102 mut self,
103 params: serde_json::Map<String, serde_json::Value>,
104 ) -> Self {
105 self.$recv = self.$recv.merge_additional_params(params);
106 self
107 }
108
109 pub fn replace_additional_params(mut self, params: serde_json::Value) -> Self {
111 self.$recv = self.$recv.replace_additional_params(params);
112 self
113 }
114
115 pub fn without_additional_params(mut self) -> Self {
117 self.$recv = self.$recv.without_additional_params();
118 self
119 }
120
121 pub fn tool_choice(mut self, tool_choice: rig_core::message::ToolChoice) -> Self {
123 self.$recv = self.$recv.tool_choice(tool_choice);
124 self
125 }
126
127 pub fn without_tool_choice(mut self) -> Self {
129 self.$recv = self.$recv.without_tool_choice();
130 self
131 }
132
133 pub fn record_content_telemetry(mut self, enabled: bool) -> Self {
144 self.$recv = self.$recv.record_content_telemetry(enabled);
145 self
146 }
147
148 pub fn conversation(mut self, id: impl Into<String>) -> Self {
153 self.$recv = self.$recv.conversation(id);
154 self
155 }
156
157 pub fn without_memory(mut self) -> Self {
161 self.$recv = self.$recv.without_memory();
162 self
163 }
164
165 pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
169 self.$recv = self.$recv.max_invalid_tool_call_retries(retries);
170 self
171 }
172 };
173}
174pub(crate) use forward_prompt_setters;
175
176macro_rules! forward_tool_concurrency {
181 ($recv:ident) => {
182 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
189 self.$recv = self.$recv.tool_concurrency(concurrency);
190 self
191 }
192 };
193}
194
195pub trait PromptType {}
196pub struct Standard;
197pub struct Extended;
198
199impl PromptType for Standard {}
200impl PromptType for Extended {}
201
202pub struct PromptRequest<S, M>
210where
211 S: PromptType,
212 M: CompletionModel,
213{
214 pub(crate) runner: AgentRunner<M>,
216 state: PhantomData<S>,
218}
219
220impl<M> PromptRequest<Standard, M>
221where
222 M: CompletionModel,
223{
224 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
227 PromptRequest {
228 runner: AgentRunner::from_agent(agent, prompt),
229 state: PhantomData,
230 }
231 }
232}
233
234impl<S, M> PromptRequest<S, M>
235where
236 S: PromptType,
237 M: CompletionModel,
238{
239 pub fn extended_details(self) -> PromptRequest<Extended, M> {
246 PromptRequest {
247 runner: self.runner,
248 state: PhantomData,
249 }
250 }
251
252 pub fn max_turns(mut self, max_turns: usize) -> Self {
257 self.runner = self.runner.max_turns(max_turns);
258 self
259 }
260
261 pub fn add_hook<H>(mut self, hook: H) -> Self
268 where
269 H: AgentHook + 'static,
270 {
271 self.runner = self.runner.add_hook(hook);
272 self
273 }
274
275 forward_prompt_setters!(runner);
276 forward_tool_concurrency!(runner);
277}
278
279impl<M> IntoFuture for PromptRequest<Standard, M>
283where
284 M: CompletionModel + 'static,
285{
286 type Output = Result<String, PromptError>;
287 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
288
289 fn into_future(self) -> Self::IntoFuture {
290 Box::pin(self.send())
291 }
292}
293
294impl<M> IntoFuture for PromptRequest<Extended, M>
295where
296 M: CompletionModel + 'static,
297{
298 type Output = Result<PromptResponse, PromptError>;
299 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
300
301 fn into_future(self) -> Self::IntoFuture {
302 Box::pin(self.send())
303 }
304}
305
306impl<M> PromptRequest<Standard, M>
307where
308 M: CompletionModel,
309{
310 async fn send(self) -> Result<String, PromptError> {
311 self.extended_details().send().await.map(|resp| resp.output)
312 }
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317#[non_exhaustive]
318pub struct CompletionCall {
319 pub call_index: usize,
321 #[serde(default, deserialize_with = "usage_null_as_default")]
327 pub usage: Usage,
328}
329
330impl CompletionCall {
331 pub fn new(call_index: usize, usage: Usage) -> Self {
333 Self { call_index, usage }
334 }
335}
336
337fn usage_null_as_default<'de, D>(deserializer: D) -> Result<Usage, D::Error>
344where
345 D: serde::Deserializer<'de>,
346{
347 Ok(Option::<Usage>::deserialize(deserializer)?.unwrap_or_default())
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
360#[serde(from = "PromptResponseRepr", into = "PromptResponseRepr")]
367#[non_exhaustive]
368pub struct PromptResponse {
369 pub output: String,
371 pub usage: Usage,
373 pub completion_calls: Vec<CompletionCall>,
380 pub messages: Option<Vec<Message>>,
383 pub content: OneOrMany<AssistantContent>,
388 output_tool_calls: usize,
392}
393
394#[derive(Serialize, Deserialize)]
402struct PromptResponseRepr {
403 output: String,
404 usage: Usage,
405 #[serde(default, skip_serializing_if = "Vec::is_empty")]
406 completion_calls: Vec<CompletionCall>,
407 messages: Option<Vec<Message>>,
408 #[serde(default)]
409 content: Option<OneOrMany<AssistantContent>>,
410 #[serde(skip)]
411 output_tool_calls: usize,
412}
413
414impl From<PromptResponseRepr> for PromptResponse {
415 fn from(repr: PromptResponseRepr) -> Self {
416 let content = repr
417 .content
418 .unwrap_or_else(|| OneOrMany::one(AssistantContent::text(repr.output.clone())));
419 Self {
420 output: repr.output,
421 usage: repr.usage,
422 completion_calls: repr.completion_calls,
423 messages: repr.messages,
424 content,
425 output_tool_calls: repr.output_tool_calls,
426 }
427 }
428}
429
430impl From<PromptResponse> for PromptResponseRepr {
431 fn from(response: PromptResponse) -> Self {
432 Self {
433 output: response.output,
434 usage: response.usage,
435 completion_calls: response.completion_calls,
436 messages: response.messages,
437 content: Some(response.content),
438 output_tool_calls: response.output_tool_calls,
439 }
440 }
441}
442
443impl std::fmt::Display for PromptResponse {
444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
445 self.output.fmt(f)
446 }
447}
448
449impl PromptResponse {
450 pub fn new(output: impl Into<String>, usage: Usage) -> Self {
451 let output = output.into();
452 Self {
453 content: OneOrMany::one(AssistantContent::text(output.clone())),
454 output,
455 usage,
456 completion_calls: Vec::new(),
457 messages: None,
458 output_tool_calls: 0,
459 }
460 }
461
462 pub fn empty() -> Self {
464 Self::new(String::new(), Usage::new())
465 }
466
467 pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
468 self.messages = Some(messages);
469 self
470 }
471
472 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
474 self.completion_calls = completion_calls;
475 self
476 }
477
478 pub fn with_content(mut self, content: OneOrMany<AssistantContent>) -> Self {
480 self.content = content;
481 self
482 }
483
484 pub(crate) fn with_output_tool_calls(mut self, count: usize) -> Self {
485 self.output_tool_calls = count;
486 self
487 }
488
489 pub(crate) fn output_tool_calls(&self) -> usize {
490 self.output_tool_calls
491 }
492
493 pub fn output(&self) -> &str {
495 &self.output
496 }
497
498 pub fn usage(&self) -> Usage {
500 self.usage
501 }
502
503 pub fn messages(&self) -> Option<&[Message]> {
505 self.messages.as_deref()
506 }
507
508 pub fn content(&self) -> &OneOrMany<AssistantContent> {
510 &self.content
511 }
512
513 pub fn completion_calls(&self) -> &[CompletionCall] {
518 &self.completion_calls
519 }
520
521 pub fn requests(&self) -> usize {
523 self.completion_calls.len()
524 }
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[non_exhaustive]
529pub struct TypedPromptResponse<T> {
530 pub output: T,
531 pub usage: Usage,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
539 pub completion_calls: Vec<CompletionCall>,
540}
541
542impl<T> TypedPromptResponse<T> {
543 pub fn new(output: T, usage: Usage) -> Self {
544 Self {
545 output,
546 usage,
547 completion_calls: Vec::new(),
548 }
549 }
550
551 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
553 self.completion_calls = completion_calls;
554 self
555 }
556
557 pub fn completion_calls(&self) -> &[CompletionCall] {
562 &self.completion_calls
563 }
564
565 pub fn requests(&self) -> usize {
567 self.completion_calls.len()
568 }
569}
570
571pub(crate) const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
572 "Tool not executed because another tool call in the same assistant turn was invalid.";
573
574pub(crate) fn build_history_for_request(
576 chat_history: Option<&[Message]>,
577 new_messages: &[Message],
578) -> Vec<Message> {
579 let input = chat_history.unwrap_or(&[]);
580 input.iter().chain(new_messages.iter()).cloned().collect()
581}
582
583pub(crate) fn build_full_history(
585 chat_history: Option<&[Message]>,
586 new_messages: Vec<Message>,
587) -> Vec<Message> {
588 let input = chat_history.unwrap_or(&[]);
589 input.iter().cloned().chain(new_messages).collect()
590}
591
592fn tool_result_with(
595 id: String,
596 call_id: Option<String>,
597 content: OneOrMany<ToolResultContent>,
598) -> UserContent {
599 match call_id {
600 Some(call_id) => UserContent::tool_result_with_call_id(id, call_id, content),
601 None => UserContent::tool_result(id, content),
602 }
603}
604
605pub(crate) fn tool_result_output(
607 id: String,
608 call_id: Option<String>,
609 output: ToolOutput,
610) -> UserContent {
611 tool_result_with(id, call_id, output.into_content())
612}
613
614pub(crate) fn tool_result_message(
620 id: String,
621 call_id: Option<String>,
622 message: String,
623) -> UserContent {
624 tool_result_with(
625 id,
626 call_id,
627 OneOrMany::one(ToolResultContent::text(message)),
628 )
629}
630
631pub(crate) fn invalid_tool_retry_user_message(
632 assistant_content: &OneOrMany<AssistantContent>,
633 invalid_tool_call_id: &str,
634 feedback: String,
635) -> Option<Message> {
636 let retry_results = assistant_content
637 .iter()
638 .filter_map(|content| match content {
639 AssistantContent::ToolCall(tool_call) if tool_call.id == invalid_tool_call_id => {
640 Some(tool_result_message(
641 tool_call.id.clone(),
642 tool_call.call_id.clone(),
643 feedback.clone(),
644 ))
645 }
646 AssistantContent::ToolCall(tool_call) => Some(tool_result_message(
647 tool_call.id.clone(),
648 tool_call.call_id.clone(),
649 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
650 )),
651 _ => None,
652 })
653 .collect::<Vec<_>>();
654
655 Some(Message::User {
656 content: OneOrMany::from_iter_optional(retry_results)?,
657 })
658}
659
660pub(crate) fn is_empty_assistant_turn(choice: &OneOrMany<AssistantContent>) -> bool {
661 choice.len() == 1
662 && matches!(
663 choice.first(),
664 AssistantContent::Text(text) if text.text.is_empty() && text.additional_params.is_none()
665 )
666}
667
668pub(crate) fn assistant_text_from_choice(choice: &OneOrMany<AssistantContent>) -> String {
669 choice
670 .iter()
671 .filter_map(|content| match content {
672 AssistantContent::Text(text) => Some(text.text.as_str()),
673 _ => None,
674 })
675 .collect()
676}
677
678impl<M> PromptRequest<Extended, M>
679where
680 M: CompletionModel,
681{
682 async fn send(self) -> Result<PromptResponse, PromptError> {
683 self.runner.run().await
684 }
685}
686
687use crate::completion::StructuredOutputError;
692use schemars::{JsonSchema, schema_for};
693use serde::de::DeserializeOwned;
694
695pub struct TypedPromptRequest<T, S, M>
712where
713 T: JsonSchema + DeserializeOwned + WasmCompatSend,
714 S: PromptType,
715 M: CompletionModel,
716{
717 inner: PromptRequest<S, M>,
718 _phantom: std::marker::PhantomData<T>,
719}
720
721impl<T, M> TypedPromptRequest<T, Standard, M>
722where
723 T: JsonSchema + DeserializeOwned + WasmCompatSend,
724 M: CompletionModel,
725{
726 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
730 let mut inner = PromptRequest::from_agent(agent, prompt);
731 inner.runner.output_schema = Some(schema_for!(T));
733 inner.runner.output_mode = OutputMode::Native;
740 Self {
741 inner,
742 _phantom: std::marker::PhantomData,
743 }
744 }
745}
746
747impl<T, S, M> TypedPromptRequest<T, S, M>
748where
749 T: JsonSchema + DeserializeOwned + WasmCompatSend,
750 S: PromptType,
751 M: CompletionModel,
752{
753 pub fn extended_details(self) -> TypedPromptRequest<T, Extended, M> {
759 TypedPromptRequest {
760 inner: self.inner.extended_details(),
761 _phantom: std::marker::PhantomData,
762 }
763 }
764
765 pub fn max_turns(mut self, max_turns: usize) -> Self {
770 self.inner = self.inner.max_turns(max_turns);
771 self
772 }
773
774 pub fn add_hook<H>(mut self, hook: H) -> Self
777 where
778 H: AgentHook + 'static,
779 {
780 self.inner = self.inner.add_hook(hook);
781 self
782 }
783
784 forward_prompt_setters!(inner);
785 forward_tool_concurrency!(inner);
786}
787
788fn deserialize_structured_output<T: DeserializeOwned>(text: &str) -> Result<T, serde_json::Error> {
795 let trimmed = text.trim();
796 match serde_json::from_str::<T>(trimmed) {
797 Ok(value) => Ok(value),
798 Err(direct_err) => {
799 let Some(start) = trimmed.find(['{', '[']) else {
800 return Err(direct_err);
801 };
802 serde_json::Deserializer::from_str(&trimmed[start..])
803 .into_iter::<T>()
804 .next()
805 .unwrap_or(Err(direct_err))
806 }
807 }
808}
809
810impl<T, M> TypedPromptRequest<T, Standard, M>
811where
812 T: JsonSchema + DeserializeOwned + WasmCompatSend,
813 M: CompletionModel,
814{
815 async fn send(self) -> Result<T, StructuredOutputError> {
817 let response = self.inner.send().await.map_err(Box::new)?;
818
819 if response.is_empty() {
820 return Err(StructuredOutputError::EmptyResponse);
821 }
822
823 let parsed: T = deserialize_structured_output(&response)?;
824 Ok(parsed)
825 }
826}
827
828impl<T, M> TypedPromptRequest<T, Extended, M>
829where
830 T: JsonSchema + DeserializeOwned + WasmCompatSend,
831 M: CompletionModel,
832{
833 async fn send(self) -> Result<TypedPromptResponse<T>, StructuredOutputError> {
835 let response = self.inner.send().await.map_err(Box::new)?;
836
837 if response.output.is_empty() {
838 return Err(StructuredOutputError::EmptyResponse);
839 }
840
841 let parsed: T = deserialize_structured_output(&response.output)?;
842 Ok(TypedPromptResponse::new(parsed, response.usage)
843 .with_completion_calls(response.completion_calls))
844 }
845}
846
847impl<T, M> IntoFuture for TypedPromptRequest<T, Standard, M>
848where
849 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
850 M: CompletionModel + 'static,
851{
852 type Output = Result<T, StructuredOutputError>;
853 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
854
855 fn into_future(self) -> Self::IntoFuture {
856 Box::pin(self.send())
857 }
858}
859
860impl<T, M> IntoFuture for TypedPromptRequest<T, Extended, M>
861where
862 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
863 M: CompletionModel + 'static,
864{
865 type Output = Result<TypedPromptResponse<T>, StructuredOutputError>;
866 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
867
868 fn into_future(self) -> Self::IntoFuture {
869 Box::pin(self.send())
870 }
871}
872#[cfg(test)]
873mod tests {
874 use super::{CompletionCall, PromptResponse, PromptResponseRepr, TypedPromptResponse};
875 use crate::{
876 agent::{
877 AgentBuilder,
878 hook::{
879 AgentHook, CompletionResponse as CompletionResponseEvent, HookContext,
880 InvalidToolCallAction, InvalidToolCallContext, ObservationAction,
881 ToolCall as ToolCallEvent, ToolCallAction,
882 },
883 },
884 completion::{
885 AssistantContent, CompletionError, CompletionRequest, Message, Prompt, PromptError,
886 StructuredOutputError, TypedPrompt, Usage,
887 },
888 test_utils::{
889 AppendFailingMemory, CountingMemory, FailingMemory, MockAddTool, MockCompletionModel,
890 MockContextProbeTool, MockOperationArgs, MockSubtractTool, MockToolError, MockTurn,
891 SessionId,
892 },
893 tool::{Tool, ToolContext},
894 };
895 use rig_core::message::{Text, ToolCall, ToolChoice, ToolFunction, UserContent};
896 use schemars::JsonSchema;
897 use serde::{Deserialize, Serialize};
898 use serde_json::json;
899 use std::sync::{
900 Arc, Mutex,
901 atomic::{AtomicU32, Ordering},
902 };
903
904 #[derive(Serialize)]
905 struct SerializeOnly {
906 value: &'static str,
907 }
908
909 #[derive(Deserialize)]
910 struct DeserializeOnly {
911 value: String,
912 }
913
914 #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
915 struct TypedAnswer {
916 value: String,
917 }
918
919 #[test]
920 fn deserialize_structured_output_tolerates_fences_and_prose() {
921 assert_eq!(
923 super::deserialize_structured_output::<TypedAnswer>(r#"{"value":"x"}"#).unwrap(),
924 TypedAnswer { value: "x".into() }
925 );
926 assert_eq!(
928 super::deserialize_structured_output::<TypedAnswer>("```json\n{\"value\":\"y\"}\n```")
929 .unwrap(),
930 TypedAnswer { value: "y".into() }
931 );
932 assert_eq!(
934 super::deserialize_structured_output::<TypedAnswer>(
935 "Here you go: {\"value\":\"z\"} — hope that helps!"
936 )
937 .unwrap(),
938 TypedAnswer { value: "z".into() }
939 );
940 assert!(super::deserialize_structured_output::<TypedAnswer>("no json here").is_err());
942 }
943
944 #[derive(Clone)]
945 struct PanicOnUnknownToolHook;
946
947 impl AgentHook for PanicOnUnknownToolHook {
948 async fn on_completion_response(
949 &self,
950 _ctx: &HookContext,
951 _event: CompletionResponseEvent<'_>,
952 ) -> ObservationAction {
953 panic!("unknown tool response should fail before response hooks run")
954 }
955 async fn on_tool_call(
956 &self,
957 _ctx: &HookContext,
958 _event: ToolCallEvent<'_>,
959 ) -> ToolCallAction {
960 panic!("unknown tool call should fail before tool hooks run")
961 }
962 }
963
964 #[derive(Clone)]
965 struct PanicOnToolCallHook;
966
967 impl AgentHook for PanicOnToolCallHook {
968 async fn on_tool_call(
969 &self,
970 _ctx: &HookContext,
971 _event: ToolCallEvent<'_>,
972 ) -> ToolCallAction {
973 panic!("recovered invalid turn should not invoke normal tool hooks")
974 }
975 }
976
977 #[derive(Clone)]
978 struct SkipDefaultApiAndPanicOnToolCallHook;
979
980 impl AgentHook for SkipDefaultApiAndPanicOnToolCallHook {
981 async fn on_invalid_tool_call(
982 &self,
983 ctx: &HookContext,
984 event: &InvalidToolCallContext,
985 ) -> Option<InvalidToolCallAction> {
986 SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
987 }
988 async fn on_tool_call(
989 &self,
990 ctx: &HookContext,
991 event: ToolCallEvent<'_>,
992 ) -> ToolCallAction {
993 PanicOnToolCallHook.on_tool_call(ctx, event).await
994 }
995 }
996
997 #[derive(Clone)]
998 struct RepairDefaultApiHook;
999
1000 impl AgentHook for RepairDefaultApiHook {
1001 async fn on_invalid_tool_call(
1002 &self,
1003 _ctx: &HookContext,
1004 event: &InvalidToolCallContext,
1005 ) -> Option<InvalidToolCallAction> {
1006 assert_eq!(event.tool_name, "default_api");
1007 Some(InvalidToolCallAction::repair("add"))
1008 }
1009 }
1010
1011 #[derive(Clone)]
1012 struct RepairToSubtractHook;
1013
1014 impl AgentHook for RepairToSubtractHook {
1015 async fn on_invalid_tool_call(
1016 &self,
1017 _ctx: &HookContext,
1018 _event: &InvalidToolCallContext,
1019 ) -> Option<InvalidToolCallAction> {
1020 Some(InvalidToolCallAction::repair("subtract"))
1021 }
1022 }
1023
1024 #[derive(Clone)]
1025 struct RetryDefaultApiHook;
1026
1027 impl AgentHook for RetryDefaultApiHook {
1028 async fn on_invalid_tool_call(
1029 &self,
1030 _ctx: &HookContext,
1031 event: &InvalidToolCallContext,
1032 ) -> Option<InvalidToolCallAction> {
1033 Some(InvalidToolCallAction::retry(format!(
1034 "Use one of these tools instead: {:?}",
1035 event.allowed_tools
1036 )))
1037 }
1038 }
1039
1040 #[derive(Clone)]
1041 struct SkipDefaultApiHook;
1042
1043 impl AgentHook for SkipDefaultApiHook {
1044 async fn on_invalid_tool_call(
1045 &self,
1046 _ctx: &HookContext,
1047 _event: &InvalidToolCallContext,
1048 ) -> Option<InvalidToolCallAction> {
1049 Some(InvalidToolCallAction::skip("default_api is not available"))
1050 }
1051 }
1052
1053 #[derive(Clone, Default)]
1054 struct RecordingInvalidToolCallHook {
1055 contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
1056 }
1057
1058 impl RecordingInvalidToolCallHook {
1059 fn observed(&self) -> Vec<InvalidToolCallContext> {
1060 self.contexts
1061 .lock()
1062 .expect("invalid tool context records mutex was poisoned")
1063 .clone()
1064 }
1065 }
1066
1067 impl AgentHook for RecordingInvalidToolCallHook {
1068 async fn on_invalid_tool_call(
1069 &self,
1070 _ctx: &HookContext,
1071 event: &InvalidToolCallContext,
1072 ) -> Option<InvalidToolCallAction> {
1073 self.contexts
1074 .lock()
1075 .expect("invalid tool context records mutex was poisoned")
1076 .push(event.clone());
1077 None
1078 }
1079 }
1080
1081 #[derive(Clone)]
1082 struct CountingAddTool {
1083 calls: Arc<AtomicU32>,
1084 }
1085
1086 impl Tool for CountingAddTool {
1087 const NAME: &'static str = "add";
1088 type Error = MockToolError;
1089 type Args = MockOperationArgs;
1090 type Output = i32;
1091
1092 fn description(&self) -> String {
1093 MockAddTool.description()
1094 }
1095
1096 fn parameters(&self) -> serde_json::Value {
1097 MockAddTool.parameters()
1098 }
1099
1100 async fn call(
1101 &self,
1102 _context: &mut crate::tool::ToolContext,
1103 _args: Self::Args,
1104 ) -> Result<Self::Output, Self::Error> {
1105 self.calls.fetch_add(1, Ordering::SeqCst);
1106 Ok(0)
1107 }
1108 }
1109
1110 fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
1111 Usage {
1112 input_tokens,
1113 output_tokens,
1114 total_tokens: input_tokens + output_tokens,
1115 cached_input_tokens: 0,
1116 cache_creation_input_tokens: 0,
1117 tool_use_prompt_tokens: 0,
1118 reasoning_tokens: 0,
1119 }
1120 }
1121
1122 #[test]
1123 fn typed_prompt_response_serializes_with_serialize_only_output() {
1124 let response = TypedPromptResponse::new(
1125 SerializeOnly { value: "ok" },
1126 Usage {
1127 input_tokens: 1,
1128 output_tokens: 2,
1129 total_tokens: 3,
1130 cached_input_tokens: 0,
1131 cache_creation_input_tokens: 0,
1132 tool_use_prompt_tokens: 0,
1133 reasoning_tokens: 0,
1134 },
1135 );
1136
1137 let json = serde_json::to_string(&response).expect("serialize typed prompt response");
1138 assert!(json.contains("\"value\":\"ok\""));
1139 }
1140
1141 #[test]
1142 fn typed_prompt_response_deserializes_with_deserialize_only_output() {
1143 let response: TypedPromptResponse<DeserializeOnly> = serde_json::from_str(
1144 r#"{"output":{"value":"ok"},"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3,"cached_input_tokens":0,"cache_creation_input_tokens":0,"reasoning_tokens":0}}"#,
1145 )
1146 .expect("deserialize typed prompt response");
1147
1148 assert_eq!(response.requests(), 0);
1149 assert_eq!(response.output.value, "ok");
1150 assert_eq!(response.usage.input_tokens, 1);
1151 assert_eq!(response.usage.output_tokens, 2);
1152 assert_eq!(response.usage.total_tokens, 3);
1153 }
1154
1155 #[test]
1156 fn prompt_response_serializes_completion_calls_with_missing_usage() {
1157 let reported_usage = usage(3, 4);
1158 let response = PromptResponse::new("ok", reported_usage).with_completion_calls(vec![
1159 CompletionCall::new(0, Usage::new()),
1160 CompletionCall::new(1, reported_usage),
1161 ]);
1162
1163 let value = serde_json::to_value(&response).expect("serialize prompt response");
1164
1165 assert_eq!(
1169 value.get("completion_calls"),
1170 Some(&json!([
1171 {
1172 "call_index": 0,
1173 "usage": {
1174 "input_tokens": 0,
1175 "output_tokens": 0,
1176 "total_tokens": 0,
1177 "cached_input_tokens": 0,
1178 "cache_creation_input_tokens": 0,
1179 "tool_use_prompt_tokens": 0,
1180 "reasoning_tokens": 0,
1181 }
1182 },
1183 {
1184 "call_index": 1,
1185 "usage": {
1186 "input_tokens": 3,
1187 "output_tokens": 4,
1188 "total_tokens": 7,
1189 "cached_input_tokens": 0,
1190 "cache_creation_input_tokens": 0,
1191 "tool_use_prompt_tokens": 0,
1192 "reasoning_tokens": 0,
1193 }
1194 }
1195 ]))
1196 );
1197
1198 let response: PromptResponse =
1199 serde_json::from_value(value).expect("deserialize prompt response");
1200 assert_eq!(
1201 response.completion_calls(),
1202 &[
1203 CompletionCall::new(0, Usage::new()),
1204 CompletionCall::new(1, reported_usage)
1205 ]
1206 );
1207 assert_eq!(response.requests(), 2);
1208 }
1209
1210 #[test]
1211 fn prompt_response_output_tool_marker_is_never_serialized() {
1212 let response = PromptResponse::new("ok", usage(1, 2)).with_output_tool_calls(3);
1213
1214 let value = serde_json::to_value(&response).expect("serialize prompt response");
1215 assert!(value.get("output_tool_calls").is_none());
1216
1217 let decoded: PromptResponse =
1218 serde_json::from_value(value).expect("deserialize prompt response");
1219 assert_eq!(decoded.output_tool_calls(), 0);
1220 }
1221
1222 #[test]
1223 fn prompt_response_deserializes_pre_monoid_null_usage_format() {
1224 let fixture = r#"{"output":"ok","usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0},"completion_calls":[{"call_index":0,"usage":null},{"call_index":1,"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0}}],"messages":[{"role":"user","content":[{"type":"text","text":"add things"}]}]}"#;
1227
1228 let response: PromptResponse =
1229 serde_json::from_str(fixture).expect("old-format response should deserialize");
1230 assert_eq!(
1231 response.completion_calls(),
1232 &[
1233 CompletionCall::new(0, Usage::new()),
1234 CompletionCall::new(1, usage(3, 4))
1235 ]
1236 );
1237 }
1238
1239 #[test]
1240 fn prompt_response_missing_content_reconstructs_from_output() {
1241 let mut value = serde_json::to_value(PromptResponse::new("hello", Usage::new()))
1245 .expect("serialize prompt response");
1246 value
1247 .as_object_mut()
1248 .expect("prompt response serializes to a JSON object")
1249 .remove("content");
1250 assert!(
1251 value.get("content").is_none(),
1252 "fixture must omit the content field to model legacy data"
1253 );
1254
1255 let response: PromptResponse = serde_json::from_value(value)
1256 .expect("legacy response without content should deserialize");
1257
1258 assert_eq!(response.output(), "hello");
1259 assert_eq!(response.content().iter().count(), 1);
1260 assert_eq!(response.content().first(), AssistantContent::text("hello"));
1261 }
1262
1263 #[test]
1264 fn prompt_response_missing_content_empty_output_stays_empty_text() {
1265 let mut value =
1266 serde_json::to_value(PromptResponse::empty()).expect("serialize prompt response");
1267 value
1268 .as_object_mut()
1269 .expect("prompt response serializes to a JSON object")
1270 .remove("content");
1271
1272 let response: PromptResponse = serde_json::from_value(value)
1273 .expect("legacy empty response without content should deserialize");
1274
1275 assert_eq!(response.output(), "");
1276 assert_eq!(response.content().first(), AssistantContent::text(""));
1277 }
1278
1279 #[test]
1280 fn prompt_response_roundtrip_preserves_explicit_content() {
1281 let response = PromptResponse::new("visible text", Usage::new()).with_content(
1285 rig_core::OneOrMany::one(AssistantContent::text("structured")),
1286 );
1287
1288 let value = serde_json::to_value(&response).expect("serialize prompt response");
1289 assert!(
1290 value.get("content").is_some(),
1291 "content is part of the serialized shape"
1292 );
1293
1294 let round: PromptResponse =
1295 serde_json::from_value(value).expect("deserialize prompt response");
1296 assert_eq!(round.output(), "visible text");
1297 let AssistantContent::Text(text) = round.content().first() else {
1302 panic!("expected text content, got {:?}", round.content().first());
1303 };
1304 assert_eq!(text.text, "structured");
1305 }
1306
1307 #[test]
1308 fn prompt_response_serialize_and_deserialize_agree_on_wire_shape() {
1309 let response = PromptResponse::new("hi", usage(1, 2))
1318 .with_completion_calls(vec![CompletionCall::new(0, usage(1, 2))]);
1319
1320 let from_response = serde_json::to_value(&response).expect("serialize response");
1321 let from_shadow = serde_json::to_value(PromptResponseRepr::from(response.clone()))
1322 .expect("serialize shadow");
1323 assert_eq!(
1324 from_response, from_shadow,
1325 "serialize must route through the same shadow as deserialize"
1326 );
1327
1328 let round: PromptResponse =
1330 serde_json::from_value(from_response).expect("deserialize response");
1331 assert_eq!(round.output(), "hi");
1332 assert_eq!(round.usage(), usage(1, 2));
1333 assert_eq!(
1334 round.completion_calls(),
1335 &[CompletionCall::new(0, usage(1, 2))]
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn prompt_response_records_completion_call_without_reported_usage() {
1341 let model = MockCompletionModel::new([MockTurn::text("ok")]);
1342 let agent = AgentBuilder::new(model).build();
1343
1344 let response = agent
1345 .prompt("say ok")
1346 .extended_details()
1347 .await
1348 .expect("prompt should succeed");
1349
1350 assert_eq!(response.output, "ok");
1351 assert_eq!(response.usage, Usage::new());
1352 assert_eq!(
1353 response.completion_calls(),
1354 &[CompletionCall::new(0, Usage::new())]
1355 );
1356 }
1357
1358 #[tokio::test]
1359 async fn typed_prompt_response_preserves_completion_calls() {
1360 let call_usage = Usage {
1361 input_tokens: 4,
1362 output_tokens: 6,
1363 total_tokens: 10,
1364 cached_input_tokens: 0,
1365 cache_creation_input_tokens: 0,
1366 tool_use_prompt_tokens: 0,
1367 reasoning_tokens: 0,
1368 };
1369 let model =
1370 MockCompletionModel::new([MockTurn::text(r#"{"value":"ok"}"#).with_usage(call_usage)]);
1371 let agent = AgentBuilder::new(model).build();
1372
1373 let response = agent
1374 .prompt_typed::<TypedAnswer>("return typed json")
1375 .extended_details()
1376 .await
1377 .expect("typed prompt should succeed");
1378
1379 assert_eq!(
1380 response.output,
1381 TypedAnswer {
1382 value: "ok".to_string()
1383 }
1384 );
1385 assert_eq!(response.usage, call_usage);
1386 assert_eq!(
1387 response.completion_calls(),
1388 &[CompletionCall::new(0, call_usage)]
1389 );
1390 }
1391
1392 fn validate_follow_up_tool_history(request: &CompletionRequest) {
1393 let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
1394 assert_eq!(
1395 history.len(),
1396 3,
1397 "follow-up request should contain the prompt, assistant tool call, and user tool result: {history:?}"
1398 );
1399
1400 assert!(matches!(
1401 history.first(),
1402 Some(Message::User { content })
1403 if matches!(
1404 content.first(),
1405 UserContent::Text(text) if text.text == "do tool work"
1406 )
1407 ));
1408
1409 assert!(matches!(
1410 history.get(1),
1411 Some(Message::Assistant { content, .. })
1412 if matches!(
1413 content.first(),
1414 AssistantContent::ToolCall(tool_call)
1415 if tool_call.id == "tool_call_1"
1416 && tool_call.call_id.as_deref() == Some("call_1")
1417 )
1418 ));
1419
1420 assert!(matches!(
1421 history.get(2),
1422 Some(Message::User { content })
1423 if matches!(
1424 content.first(),
1425 UserContent::ToolResult(tool_result)
1426 if tool_result.id == "tool_call_1"
1427 && tool_result.call_id.as_deref() == Some("call_1")
1428 )
1429 ));
1430 }
1431
1432 fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1433 history.iter().any(|message| {
1434 matches!(
1435 message,
1436 Message::Assistant { content, .. }
1437 if content.iter().any(|item| matches!(
1438 item,
1439 AssistantContent::ToolCall(tool_call)
1440 if tool_call.function.name == tool_name
1441 ))
1442 )
1443 })
1444 }
1445
1446 #[tokio::test]
1447 async fn unknown_tool_call_fails_before_non_streaming_second_request() {
1448 let model = MockCompletionModel::new([
1449 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2})),
1450 MockTurn::text("should not be requested"),
1451 ]);
1452 let recorded = model.clone();
1453 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1454
1455 let err = agent
1456 .prompt("use the tool")
1457 .add_hook(PanicOnUnknownToolHook)
1458 .max_turns(3)
1459 .await
1460 .expect_err("unknown model-emitted tool should fail");
1461
1462 match err {
1463 PromptError::UnknownToolCall {
1464 tool_name,
1465 available_tools,
1466 allowed_tools,
1467 chat_history,
1468 } => {
1469 assert_eq!(tool_name, "default_api");
1470 assert_eq!(available_tools, vec!["add".to_string()]);
1471 assert_eq!(allowed_tools, vec!["add".to_string()]);
1472 assert!(history_contains_tool_call(&chat_history, "default_api"));
1473 }
1474 other => panic!("expected UnknownToolCall, got {other:?}"),
1475 }
1476 assert_eq!(recorded.request_count(), 1);
1477 }
1478
1479 #[tokio::test]
1482 async fn tool_context_reaches_tool_through_agent_loop() {
1483 let model = MockCompletionModel::new([
1484 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1485 MockTurn::text("done"),
1486 ]);
1487 let probe = MockContextProbeTool::default();
1488 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1489
1490 let mut context = ToolContext::new();
1491 context.insert(SessionId("abc-123".to_string()));
1492
1493 let out = agent
1494 .prompt("use the tool")
1495 .tool_context(context)
1496 .max_turns(3)
1497 .await
1498 .expect("run succeeds");
1499
1500 assert_eq!(out, "done");
1501 assert_eq!(probe.observed().as_deref(), Some("session:abc-123"));
1502 }
1503
1504 #[tokio::test]
1508 async fn tool_context_persists_across_multiple_rounds() {
1509 let model = MockCompletionModel::new([
1510 MockTurn::tool_call("c1", "context_probe", json!({})),
1511 MockTurn::tool_call("c2", "context_probe", json!({})),
1512 MockTurn::text("done"),
1513 ]);
1514 let probe = MockContextProbeTool::default();
1515 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1516
1517 let mut context = ToolContext::new();
1518 context.insert(SessionId("abc-123".to_string()));
1519
1520 let out = agent
1521 .prompt("use the tool twice")
1522 .tool_context(context)
1523 .max_turns(5)
1524 .await
1525 .expect("run succeeds");
1526
1527 assert_eq!(out, "done");
1528 assert_eq!(
1529 probe.observations(),
1530 vec!["session:abc-123".to_string(), "session:abc-123".to_string()],
1531 );
1532 }
1533
1534 #[tokio::test]
1537 async fn tool_runs_with_empty_context_when_none_supplied() {
1538 let model = MockCompletionModel::new([
1539 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1540 MockTurn::text("done"),
1541 ]);
1542 let probe = MockContextProbeTool::default();
1543 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1544
1545 let out = agent
1546 .prompt("use the tool")
1547 .max_turns(3)
1548 .await
1549 .expect("run succeeds");
1550
1551 assert_eq!(out, "done");
1552 assert_eq!(probe.observed().as_deref(), Some("no-session"));
1554 }
1555
1556 #[tokio::test]
1558 async fn probe_direct_call_uses_context() {
1559 let probe = MockContextProbeTool::default();
1560 let out = probe
1561 .call(&mut ToolContext::new(), json!({}))
1562 .await
1563 .expect("call succeeds");
1564 assert_eq!(out, "no-session");
1565 assert_eq!(probe.observed().as_deref(), Some("no-session"));
1566 }
1567
1568 #[tokio::test]
1569 async fn invalid_tool_call_context_uses_completed_tool_call_provider_id() {
1570 let invalid_hook = RecordingInvalidToolCallHook::default();
1571 let model = MockCompletionModel::new([
1572 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2}))
1573 .with_call_id("provider_call_1"),
1574 MockTurn::text("should not be requested"),
1575 ]);
1576 let recorded = model.clone();
1577 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1578
1579 let err = agent
1580 .prompt("use the tool")
1581 .add_hook(invalid_hook.clone())
1582 .max_turns(3)
1583 .await
1584 .expect_err("invalid tool should fail");
1585
1586 assert!(matches!(err, PromptError::UnknownToolCall { .. }));
1587 assert_eq!(recorded.request_count(), 1);
1588 let contexts = invalid_hook.observed();
1589 assert_eq!(contexts.len(), 1);
1590 let context = &contexts[0];
1591 assert_eq!(context.tool_name, "default_api");
1592 assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
1593 assert_eq!(context.internal_call_id, None);
1594 assert!(!context.is_streaming);
1595 }
1596
1597 #[tokio::test]
1598 async fn disallowed_specific_tool_call_fails_before_non_streaming_second_request() {
1599 let model = MockCompletionModel::new([
1600 MockTurn::tool_call("tool_call_1", "subtract", json!({"x": 3, "y": 1})),
1601 MockTurn::text("should not be requested"),
1602 ]);
1603 let recorded = model.clone();
1604 let agent = AgentBuilder::new(model)
1605 .tool(MockAddTool)
1606 .tool(MockSubtractTool)
1607 .tool_choice(ToolChoice::Specific {
1608 function_names: vec!["add".to_string()],
1609 })
1610 .build();
1611
1612 let err = agent
1613 .prompt("use the allowed tool")
1614 .add_hook(PanicOnUnknownToolHook)
1615 .max_turns(3)
1616 .await
1617 .expect_err("disallowed model-emitted tool should fail");
1618
1619 match err {
1620 PromptError::UnknownToolCall {
1621 tool_name,
1622 available_tools,
1623 allowed_tools,
1624 chat_history,
1625 } => {
1626 assert_eq!(tool_name, "subtract");
1627 assert_eq!(
1628 available_tools,
1629 vec!["add".to_string(), "subtract".to_string()]
1630 );
1631 assert_eq!(allowed_tools, vec!["add".to_string()]);
1632 assert!(history_contains_tool_call(&chat_history, "subtract"));
1633 }
1634 other => panic!("expected UnknownToolCall, got {other:?}"),
1635 }
1636 assert_eq!(recorded.request_count(), 1);
1637 }
1638
1639 #[tokio::test]
1640 async fn tool_choice_none_rejects_non_streaming_tool_call() {
1641 let model = MockCompletionModel::new([
1642 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
1643 MockTurn::text("should not be requested"),
1644 ]);
1645 let recorded = model.clone();
1646 let agent = AgentBuilder::new(model)
1647 .tool(MockAddTool)
1648 .tool_choice(ToolChoice::None)
1649 .build();
1650
1651 let err = agent
1652 .prompt("do not use tools")
1653 .add_hook(PanicOnUnknownToolHook)
1654 .max_turns(3)
1655 .await
1656 .expect_err("ToolChoice::None should reject returned tool calls");
1657
1658 match err {
1659 PromptError::UnknownToolCall {
1660 tool_name,
1661 available_tools,
1662 allowed_tools,
1663 chat_history,
1664 } => {
1665 assert_eq!(tool_name, "add");
1666 assert_eq!(available_tools, vec!["add".to_string()]);
1667 assert!(allowed_tools.is_empty());
1668 assert!(history_contains_tool_call(&chat_history, "add"));
1669 }
1670 other => panic!("expected UnknownToolCall, got {other:?}"),
1671 }
1672 assert_eq!(recorded.request_count(), 1);
1673 }
1674
1675 #[tokio::test]
1676 async fn invalid_tool_call_hook_can_repair_non_streaming_tool_name() {
1677 let model = MockCompletionModel::new([
1678 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1679 MockTurn::text("done"),
1680 ]);
1681 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1682
1683 let response = agent
1684 .prompt("add")
1685 .add_hook(RepairDefaultApiHook)
1686 .max_turns(3)
1687 .extended_details()
1688 .await
1689 .expect("repaired tool call should execute");
1690
1691 assert_eq!(response.output, "done");
1692 let messages = response.messages.expect("messages should be present");
1693 assert!(history_contains_tool_call(&messages, "add"));
1694 assert!(!history_contains_tool_call(&messages, "default_api"));
1695 assert!(messages.iter().any(|message| {
1696 matches!(
1697 message,
1698 Message::User { content }
1699 if content.iter().any(|content| {
1700 matches!(
1701 content,
1702 UserContent::ToolResult(result)
1703 if result.content.iter().any(|content| {
1704 matches!(
1705 content,
1706 rig_core::message::ToolResultContent::Json { value }
1707 if value == &serde_json::json!(5)
1708 )
1709 })
1710 )
1711 })
1712 )
1713 }));
1714 }
1715
1716 #[tokio::test]
1717 async fn invalid_tool_call_hook_retry_adds_feedback_and_retries_non_streaming() {
1718 let model = MockCompletionModel::new([
1719 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1720 MockTurn::text("retried"),
1721 ]);
1722 let recorded = model.clone();
1723 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1724
1725 let response = agent
1726 .prompt("add")
1727 .add_hook(RetryDefaultApiHook)
1728 .max_invalid_tool_call_retries(1)
1729 .max_turns(3)
1730 .extended_details()
1731 .await
1732 .expect("retry should recover");
1733
1734 assert_eq!(response.output, "retried");
1735 assert_eq!(recorded.request_count(), 2);
1736 let messages = response.messages.expect("messages should be present");
1737 assert!(messages.iter().any(|message| {
1738 matches!(
1739 message,
1740 Message::User { content }
1741 if content.iter().any(|content| {
1742 matches!(
1743 content,
1744 UserContent::ToolResult(result)
1745 if result.content.iter().any(|content| {
1746 matches!(
1747 content,
1748 rig_core::message::ToolResultContent::Text(text)
1749 if text.text.contains("Use one of these tools instead")
1750 )
1751 })
1752 )
1753 })
1754 )
1755 }));
1756 }
1757
1758 #[tokio::test]
1759 async fn invalid_tool_call_hook_retries_mixed_non_streaming_turn_without_executing_valid_call()
1760 {
1761 let add_calls = Arc::new(AtomicU32::new(0));
1762 let mut valid_tool_call = ToolCall::new(
1763 "tool_call_1".to_string(),
1764 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1765 );
1766 valid_tool_call.call_id = Some("call_1".to_string());
1767 let mut invalid_tool_call = ToolCall::new(
1768 "tool_call_2".to_string(),
1769 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1770 );
1771 invalid_tool_call.call_id = Some("call_2".to_string());
1772 let model = MockCompletionModel::new([
1773 MockTurn::from_contents([
1774 AssistantContent::ToolCall(valid_tool_call),
1775 AssistantContent::ToolCall(invalid_tool_call),
1776 ])
1777 .expect("tool-call response should be non-empty"),
1778 MockTurn::text("retried"),
1779 ]);
1780 let recorded = model.clone();
1781 let agent = AgentBuilder::new(model)
1782 .tool(CountingAddTool {
1783 calls: add_calls.clone(),
1784 })
1785 .build();
1786
1787 let response = agent
1788 .prompt("add")
1789 .add_hook(RetryDefaultApiHook)
1790 .max_invalid_tool_call_retries(1)
1791 .max_turns(3)
1792 .extended_details()
1793 .await
1794 .expect("retry should recover");
1795
1796 assert_eq!(response.output, "retried");
1797 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1798 let requests = recorded.requests();
1799 assert_eq!(requests.len(), 2);
1800 let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
1801 assert_eq!(retry_history.len(), 3);
1802 assert!(matches!(
1803 retry_history.get(1),
1804 Some(Message::Assistant { content, .. })
1805 if content.iter().any(|item| matches!(
1806 item,
1807 AssistantContent::ToolCall(tool_call)
1808 if tool_call.id == "tool_call_1"
1809 && tool_call.function.name == "add"
1810 ))
1811 && content.iter().any(|item| matches!(
1812 item,
1813 AssistantContent::ToolCall(tool_call)
1814 if tool_call.id == "tool_call_2"
1815 && tool_call.function.name == "default_api"
1816 ))
1817 ));
1818 assert!(matches!(
1819 retry_history.get(2),
1820 Some(Message::User { content })
1821 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1822 && content.iter().any(|item| matches!(
1823 item,
1824 UserContent::ToolResult(result)
1825 if result.id == "tool_call_1"
1826 && result.call_id.as_deref() == Some("call_1")
1827 && result.content.iter().any(|content| matches!(
1828 content,
1829 rig_core::message::ToolResultContent::Text(text)
1830 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1831 ))
1832 ))
1833 && content.iter().any(|item| matches!(
1834 item,
1835 UserContent::ToolResult(result)
1836 if result.id == "tool_call_2"
1837 && result.call_id.as_deref() == Some("call_2")
1838 && result.content.iter().any(|content| matches!(
1839 content,
1840 rig_core::message::ToolResultContent::Text(text)
1841 if text.text.contains("Use one of these tools instead")
1842 ))
1843 ))
1844 ));
1845 }
1846
1847 #[tokio::test]
1848 async fn invalid_tool_call_hook_skips_mixed_non_streaming_turn_without_executing_valid_call() {
1849 let add_calls = Arc::new(AtomicU32::new(0));
1850 let mut valid_tool_call = ToolCall::new(
1851 "tool_call_1".to_string(),
1852 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1853 );
1854 valid_tool_call.call_id = Some("call_1".to_string());
1855 let mut invalid_tool_call = ToolCall::new(
1856 "tool_call_2".to_string(),
1857 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1858 );
1859 invalid_tool_call.call_id = Some("call_2".to_string());
1860 let model = MockCompletionModel::new([
1861 MockTurn::from_contents([
1862 AssistantContent::ToolCall(valid_tool_call),
1863 AssistantContent::ToolCall(invalid_tool_call),
1864 ])
1865 .expect("tool-call response should be non-empty"),
1866 MockTurn::text("skipped"),
1867 ]);
1868 let agent = AgentBuilder::new(model)
1869 .tool(CountingAddTool {
1870 calls: add_calls.clone(),
1871 })
1872 .build();
1873
1874 let response = agent
1875 .prompt("add")
1876 .add_hook(SkipDefaultApiAndPanicOnToolCallHook)
1877 .max_turns(3)
1878 .extended_details()
1879 .await
1880 .expect("skip should recover without executing peer tools");
1881
1882 assert_eq!(response.output, "skipped");
1883 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1884 let messages = response.messages.expect("messages should be present");
1885 assert!(history_contains_tool_call(&messages, "add"));
1886 assert!(history_contains_tool_call(&messages, "default_api"));
1887 assert!(matches!(
1888 messages.get(2),
1889 Some(Message::User { content })
1890 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1891 && content.iter().any(|item| matches!(
1892 item,
1893 UserContent::ToolResult(result)
1894 if result.id == "tool_call_1"
1895 && result.call_id.as_deref() == Some("call_1")
1896 && result.content.iter().any(|content| matches!(
1897 content,
1898 rig_core::message::ToolResultContent::Text(text)
1899 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1900 ))
1901 ))
1902 && content.iter().any(|item| matches!(
1903 item,
1904 UserContent::ToolResult(result)
1905 if result.id == "tool_call_2"
1906 && result.call_id.as_deref() == Some("call_2")
1907 && result.content.iter().any(|content| matches!(
1908 content,
1909 rig_core::message::ToolResultContent::Text(text)
1910 if text.text == "default_api is not available"
1911 ))
1912 ))
1913 ));
1914 }
1915
1916 #[tokio::test]
1917 async fn invalid_tool_call_hook_retry_budget_exhaustion_fails() {
1918 let model = MockCompletionModel::new([
1919 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1920 MockTurn::text("should not be requested"),
1921 ]);
1922 let recorded = model.clone();
1923 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1924
1925 let err = agent
1926 .prompt("add")
1927 .add_hook(RetryDefaultApiHook)
1928 .max_invalid_tool_call_retries(0)
1929 .max_turns(3)
1930 .await
1931 .expect_err("retry without budget should fail");
1932
1933 match err {
1934 PromptError::UnknownToolCall {
1935 tool_name,
1936 chat_history,
1937 ..
1938 } => {
1939 assert_eq!(tool_name, "default_api");
1940 assert!(history_contains_tool_call(&chat_history, "default_api"));
1941 }
1942 other => panic!("expected UnknownToolCall, got {other:?}"),
1943 }
1944 assert_eq!(recorded.request_count(), 1);
1945 }
1946
1947 #[tokio::test]
1948 async fn invalid_tool_call_hook_can_skip_structured_non_streaming_call() {
1949 let model = MockCompletionModel::new([
1950 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1951 MockTurn::text("skipped"),
1952 ]);
1953 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1954
1955 let response = agent
1956 .prompt("add")
1957 .add_hook(SkipDefaultApiHook)
1958 .max_turns(3)
1959 .extended_details()
1960 .await
1961 .expect("skip should continue with synthetic tool result");
1962
1963 assert_eq!(response.output, "skipped");
1964 let messages = response.messages.expect("messages should be present");
1965 assert!(history_contains_tool_call(&messages, "default_api"));
1966 assert!(messages.iter().any(|message| {
1967 matches!(
1968 message,
1969 Message::User { content }
1970 if content.iter().any(|content| {
1971 matches!(
1972 content,
1973 UserContent::ToolResult(result)
1974 if result.content.iter().any(|content| {
1975 matches!(
1976 content,
1977 rig_core::message::ToolResultContent::Text(text)
1978 if text.text == "default_api is not available"
1979 )
1980 })
1981 )
1982 })
1983 )
1984 }));
1985 }
1986
1987 #[tokio::test]
1988 async fn skip_under_specific_tool_choice_returns_synthetic_feedback() {
1989 let model = MockCompletionModel::new([
1990 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1991 MockTurn::text("skipped"),
1992 ]);
1993 let agent = AgentBuilder::new(model)
1994 .tool(MockAddTool)
1995 .tool_choice(ToolChoice::Specific {
1996 function_names: vec!["add".to_string()],
1997 })
1998 .build();
1999
2000 let response = agent
2001 .prompt("add")
2002 .add_hook(SkipDefaultApiHook)
2003 .max_turns(3)
2004 .extended_details()
2005 .await
2006 .expect("skip should produce synthetic feedback under Specific");
2007
2008 assert_eq!(response.output, "skipped");
2009 let messages = response.messages.expect("messages should be present");
2010 assert!(history_contains_tool_call(&messages, "default_api"));
2011 assert!(messages.iter().any(|message| {
2012 matches!(
2013 message,
2014 Message::User { content }
2015 if content.iter().any(|content| {
2016 matches!(
2017 content,
2018 UserContent::ToolResult(result)
2019 if result.id == "tool_call_1"
2020 && result.content.iter().any(|content| {
2021 matches!(
2022 content,
2023 rig_core::message::ToolResultContent::Text(text)
2024 if text.text == "default_api is not available"
2025 )
2026 })
2027 )
2028 })
2029 )
2030 }));
2031 }
2032
2033 #[tokio::test]
2034 async fn repair_to_disallowed_specific_tool_fails() {
2035 let model = MockCompletionModel::new([
2036 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2037 MockTurn::text("should not be requested"),
2038 ]);
2039 let recorded = model.clone();
2040 let agent = AgentBuilder::new(model)
2041 .tool(MockAddTool)
2042 .tool(MockSubtractTool)
2043 .tool_choice(ToolChoice::Specific {
2044 function_names: vec!["add".to_string()],
2045 })
2046 .build();
2047
2048 let err = agent
2049 .prompt("add")
2050 .add_hook(RepairToSubtractHook)
2051 .max_turns(3)
2052 .await
2053 .expect_err("repair to a disallowed tool should fail");
2054
2055 match err {
2056 PromptError::UnknownToolCall { tool_name, .. } => {
2057 assert_eq!(tool_name, "subtract");
2058 }
2059 other => panic!("expected UnknownToolCall, got {other:?}"),
2060 }
2061 assert_eq!(recorded.request_count(), 1);
2062 }
2063
2064 #[tokio::test]
2065 async fn repair_under_tool_choice_none_fails() {
2066 let model = MockCompletionModel::new([
2067 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2068 MockTurn::text("should not be requested"),
2069 ]);
2070 let recorded = model.clone();
2071 let agent = AgentBuilder::new(model)
2072 .tool(MockAddTool)
2073 .tool_choice(ToolChoice::None)
2074 .build();
2075
2076 let err = agent
2077 .prompt("do not use tools")
2078 .add_hook(RepairDefaultApiHook)
2079 .max_turns(3)
2080 .await
2081 .expect_err("ToolChoice::None should reject repaired tool calls");
2082
2083 match err {
2084 PromptError::UnknownToolCall { tool_name, .. } => {
2085 assert_eq!(tool_name, "add");
2086 }
2087 other => panic!("expected UnknownToolCall, got {other:?}"),
2088 }
2089 assert_eq!(recorded.request_count(), 1);
2090 }
2091
2092 #[tokio::test]
2093 async fn skip_under_tool_choice_none_fails() {
2094 let model = MockCompletionModel::new([
2095 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2096 MockTurn::text("should not be requested"),
2097 ]);
2098 let recorded = model.clone();
2099 let agent = AgentBuilder::new(model)
2100 .tool(MockAddTool)
2101 .tool_choice(ToolChoice::None)
2102 .build();
2103
2104 let err = agent
2105 .prompt("do not use tools")
2106 .add_hook(SkipDefaultApiHook)
2107 .max_turns(3)
2108 .await
2109 .expect_err("ToolChoice::None should reject skipped tool calls");
2110
2111 match err {
2112 PromptError::UnknownToolCall { tool_name, .. } => {
2113 assert_eq!(tool_name, "default_api");
2114 }
2115 other => panic!("expected UnknownToolCall, got {other:?}"),
2116 }
2117 assert_eq!(recorded.request_count(), 1);
2118 }
2119
2120 #[tokio::test]
2121 async fn typed_prompt_default_invalid_tool_call_fails_fast() {
2122 let model = MockCompletionModel::new([
2123 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2124 MockTurn::text(r#"{"value":"should not be requested"}"#),
2125 ]);
2126 let recorded = model.clone();
2127 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2128
2129 let err = agent
2130 .prompt_typed::<TypedAnswer>("return typed json")
2131 .add_hook(PanicOnUnknownToolHook)
2132 .max_turns(3)
2133 .await
2134 .expect_err("typed prompt should preserve fail-fast default");
2135
2136 match err {
2137 StructuredOutputError::PromptError(err) => match *err {
2138 PromptError::UnknownToolCall { tool_name, .. } => {
2139 assert_eq!(tool_name, "default_api");
2140 }
2141 other => panic!("expected UnknownToolCall, got {other:?}"),
2142 },
2143 other => panic!("expected prompt error, got {other:?}"),
2144 }
2145 assert_eq!(recorded.request_count(), 1);
2146 }
2147
2148 #[tokio::test]
2149 async fn typed_prompt_invalid_tool_call_hook_can_repair_tool_name() {
2150 let model = MockCompletionModel::new([
2151 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2152 MockTurn::text(r#"{"value":"repaired"}"#),
2153 ]);
2154 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2155
2156 let response = agent
2157 .prompt_typed::<TypedAnswer>("return typed json")
2158 .add_hook(RepairDefaultApiHook)
2159 .max_turns(3)
2160 .await
2161 .expect("typed prompt should repair invalid tool call");
2162
2163 assert_eq!(
2164 response,
2165 TypedAnswer {
2166 value: "repaired".to_string()
2167 }
2168 );
2169 }
2170
2171 #[tokio::test]
2172 async fn typed_prompt_invalid_tool_call_hook_can_retry_and_parse_response() {
2173 let model = MockCompletionModel::new([
2174 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2175 MockTurn::text(r#"{"value":"retried"}"#),
2176 ]);
2177 let recorded = model.clone();
2178 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2179
2180 let response = agent
2181 .prompt_typed::<TypedAnswer>("return typed json")
2182 .add_hook(RetryDefaultApiHook)
2183 .max_invalid_tool_call_retries(1)
2184 .max_turns(3)
2185 .await
2186 .expect("typed prompt should retry invalid tool call");
2187
2188 assert_eq!(
2189 response,
2190 TypedAnswer {
2191 value: "retried".to_string()
2192 }
2193 );
2194 assert_eq!(recorded.request_count(), 2);
2195 }
2196
2197 #[tokio::test]
2198 async fn typed_prompt_invalid_tool_call_retry_budget_exhaustion_fails() {
2199 let model = MockCompletionModel::new([
2200 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2201 MockTurn::text(r#"{"value":"should not be requested"}"#),
2202 ]);
2203 let recorded = model.clone();
2204 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2205
2206 let err = agent
2207 .prompt_typed::<TypedAnswer>("return typed json")
2208 .add_hook(RetryDefaultApiHook)
2209 .max_invalid_tool_call_retries(0)
2210 .max_turns(3)
2211 .await
2212 .expect_err("typed prompt should fail when retry budget is exhausted");
2213
2214 match err {
2215 StructuredOutputError::PromptError(err) => match *err {
2216 PromptError::UnknownToolCall { tool_name, .. } => {
2217 assert_eq!(tool_name, "default_api");
2218 }
2219 other => panic!("expected UnknownToolCall, got {other:?}"),
2220 },
2221 other => panic!("expected prompt error, got {other:?}"),
2222 }
2223 assert_eq!(recorded.request_count(), 1);
2224 }
2225
2226 #[tokio::test]
2227 async fn invalid_specific_tool_choice_fails_before_non_streaming_provider_request() {
2228 let model = MockCompletionModel::text("should not be requested");
2229 let recorded = model.clone();
2230 let agent = AgentBuilder::new(model)
2231 .tool(MockAddTool)
2232 .tool_choice(ToolChoice::Specific {
2233 function_names: vec!["missing".to_string()],
2234 })
2235 .build();
2236
2237 let err = agent
2238 .prompt("use the missing tool")
2239 .await
2240 .expect_err("invalid ToolChoice::Specific should fail before provider request");
2241
2242 match err {
2243 PromptError::CompletionError(CompletionError::RequestError(err)) => {
2244 let msg = err.to_string();
2245 assert!(msg.contains("missing"), "got: {msg}");
2246 assert!(msg.contains("add"), "got: {msg}");
2247 }
2248 other => panic!("expected CompletionError::RequestError, got {other:?}"),
2249 }
2250 assert_eq!(recorded.request_count(), 0);
2251 }
2252
2253 #[tokio::test]
2254 async fn allowed_specific_tool_call_executes_normally() {
2255 let model = MockCompletionModel::new([
2256 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
2257 MockTurn::text("done"),
2258 ]);
2259 let recorded = model.clone();
2260 let agent = AgentBuilder::new(model)
2261 .tool(MockAddTool)
2262 .tool_choice(ToolChoice::Specific {
2263 function_names: vec!["add".to_string()],
2264 })
2265 .build();
2266
2267 let response = agent
2268 .prompt("use the allowed tool")
2269 .max_turns(3)
2270 .await
2271 .expect("allowed specific tool should execute");
2272
2273 assert_eq!(response, "done");
2274 assert_eq!(recorded.request_count(), 2);
2275 }
2276
2277 #[tokio::test]
2278 async fn prompt_request_stops_cleanly_on_empty_terminal_turn() {
2279 let first_call_usage = Usage {
2280 input_tokens: 1,
2281 output_tokens: 1,
2282 total_tokens: 2,
2283 cached_input_tokens: 0,
2284 cache_creation_input_tokens: 0,
2285 tool_use_prompt_tokens: 0,
2286 reasoning_tokens: 0,
2287 };
2288 let second_call_usage = Usage {
2289 input_tokens: 1,
2290 output_tokens: 1,
2291 total_tokens: 2,
2292 cached_input_tokens: 0,
2293 cache_creation_input_tokens: 0,
2294 tool_use_prompt_tokens: 0,
2295 reasoning_tokens: 0,
2296 };
2297 let model = MockCompletionModel::new([
2298 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2}))
2299 .with_call_id("call_1")
2300 .with_usage(first_call_usage),
2301 MockTurn::text("").with_usage(second_call_usage),
2302 ]);
2303 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2304
2305 let response = agent
2306 .prompt("do tool work")
2307 .max_turns(3)
2308 .extended_details()
2309 .await
2310 .expect("empty terminal turn should not error");
2311
2312 assert!(response.output.is_empty());
2313 assert_eq!(
2314 response.usage,
2315 Usage {
2316 input_tokens: 2,
2317 output_tokens: 2,
2318 total_tokens: 4,
2319 cached_input_tokens: 0,
2320 cache_creation_input_tokens: 0,
2321 tool_use_prompt_tokens: 0,
2322 reasoning_tokens: 0,
2323 }
2324 );
2325 assert_eq!(
2326 response.completion_calls(),
2327 &[
2328 CompletionCall::new(0, first_call_usage),
2329 CompletionCall::new(1, second_call_usage)
2330 ]
2331 );
2332
2333 let history = response
2334 .messages
2335 .expect("extended response should include history");
2336 assert_eq!(history.len(), 3);
2337 assert!(matches!(
2338 history.first(),
2339 Some(Message::User { content })
2340 if matches!(
2341 content.first(),
2342 UserContent::Text(text) if text.text == "do tool work"
2343 )
2344 ));
2345 assert!(history.iter().any(|message| matches!(
2346 message,
2347 Message::Assistant { content, .. }
2348 if matches!(
2349 content.first(),
2350 AssistantContent::ToolCall(tool_call)
2351 if tool_call.id == "tool_call_1"
2352 && tool_call.call_id.as_deref() == Some("call_1")
2353 )
2354 )));
2355 assert!(history.iter().any(|message| matches!(
2356 message,
2357 Message::User { content }
2358 if matches!(
2359 content.first(),
2360 UserContent::ToolResult(tool_result)
2361 if tool_result.id == "tool_call_1"
2362 && tool_result.call_id.as_deref() == Some("call_1")
2363 )
2364 )));
2365 assert!(!history.iter().any(|message| matches!(
2366 message,
2367 Message::Assistant { content, .. }
2368 if content.iter().any(|item| matches!(
2369 item,
2370 AssistantContent::Text(text) if text.text.is_empty()
2371 ))
2372 )));
2373 let requests = agent.model.requests();
2374 assert_eq!(requests.len(), 2);
2375 validate_follow_up_tool_history(&requests[1]);
2376 }
2377
2378 #[tokio::test]
2379 async fn prompt_request_concatenates_text_blocks_without_inserted_newlines() {
2380 let model = MockCompletionModel::new([MockTurn::from_contents([
2381 AssistantContent::Text(Text::new("According to the document, ")),
2382 AssistantContent::Text(Text::new("the grass is green")),
2383 AssistantContent::Text(Text::new(" and the sky is blue.")),
2384 ])
2385 .expect("mock response should contain text blocks")]);
2386 let agent = AgentBuilder::new(model).build();
2387
2388 let response = agent
2389 .prompt("answer with cited spans")
2390 .await
2391 .expect("prompt should succeed");
2392
2393 assert_eq!(
2394 response,
2395 "According to the document, the grass is green and the sky is blue."
2396 );
2397 }
2398
2399 #[tokio::test]
2400 async fn prompt_request_preserves_metadata_only_text_turn_in_history() {
2401 let metadata = json!({
2402 "citations": [{
2403 "type": "web_search_result_location",
2404 "cited_text": "Claude Shannon was born in 1916.",
2405 "url": "https://example.com/shannon",
2406 "title": null,
2407 "encrypted_index": "encrypted-reference"
2408 }]
2409 });
2410 let model =
2411 MockCompletionModel::new([MockTurn::from_content(AssistantContent::Text(Text {
2412 text: String::new(),
2413 additional_params: Some(metadata.clone()),
2414 }))]);
2415 let agent = AgentBuilder::new(model).build();
2416
2417 let response = agent
2418 .prompt("answer with cited metadata")
2419 .extended_details()
2420 .await
2421 .expect("metadata-only text turn should succeed");
2422
2423 assert!(response.output.is_empty());
2424 let history = response
2425 .messages
2426 .expect("extended response should include history");
2427 assert!(history.iter().any(|message| matches!(
2428 message,
2429 Message::Assistant { content, .. }
2430 if matches!(
2431 content.first(),
2432 AssistantContent::Text(text)
2433 if text.text.is_empty()
2434 && text.additional_params.as_ref() == Some(&metadata)
2435 )
2436 )));
2437 }
2438
2439 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
2442
2443 #[tokio::test]
2444 async fn memory_loads_into_request_history() {
2445 let memory = InMemoryConversationMemory::new();
2446 memory
2447 .append(
2448 "thread-1",
2449 vec![Message::user("hello"), Message::assistant("hi there")],
2450 )
2451 .await
2452 .unwrap();
2453
2454 let model = MockCompletionModel::text("ack");
2455 let recorded = model.clone();
2456
2457 let agent = AgentBuilder::new(model).memory(memory).build();
2458 let _ = agent
2459 .prompt("ping")
2460 .conversation("thread-1")
2461 .await
2462 .expect("prompt should succeed");
2463
2464 let received = recorded.requests()[0]
2465 .chat_history
2466 .iter()
2467 .cloned()
2468 .collect::<Vec<_>>();
2469 assert_eq!(
2470 received.len(),
2471 3,
2472 "loaded memory (2) + current prompt should appear in request: {received:?}"
2473 );
2474 }
2475
2476 #[tokio::test]
2477 async fn memory_appends_full_turn_after_success() {
2478 let memory = InMemoryConversationMemory::new();
2479 let model = MockCompletionModel::text("ack");
2480 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2481
2482 let _ = agent
2483 .prompt("hello")
2484 .conversation("t1")
2485 .await
2486 .expect("prompt should succeed");
2487
2488 let stored = memory.load("t1").await.unwrap();
2489 assert_eq!(stored.len(), 2, "user prompt + assistant response saved");
2490 }
2491
2492 #[tokio::test]
2493 async fn explicit_with_history_overrides_memory() {
2494 let memory = CountingMemory::default();
2495 memory
2496 .inner()
2497 .append("t1", vec![Message::user("from-memory")])
2498 .await
2499 .unwrap();
2500
2501 let model = MockCompletionModel::text("ack");
2502 let recorded = model.clone();
2503
2504 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2505 let _ = agent
2506 .prompt("hello")
2507 .conversation("t1")
2508 .history(vec![Message::user("from-caller")])
2509 .await
2510 .expect("prompt should succeed");
2511
2512 assert_eq!(memory.load_count(), 0, "load skipped");
2513 let appends = memory.append_count();
2514 assert_eq!(appends, 0, "append skipped");
2515
2516 let received = recorded.requests()[0]
2517 .chat_history
2518 .iter()
2519 .cloned()
2520 .collect::<Vec<_>>();
2521 assert_eq!(received.len(), 2, "caller history (1) + current prompt");
2522 assert!(matches!(
2523 received.first(),
2524 Some(Message::User { content })
2525 if matches!(content.first(), UserContent::Text(t) if t.text == "from-caller")
2526 ));
2527 }
2528
2529 #[tokio::test]
2530 async fn memory_unchanged_on_provider_error() {
2531 let memory = InMemoryConversationMemory::new();
2532 let model = MockCompletionModel::new([MockTurn::error("boom")]);
2533
2534 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2535 let result = agent.prompt("hello").conversation("t1").await;
2536 assert!(result.is_err());
2537
2538 let stored = memory.load("t1").await.unwrap();
2539 assert!(stored.is_empty(), "no append on error");
2540 }
2541
2542 #[tokio::test]
2543 async fn multi_step_tool_run_appends_committed_turn_exactly_once() {
2544 let memory = CountingMemory::default();
2548 let model = MockCompletionModel::new([
2549 MockTurn::tool_call("call-1", "add", json!({"x": 2, "y": 3})),
2550 MockTurn::text("sum is 5"),
2551 ]);
2552
2553 let agent = AgentBuilder::new(model)
2554 .memory(memory.clone())
2555 .tool(MockAddTool)
2556 .default_max_turns(2)
2557 .build();
2558
2559 let _ = agent
2560 .prompt("add 2 and 3")
2561 .conversation("t1")
2562 .await
2563 .expect("multi-step run should succeed");
2564
2565 assert_eq!(
2566 memory.append_count(),
2567 1,
2568 "one append for the whole run, not one per model call"
2569 );
2570
2571 let stored = memory.load("t1").await.unwrap();
2572 assert_eq!(
2574 stored.len(),
2575 4,
2576 "the full committed turn is persisted once: {stored:?}"
2577 );
2578 assert!(
2579 matches!(
2580 stored.last(),
2581 Some(Message::Assistant { content, .. })
2582 if content
2583 .iter()
2584 .any(|item| matches!(item, AssistantContent::Text(t) if t.text == "sum is 5"))
2585 ),
2586 "final assistant text is persisted: {stored:?}"
2587 );
2588 }
2589
2590 #[tokio::test]
2591 async fn append_persists_only_newly_committed_messages() {
2592 let memory = CountingMemory::default();
2597 memory
2598 .inner()
2599 .append(
2600 "t1",
2601 vec![Message::user("old-q"), Message::assistant("old-a")],
2602 )
2603 .await
2604 .unwrap();
2605
2606 let model = MockCompletionModel::text("new-a");
2607 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2608
2609 let _ = agent
2610 .prompt("new-q")
2611 .conversation("t1")
2612 .await
2613 .expect("prompt should succeed");
2614
2615 assert_eq!(memory.append_count(), 1, "one append for the run");
2616
2617 let stored = memory.load("t1").await.unwrap();
2618 assert_eq!(
2621 stored.len(),
2622 4,
2623 "only the new turn is appended, loaded history is not duplicated: {stored:?}"
2624 );
2625 assert!(
2626 matches!(
2627 stored.first(),
2628 Some(Message::User { content })
2629 if matches!(content.first(), UserContent::Text(t) if t.text == "old-q")
2630 ),
2631 "loaded history is preserved once at the front: {stored:?}"
2632 );
2633 }
2634
2635 #[tokio::test]
2636 async fn hook_stopped_run_does_not_append() {
2637 struct StopOnCompletion;
2639 impl AgentHook for StopOnCompletion {
2640 async fn on_completion_call(
2641 &self,
2642 _ctx: &HookContext,
2643 _event: crate::agent::CompletionCallEvent<'_>,
2644 ) -> crate::agent::CompletionCallAction {
2645 crate::agent::CompletionCallAction::stop("stop")
2646 }
2647 }
2648
2649 let memory = CountingMemory::default();
2650 let model = MockCompletionModel::text("unreached");
2651 let agent = AgentBuilder::new(model)
2652 .memory(memory.clone())
2653 .add_hook(StopOnCompletion)
2654 .build();
2655
2656 let result = agent.prompt("hello").conversation("t1").await;
2657 assert!(result.is_err(), "a stop hook terminates the run");
2658
2659 assert_eq!(memory.append_count(), 0, "stopped runs do not append");
2660 let stored = memory.load("t1").await.unwrap();
2661 assert!(stored.is_empty(), "nothing persisted on stop: {stored:?}");
2662 }
2663
2664 #[tokio::test]
2665 async fn committed_transcript_roles_form_a_valid_sequence() {
2666 let memory = CountingMemory::default();
2671 let model = MockCompletionModel::new([
2672 MockTurn::tool_call("call-1", "add", json!({"x": 1, "y": 1})),
2673 MockTurn::text("done"),
2674 ]);
2675
2676 let agent = AgentBuilder::new(model)
2677 .memory(memory.clone())
2678 .tool(MockAddTool)
2679 .default_max_turns(2)
2680 .build();
2681
2682 let _ = agent
2683 .prompt("go")
2684 .conversation("t1")
2685 .await
2686 .expect("run should succeed");
2687
2688 let stored = memory.load("t1").await.unwrap();
2689
2690 assert!(
2691 matches!(stored.first(), Some(Message::User { .. })),
2692 "committed transcript begins with a user message: {stored:?}"
2693 );
2694 assert!(
2695 !stored
2696 .windows(2)
2697 .any(|pair| matches!(pair, [Message::Assistant { .. }, Message::Assistant { .. }])),
2698 "no two assistant messages are committed back to back: {stored:?}"
2699 );
2700 for (index, message) in stored.iter().enumerate() {
2703 let has_tool_call = matches!(
2704 message,
2705 Message::Assistant { content, .. }
2706 if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
2707 );
2708 if has_tool_call {
2709 assert!(
2710 matches!(stored.get(index + 1), Some(Message::User { content })
2711 if content
2712 .iter()
2713 .any(|item| matches!(item, UserContent::ToolResult(_)))),
2714 "assistant tool call at {index} is followed by a user tool result: {stored:?}"
2715 );
2716 }
2717 }
2718 }
2719
2720 #[tokio::test]
2721 async fn missing_conversation_id_behaves_as_no_memory() {
2722 let memory = CountingMemory::default();
2723 let model = MockCompletionModel::text("ack");
2724 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2725
2726 let _ = agent.prompt("hello").await.expect("prompt should succeed");
2727
2728 assert_eq!(memory.load_count(), 0);
2729 assert_eq!(memory.append_count(), 0);
2730 }
2731
2732 #[tokio::test]
2733 async fn default_conversation_id_is_used_when_none_per_request() {
2734 let memory = InMemoryConversationMemory::new();
2735 let model = MockCompletionModel::text("ack");
2736 let agent = AgentBuilder::new(model)
2737 .memory(memory.clone())
2738 .conversation("default-thread")
2739 .build();
2740
2741 let _ = agent.prompt("hello").await.expect("prompt should succeed");
2742 let stored = memory.load("default-thread").await.unwrap();
2743 assert_eq!(stored.len(), 2);
2744 }
2745
2746 #[tokio::test]
2747 async fn with_filter_truncates_loaded_history() {
2748 let memory = InMemoryConversationMemory::new()
2749 .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
2750 memory
2751 .append(
2752 "t1",
2753 vec![
2754 Message::user("1"),
2755 Message::assistant("2"),
2756 Message::user("3"),
2757 Message::assistant("4"),
2758 ],
2759 )
2760 .await
2761 .unwrap();
2762
2763 let model = MockCompletionModel::text("ack");
2764 let recorded = model.clone();
2765 let agent = AgentBuilder::new(model).memory(memory).build();
2766
2767 let _ = agent
2768 .prompt("ping")
2769 .conversation("t1")
2770 .await
2771 .expect("prompt should succeed");
2772
2773 let received = recorded.requests()[0]
2774 .chat_history
2775 .iter()
2776 .cloned()
2777 .collect::<Vec<_>>();
2778 assert_eq!(
2779 received.len(),
2780 3,
2781 "window-truncated history (2) + current prompt"
2782 );
2783 }
2784
2785 #[tokio::test]
2786 async fn without_memory_disables_for_request() {
2787 let memory = CountingMemory::default();
2788 let model = MockCompletionModel::text("ack");
2789 let agent = AgentBuilder::new(model)
2790 .memory(memory.clone())
2791 .conversation("t1")
2792 .build();
2793
2794 let _ = agent
2795 .prompt("hello")
2796 .without_memory()
2797 .await
2798 .expect("prompt should succeed");
2799
2800 assert_eq!(memory.load_count(), 0);
2801 assert_eq!(memory.append_count(), 0);
2802 }
2803
2804 #[tokio::test]
2805 async fn memory_load_error_surfaces_as_prompt_error() {
2806 let model = MockCompletionModel::text("ack");
2807 let agent = AgentBuilder::new(model)
2808 .memory(FailingMemory::default())
2809 .build();
2810 let result = agent.prompt("hello").conversation("t1").await;
2811
2812 match result {
2813 Err(PromptError::MemoryError(err)) => {
2814 let msg = err.to_string();
2815 assert!(msg.contains("load boom"), "got: {msg}");
2816 }
2817 other => panic!("expected PromptError::MemoryError, got {other:?}"),
2818 }
2819 }
2820
2821 #[tokio::test]
2822 async fn memory_append_error_does_not_drop_response() {
2823 let model = MockCompletionModel::text("ack");
2824 let agent = AgentBuilder::new(model)
2825 .memory(AppendFailingMemory::default())
2826 .build();
2827 let response: String = agent
2828 .prompt("hello")
2829 .conversation("t1")
2830 .await
2831 .expect("append failure must not block successful completion");
2832
2833 assert!(!response.is_empty());
2834 }
2835}