1use super::hook::{HookStack, RequestPatch};
2use super::prompt_request::{self, PromptRequest};
3use super::run::OutputMode;
4use super::runner::AgentRunner;
5use crate::{
6 agent::prompt_request::streaming::StreamingPromptRequest,
7 completion::{
8 Chat, CompletionError, CompletionModel, CompletionRequestBuilder, Document, GetTokenUsage,
9 Message, Prompt, PromptError, ToolDefinition, TypedPrompt,
10 },
11 json_utils,
12 streaming::{StreamingChat, StreamingPrompt},
13 tool::server::{ToolRegistrySnapshot, ToolServerError, ToolServerHandle},
14};
15use rig_core::{message::ToolChoice, wasm_compat::WasmCompatSend};
16use std::{collections::BTreeSet, sync::Arc};
17
18use super::UNKNOWN_AGENT_NAME;
19
20pub(crate) struct PreparedCompletionRequest<M: CompletionModel> {
23 pub(crate) builder: CompletionRequestBuilder<M>,
24 pub(crate) tool_snapshot: Arc<ToolRegistrySnapshot>,
26 pub(crate) executable_tool_names: BTreeSet<String>,
27 pub(crate) allowed_tool_names: BTreeSet<String>,
28 pub(crate) output_tool_name: Option<String>,
31}
32
33const DEFAULT_OUTPUT_TOOL_NAME: &str = "final_result";
35
36fn tool_choice_permits_output_tool(tool_choice: Option<&ToolChoice>) -> bool {
41 matches!(
42 tool_choice,
43 None | Some(ToolChoice::Auto | ToolChoice::Required)
44 )
45}
46
47fn output_tool_callable(tool_choice: Option<&ToolChoice>, output_tool_name: &str) -> bool {
57 match tool_choice {
58 Some(ToolChoice::Specific { function_names }) => function_names
59 .iter()
60 .any(|name| name.as_str() == output_tool_name),
61 other => tool_choice_permits_output_tool(other),
62 }
63}
64
65fn resolve_output_mode(
80 has_schema: bool,
81 has_executable_tools: bool,
82 output_tool_callable: bool,
83 provider_composes_native: bool,
84 requested: &OutputMode,
85) -> OutputMode {
86 if !has_schema {
87 return OutputMode::Native;
88 }
89 match requested {
90 OutputMode::Native => OutputMode::Native,
91 OutputMode::Prompted => OutputMode::Prompted,
92 OutputMode::Tool if output_tool_callable => OutputMode::Tool,
93 OutputMode::Tool => OutputMode::Native,
94 OutputMode::Auto
95 if has_executable_tools && output_tool_callable && !provider_composes_native =>
96 {
97 OutputMode::Tool
98 }
99 OutputMode::Auto => OutputMode::Native,
100 }
101}
102
103fn pick_output_tool_name(executable_tool_names: &BTreeSet<String>) -> String {
106 let mut name = DEFAULT_OUTPUT_TOOL_NAME.to_string();
107 let mut suffix = 1u32;
108 while executable_tool_names.contains(&name) {
109 name = format!("{DEFAULT_OUTPUT_TOOL_NAME}_{suffix}");
110 suffix += 1;
111 }
112 name
113}
114
115pub(crate) fn allowed_tool_names_for_choice(
135 executable_tool_names: &BTreeSet<String>,
136 tool_choice: Option<&ToolChoice>,
137 output_tool_name: Option<&str>,
138 pre_filter_tool_names: Option<&BTreeSet<String>>,
139) -> Result<BTreeSet<String>, CompletionError> {
140 let has_advertised_tool = !executable_tool_names.is_empty() || output_tool_name.is_some();
141 let hint = |active_tools_caused: bool| {
142 if active_tools_caused {
143 " A per-turn `active_tools` allow-list narrowed the advertised tools this turn; \
144 set a compatible `tool_choice` in the same `RequestPatch`, or widen `active_tools`."
145 } else {
146 ""
147 }
148 };
149 let advertised = || {
151 executable_tool_names
152 .iter()
153 .map(String::as_str)
154 .chain(output_tool_name)
155 .collect::<Vec<_>>()
156 };
157
158 let allowed = match tool_choice {
159 None | Some(ToolChoice::Auto) => executable_tool_names.clone(),
160 Some(ToolChoice::Required) => {
161 if !has_advertised_tool {
162 let active_tools_caused = pre_filter_tool_names.is_some_and(|pf| !pf.is_empty());
164 return Err(CompletionError::RequestError(
165 format!(
166 "ToolChoice::Required forces the model to call a tool, but no tools are \
167 advertised this turn.{}",
168 hint(active_tools_caused)
169 )
170 .into(),
171 ));
172 }
173 executable_tool_names.clone()
174 }
175 Some(ToolChoice::None) => BTreeSet::new(),
176 Some(ToolChoice::Specific { function_names }) => {
177 if function_names.is_empty() {
178 return Err(CompletionError::RequestError(
179 "ToolChoice::Specific requires at least one function name".into(),
180 ));
181 }
182
183 let requested = function_names.iter().cloned().collect::<BTreeSet<String>>();
184 let missing = function_names
185 .iter()
186 .map(String::as_str)
187 .filter(|name| {
188 !executable_tool_names.contains(*name) && Some(*name) != output_tool_name
189 })
190 .collect::<Vec<_>>();
191
192 if !missing.is_empty() {
193 let active_tools_caused = pre_filter_tool_names
196 .is_some_and(|pf| missing.iter().any(|name| pf.contains(*name)));
197 return Err(CompletionError::RequestError(
198 format!(
199 "ToolChoice::Specific requested tool names not advertised this turn: \
200 {missing:?}. Advertised: {:?}.{}",
201 advertised(),
202 hint(active_tools_caused)
203 )
204 .into(),
205 ));
206 }
207
208 requested
209 }
210 };
211
212 Ok(allowed)
213}
214
215#[allow(clippy::too_many_arguments)]
218pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
219 model: &Arc<M>,
220 prompt: Message,
221 chat_history: &[Message],
222 preamble: Option<&str>,
223 static_context: &[Document],
224 temperature: Option<f64>,
225 max_tokens: Option<u64>,
226 additional_params: Option<&serde_json::Value>,
227 record_telemetry_content: bool,
228 tool_choice: Option<&ToolChoice>,
229 tool_server_handle: &ToolServerHandle,
230 output_schema: Option<&schemars::Schema>,
231 output_mode: &OutputMode,
232 committed_output_tool: Option<&str>,
233 output_tool_description: Option<&str>,
234 augment_output_preamble: bool,
235 request_patch: Option<&RequestPatch>,
236) -> Result<PreparedCompletionRequest<M>, CompletionError> {
237 let preamble = request_patch
243 .and_then(|o| o.preamble.as_deref())
244 .or(preamble);
245 let temperature = request_patch.and_then(|o| o.temperature).or(temperature);
246 let max_tokens = request_patch.and_then(|o| o.max_tokens).or(max_tokens);
247 let tool_choice = request_patch
248 .and_then(|o| o.tool_choice.as_ref())
249 .or(tool_choice);
250 let additional_params: Option<serde_json::Value> = match (
257 additional_params,
258 request_patch.and_then(|o| o.additional_params.as_ref()),
259 ) {
260 (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
261 Some(json_utils::merge(base.clone(), patch.clone()))
262 }
263 (base, patch) => patch.or(base).cloned(),
264 };
265 let active_tools = request_patch.and_then(|o| o.active_tools.as_deref());
266
267 let retrieval_query = prompt.rag_text().or_else(|| {
270 chat_history
271 .iter()
272 .rev()
273 .find_map(|message| message.rag_text())
274 });
275
276 let mut tool_snapshot = tool_server_handle
277 .snapshot_tool_defs(retrieval_query)
278 .await
279 .map_err(|_| CompletionError::RequestError("Failed to get tool definitions".into()))?;
280
281 let pre_filter_tool_names: Option<BTreeSet<String>> = active_tools.map(|_| {
290 tool_snapshot
291 .definitions()
292 .iter()
293 .map(|tool| tool.name.clone())
294 .collect()
295 });
296
297 if let Some(allow) = active_tools {
305 if let Some(missing) = allow.iter().find(|name| {
306 !tool_snapshot
307 .definitions()
308 .iter()
309 .any(|tool| &tool.name == *name)
310 }) {
311 return Err(CompletionError::RequestError(
312 format!(
313 "active_tools requested tool `{missing}`, which is not available this turn"
314 )
315 .into(),
316 ));
317 }
318 let allowed: BTreeSet<String> = allow.iter().cloned().collect();
319 tool_snapshot.retain_names(&allowed);
320 }
321
322 let mut tooldefs = tool_snapshot.definitions().to_vec();
323
324 let executable_tool_names: BTreeSet<String> =
327 tooldefs.iter().map(|tool| tool.name.clone()).collect();
328
329 let resolved_mode = if committed_output_tool.is_some() && output_schema.is_some() {
340 OutputMode::Tool
341 } else {
342 resolve_output_mode(
343 output_schema.is_some(),
344 !executable_tool_names.is_empty(),
345 tool_choice_permits_output_tool(tool_choice),
346 model.composes_native_output_with_tools(),
347 output_mode,
348 )
349 };
350
351 let output_tool_name = matches!(resolved_mode, OutputMode::Tool).then(|| {
354 committed_output_tool.map(str::to_owned).unwrap_or_else(|| {
355 pick_output_tool_name(
356 pre_filter_tool_names
357 .as_ref()
358 .unwrap_or(&executable_tool_names),
359 )
360 })
361 });
362
363 if let Some(name) = &output_tool_name
370 && executable_tool_names.contains(name)
371 {
372 return Err(CompletionError::RequestError(
373 format!(
374 "real tool `{name}` conflicts with the structured-output tool reserved for this \
375 run; rename or remove the real tool, exclude it with `active_tools`, or make it \
376 visible before starting a new run so Rig can reserve a different output-tool name"
377 )
378 .into(),
379 ));
380 }
381
382 if let Some(name) = &output_tool_name
393 && !output_tool_callable(tool_choice, name)
394 {
395 tracing::warn!(
396 "the active tool_choice forbids calling the structured-output tool while the \
397 run is pinned to Tool output mode; this turn cannot emit the structured \
398 result (check for a `RequestPatch` setting `tool_choice` to None or a \
399 Specific set that excludes the output tool)"
400 );
401 }
402
403 let effective_preamble: Option<String> = {
406 let base = preamble.map(str::to_owned);
407 let instruction = match &resolved_mode {
408 OutputMode::Tool if augment_output_preamble => {
409 output_tool_name.as_deref().map(|name| {
410 format!(
411 "When you have gathered enough information to answer, call the `{name}` \
412 tool exactly once with your final answer. Its arguments are the structured \
413 result and must satisfy the required schema. Do not return the final answer \
414 as plain text."
415 )
416 })
417 }
418 OutputMode::Tool => None,
419 OutputMode::Prompted => output_schema.map(|schema| {
420 let schema_json = serde_json::to_string(schema.as_value()).unwrap_or_default();
421 format!(
422 "Respond with ONLY a single JSON object that conforms to this JSON Schema. \
423 Do not include any prose, explanation, or markdown code fences.\n{schema_json}"
424 )
425 }),
426 OutputMode::Native | OutputMode::Auto => None,
427 };
428 match (base, instruction) {
429 (Some(b), Some(i)) => Some(format!("{b}\n\n{i}")),
430 (Some(b), None) => Some(b),
431 (None, Some(i)) => Some(i),
432 (None, None) => None,
433 }
434 };
435
436 let messages_history: &[Message] = request_patch
441 .and_then(|o| o.history.as_deref())
442 .unwrap_or(chat_history);
443 let chat_history: Vec<Message> = if let Some(preamble) = &effective_preamble {
444 std::iter::once(Message::system(preamble.clone()))
445 .chain(messages_history.iter().cloned())
446 .collect()
447 } else {
448 messages_history.to_vec()
449 };
450
451 if let (Some(name), Some(schema)) = (&output_tool_name, output_schema) {
457 tooldefs.push(crate::completion::ToolDefinition {
458 name: name.clone(),
459 description: output_tool_description
460 .unwrap_or(
461 "Call this tool exactly once with your final answer when you are done. \
462 Its arguments are the structured result and must satisfy the output schema.",
463 )
464 .to_string(),
465 parameters: schema.clone().to_value(),
466 });
467 }
468
469 let mut completion_request = model
470 .completion_request(prompt)
471 .messages(chat_history)
472 .temperature_opt(temperature)
473 .max_tokens_opt(max_tokens)
474 .additional_params_opt(additional_params)
475 .record_content_telemetry(record_telemetry_content)
476 .documents(static_context.to_vec())
477 .tools(tooldefs);
478
479 if let Some(patch) = request_patch
483 && !patch.extra_context.is_empty()
484 {
485 completion_request = completion_request.documents(patch.extra_context.clone());
486 }
487
488 if matches!(resolved_mode, OutputMode::Native) {
490 completion_request = completion_request.output_schema_opt(output_schema.cloned());
491 }
492
493 let completion_request = if let Some(tool_choice) = tool_choice {
494 completion_request.tool_choice(tool_choice.clone())
495 } else {
496 completion_request
497 };
498
499 let mut allowed_tool_names = allowed_tool_names_for_choice(
504 &executable_tool_names,
505 tool_choice,
506 output_tool_name.as_deref(),
507 pre_filter_tool_names.as_ref(),
508 )?;
509 if let Some(name) = &output_tool_name {
512 allowed_tool_names.insert(name.clone());
513 }
514
515 Ok(PreparedCompletionRequest {
516 builder: completion_request,
517 tool_snapshot: Arc::new(tool_snapshot),
518 executable_tool_names,
519 allowed_tool_names,
520 output_tool_name,
521 })
522}
523
524#[derive(Clone)]
550#[non_exhaustive]
551pub struct Agent<M>
552where
553 M: CompletionModel,
554{
555 pub(crate) name: Option<String>,
557 pub(crate) description: Option<String>,
559 pub(crate) model: Arc<M>,
561 pub(crate) preamble: Option<String>,
563 pub(crate) static_context: Vec<Document>,
565 pub(crate) temperature: Option<f64>,
567 pub(crate) max_tokens: Option<u64>,
569 pub(crate) additional_params: Option<serde_json::Value>,
571 pub(crate) record_telemetry_content: bool,
578 pub(crate) tool_server_handle: ToolServerHandle,
579 pub(crate) tool_choice: Option<ToolChoice>,
581 pub(crate) default_max_turns: Option<usize>,
584 pub(crate) hooks: HookStack,
587 pub(crate) output_schema: Option<schemars::Schema>,
590 pub(crate) output_mode: OutputMode,
593 pub(crate) memory: Option<Arc<dyn rig_core::memory::ConversationMemory>>,
595 pub(crate) default_conversation_id: Option<String>,
597}
598
599impl<M> Agent<M>
600where
601 M: CompletionModel,
602{
603 pub fn name(&self) -> Option<&str> {
605 self.name.as_deref()
606 }
607
608 pub fn description(&self) -> Option<&str> {
610 self.description.as_deref()
611 }
612
613 pub(crate) fn name_or_default(&self) -> &str {
614 self.name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
615 }
616
617 pub fn runner(&self, prompt: impl Into<Message>) -> AgentRunner<M> {
621 AgentRunner::from_agent(self, prompt)
622 }
623
624 pub async fn tool_definitions(
629 &self,
630 prompt: Option<String>,
631 ) -> Result<Vec<ToolDefinition>, ToolServerError> {
632 self.tool_server_handle.get_tool_defs(prompt).await
633 }
634}
635
636#[allow(refining_impl_trait)]
644impl<M> Prompt for Agent<M>
645where
646 M: CompletionModel + 'static,
647{
648 fn prompt(
649 &self,
650 prompt: impl Into<Message> + WasmCompatSend,
651 ) -> PromptRequest<prompt_request::Standard, M> {
652 PromptRequest::from_agent(self, prompt)
653 }
654}
655
656#[allow(refining_impl_trait)]
657impl<M> Prompt for &Agent<M>
658where
659 M: CompletionModel + 'static,
660{
661 #[tracing::instrument(skip(self, prompt), fields(agent_name = self.name_or_default()))]
662 fn prompt(
663 &self,
664 prompt: impl Into<Message> + WasmCompatSend,
665 ) -> PromptRequest<prompt_request::Standard, M> {
666 PromptRequest::from_agent(*self, prompt)
667 }
668}
669
670#[allow(refining_impl_trait)]
671impl<M> Chat for Agent<M>
672where
673 M: CompletionModel + 'static,
674{
675 #[tracing::instrument(skip(self, prompt, chat_history), fields(agent_name = self.name_or_default()))]
676 async fn chat(
677 &self,
678 prompt: impl Into<Message> + WasmCompatSend,
679 chat_history: &mut Vec<Message>,
680 ) -> Result<String, PromptError> {
681 let response = PromptRequest::from_agent(self, prompt)
682 .history(chat_history.clone())
683 .extended_details()
684 .await?;
685
686 if let Some(messages) = response.messages {
687 chat_history.extend(messages);
688 }
689
690 Ok(response.output)
691 }
692}
693
694impl<M> StreamingPrompt<M, M::StreamingResponse> for Agent<M>
695where
696 M: CompletionModel + 'static,
697 M::StreamingResponse: GetTokenUsage,
698{
699 fn stream_prompt(
700 &self,
701 prompt: impl Into<Message> + WasmCompatSend,
702 ) -> StreamingPromptRequest<M> {
703 StreamingPromptRequest::<M>::from_agent(self, prompt)
704 }
705}
706
707impl<M> StreamingChat<M, M::StreamingResponse> for Agent<M>
708where
709 M: CompletionModel + 'static,
710 M::StreamingResponse: GetTokenUsage,
711{
712 fn stream_chat<I, T>(
713 &self,
714 prompt: impl Into<Message> + WasmCompatSend,
715 chat_history: I,
716 ) -> StreamingPromptRequest<M>
717 where
718 I: IntoIterator<Item = T>,
719 T: Into<Message>,
720 {
721 StreamingPromptRequest::<M>::from_agent(self, prompt).history(chat_history)
722 }
723}
724
725use crate::agent::prompt_request::TypedPromptRequest;
726use schemars::JsonSchema;
727use serde::de::DeserializeOwned;
728
729#[allow(refining_impl_trait)]
730impl<M> TypedPrompt for Agent<M>
731where
732 M: CompletionModel + 'static,
733{
734 type TypedRequest<T>
735 = TypedPromptRequest<T, prompt_request::Standard, M>
736 where
737 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
738
739 fn prompt_typed<T>(
772 &self,
773 prompt: impl Into<Message> + WasmCompatSend,
774 ) -> TypedPromptRequest<T, prompt_request::Standard, M>
775 where
776 T: JsonSchema + DeserializeOwned + WasmCompatSend,
777 {
778 TypedPromptRequest::from_agent(self, prompt)
779 }
780}
781
782#[allow(refining_impl_trait)]
783impl<M> TypedPrompt for &Agent<M>
784where
785 M: CompletionModel + 'static,
786{
787 type TypedRequest<T>
788 = TypedPromptRequest<T, prompt_request::Standard, M>
789 where
790 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
791
792 fn prompt_typed<T>(
793 &self,
794 prompt: impl Into<Message> + WasmCompatSend,
795 ) -> TypedPromptRequest<T, prompt_request::Standard, M>
796 where
797 T: JsonSchema + DeserializeOwned + WasmCompatSend,
798 {
799 TypedPromptRequest::from_agent(*self, prompt)
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806
807 fn tool_names(names: &[&str]) -> BTreeSet<String> {
808 names.iter().map(|name| (*name).to_string()).collect()
809 }
810
811 #[test]
812 fn allowed_tool_names_defaults_to_all_executable_tools() {
813 let executable = tool_names(&["add", "subtract"]);
814
815 assert_eq!(
816 allowed_tool_names_for_choice(&executable, None, None, None).unwrap(),
817 executable
818 );
819 }
820
821 #[test]
822 fn allowed_tool_names_auto_and_required_allow_all_executable_tools() {
823 let executable = tool_names(&["add", "subtract"]);
824
825 assert_eq!(
826 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Auto), None, None)
827 .unwrap(),
828 executable
829 );
830 assert_eq!(
831 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Required), None, None)
832 .unwrap(),
833 executable
834 );
835 }
836
837 #[test]
838 fn allowed_tool_names_none_allows_no_tools() {
839 let executable = tool_names(&["add", "subtract"]);
840
841 assert!(
842 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::None), None, None)
843 .unwrap()
844 .is_empty()
845 );
846 }
847
848 #[test]
849 fn allowed_tool_names_specific_allows_requested_executable_tools() {
850 let executable = tool_names(&["add", "subtract"]);
851 let choice = ToolChoice::Specific {
852 function_names: vec!["add".to_string()],
853 };
854
855 assert_eq!(
856 allowed_tool_names_for_choice(&executable, Some(&choice), None, None).unwrap(),
857 tool_names(&["add"])
858 );
859 }
860
861 #[test]
862 fn allowed_tool_names_specific_rejects_missing_tools() {
863 let executable = tool_names(&["add"]);
864 let choice = ToolChoice::Specific {
865 function_names: vec!["missing".to_string()],
866 };
867
868 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
869 .expect_err("missing specific tool should fail before provider request");
870
871 assert!(matches!(
872 err,
873 CompletionError::RequestError(err)
874 if err.to_string().contains("missing")
875 && err.to_string().contains("add")
876 ));
877 }
878
879 #[test]
880 fn allowed_tool_names_specific_rejects_empty_names() {
881 let executable = tool_names(&["add"]);
882 let choice = ToolChoice::Specific {
883 function_names: vec![],
884 };
885
886 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
887 .expect_err("empty specific tool choice should fail before provider request");
888
889 assert!(matches!(
890 err,
891 CompletionError::RequestError(err)
892 if err.to_string().contains("requires at least one function name")
893 ));
894 }
895
896 #[test]
897 fn output_tool_callable_honors_specific_naming_the_output_tool() {
898 assert!(output_tool_callable(None, "final_result"));
900 assert!(output_tool_callable(
901 Some(&ToolChoice::Auto),
902 "final_result"
903 ));
904 assert!(output_tool_callable(
905 Some(&ToolChoice::Required),
906 "final_result"
907 ));
908 assert!(output_tool_callable(
912 Some(&ToolChoice::Specific {
913 function_names: vec!["final_result".to_string()],
914 }),
915 "final_result",
916 ));
917 assert!(!output_tool_callable(
920 Some(&ToolChoice::Specific {
921 function_names: vec!["search".to_string()],
922 }),
923 "final_result",
924 ));
925 assert!(!output_tool_callable(
926 Some(&ToolChoice::None),
927 "final_result"
928 ));
929 }
930
931 #[test]
932 fn required_with_no_advertised_tool_is_local_error() {
933 let empty = tool_names(&[]);
934 let err = allowed_tool_names_for_choice(&empty, Some(&ToolChoice::Required), None, None)
935 .expect_err("Required with no advertised tool must fail locally");
936 assert!(matches!(
937 err,
938 CompletionError::RequestError(err) if err.to_string().contains("Required")
939 ));
940 }
941
942 #[test]
943 fn required_with_only_the_output_tool_is_allowed() {
944 let empty = tool_names(&[]);
947 let allowed = allowed_tool_names_for_choice(
948 &empty,
949 Some(&ToolChoice::Required),
950 Some("final_result"),
951 None,
952 )
953 .expect("Required is satisfiable by the output tool");
954 assert!(allowed.is_empty());
957 }
958
959 #[test]
960 fn required_with_active_tools_filter_names_the_filter_in_the_error() {
961 let empty = tool_names(&[]);
962 let err = allowed_tool_names_for_choice(
963 &empty,
964 Some(&ToolChoice::Required),
965 None,
966 Some(&tool_names(&["add"])),
967 )
968 .expect_err("Required after active_tools filtered everything must fail locally");
969 let msg = err.to_string();
970 assert!(
971 msg.contains("active_tools"),
972 "error should name active_tools: {msg}"
973 );
974 assert!(
975 msg.contains("RequestPatch"),
976 "error should suggest RequestPatch: {msg}"
977 );
978 }
979
980 #[test]
981 fn specific_naming_a_filtered_out_tool_is_a_local_error_with_hint() {
982 let executable = tool_names(&["add"]);
985 let choice = ToolChoice::Specific {
986 function_names: vec!["subtract".to_string()],
987 };
988 let err = allowed_tool_names_for_choice(
989 &executable,
990 Some(&choice),
991 None,
992 Some(&tool_names(&["add", "subtract"])),
993 )
994 .expect_err("Specific naming a filtered-out tool must fail locally");
995 let msg = err.to_string();
996 assert!(
997 msg.contains("subtract"),
998 "error should name the missing tool: {msg}"
999 );
1000 assert!(
1001 msg.contains("active_tools"),
1002 "error should name active_tools: {msg}"
1003 );
1004 }
1005
1006 #[test]
1007 fn specific_may_name_the_output_tool() {
1008 let empty = tool_names(&[]);
1010 let choice = ToolChoice::Specific {
1011 function_names: vec!["final_result".to_string()],
1012 };
1013 let allowed =
1014 allowed_tool_names_for_choice(&empty, Some(&choice), Some("final_result"), None)
1015 .expect("Specific naming the output tool is valid");
1016 assert_eq!(allowed, tool_names(&["final_result"]));
1017 }
1018
1019 #[test]
1020 fn specific_typo_is_not_blamed_on_active_tools() {
1021 let executable = tool_names(&["add"]);
1025 let choice = ToolChoice::Specific {
1026 function_names: vec!["nonexistent".to_string()],
1027 };
1028 let err = allowed_tool_names_for_choice(
1029 &executable,
1030 Some(&choice),
1031 None,
1032 Some(&tool_names(&["add"])),
1033 )
1034 .expect_err("Specific naming a non-existent tool must fail locally");
1035 let msg = err.to_string();
1036 assert!(msg.contains("nonexistent"), "error names the typo: {msg}");
1037 assert!(
1038 !msg.contains("active_tools"),
1039 "a plain typo must not be blamed on active_tools: {msg}"
1040 );
1041 }
1042
1043 #[test]
1044 fn resolve_output_mode_without_schema_is_always_native() {
1045 for requested in [
1047 OutputMode::Auto,
1048 OutputMode::Tool,
1049 OutputMode::Native,
1050 OutputMode::Prompted,
1051 ] {
1052 assert_eq!(
1053 resolve_output_mode(false, true, true, false, &requested),
1054 OutputMode::Native,
1055 "no schema should force Native for {requested:?}"
1056 );
1057 assert_eq!(
1058 resolve_output_mode(false, false, true, false, &requested),
1059 OutputMode::Native,
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn resolve_output_mode_auto_picks_tool_only_when_tools_present() {
1066 assert_eq!(
1070 resolve_output_mode(true, true, true, false, &OutputMode::Auto),
1071 OutputMode::Tool,
1072 );
1073 assert_eq!(
1075 resolve_output_mode(true, false, true, false, &OutputMode::Auto),
1076 OutputMode::Native,
1077 );
1078 }
1079
1080 #[test]
1081 fn resolve_output_mode_auto_keeps_native_when_provider_composes() {
1082 assert_eq!(
1085 resolve_output_mode(true, true, true, true, &OutputMode::Auto),
1086 OutputMode::Native,
1087 );
1088 }
1089
1090 #[test]
1091 fn resolve_output_mode_honors_explicit_choice_with_schema() {
1092 for (requested, expected) in [
1093 (OutputMode::Tool, OutputMode::Tool),
1094 (OutputMode::Native, OutputMode::Native),
1095 (OutputMode::Prompted, OutputMode::Prompted),
1096 ] {
1097 assert_eq!(
1099 resolve_output_mode(true, true, true, false, &requested),
1100 expected
1101 );
1102 assert_eq!(
1103 resolve_output_mode(true, false, true, true, &requested),
1104 expected
1105 );
1106 }
1107 }
1108
1109 #[test]
1110 fn resolve_output_mode_degrades_to_native_when_output_tool_not_callable() {
1111 assert_eq!(
1115 resolve_output_mode(true, true, false, false, &OutputMode::Auto),
1116 OutputMode::Native,
1117 );
1118 assert_eq!(
1119 resolve_output_mode(true, true, false, false, &OutputMode::Tool),
1120 OutputMode::Native,
1121 );
1122 assert_eq!(
1124 resolve_output_mode(true, true, false, false, &OutputMode::Prompted),
1125 OutputMode::Prompted,
1126 );
1127 }
1128
1129 #[test]
1130 fn tool_choice_permits_output_tool_only_for_auto_required_or_unset() {
1131 assert!(tool_choice_permits_output_tool(None));
1132 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Auto)));
1133 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Required)));
1134 assert!(!tool_choice_permits_output_tool(Some(&ToolChoice::None)));
1135 assert!(!tool_choice_permits_output_tool(Some(
1136 &ToolChoice::Specific {
1137 function_names: vec!["add".to_string()],
1138 }
1139 )));
1140 }
1141
1142 #[test]
1143 fn pick_output_tool_name_defaults_when_unused() {
1144 let executable = tool_names(&["add", "subtract"]);
1145 assert_eq!(pick_output_tool_name(&executable), DEFAULT_OUTPUT_TOOL_NAME);
1146 }
1147
1148 #[test]
1149 fn pick_output_tool_name_avoids_collision_with_real_tools() {
1150 let executable = tool_names(&["final_result"]);
1153 assert_eq!(pick_output_tool_name(&executable), "final_result_1");
1154
1155 let executable = tool_names(&["final_result", "final_result_1"]);
1156 assert_eq!(pick_output_tool_name(&executable), "final_result_2");
1157 }
1158}