1use super::hook::{HookStack, RequestPatch};
2use super::model::ModelHandle;
3use super::prompt_request::{self, PromptRequest};
4use super::run::OutputMode;
5use super::runner::AgentRunner;
6use crate::{
7 agent::prompt_request::streaming::StreamingPromptRequest,
8 completion::{
9 Chat, CompletionError, CompletionModel, CompletionRequestBuilder, Document, Message,
10 Prompt, PromptError, ToolDefinition, TypedPrompt,
11 },
12 json_utils,
13 streaming::{StreamingChat, StreamingPrompt},
14 tool::server::{ToolRegistrySnapshot, ToolServerError, ToolServerHandle},
15};
16use rig_core::{message::ToolChoice, wasm_compat::WasmCompatSend};
17use std::{collections::BTreeSet, sync::Arc};
18
19use super::UNKNOWN_AGENT_NAME;
20
21pub(crate) struct PreparedCompletionRequest {
24 pub(crate) builder: CompletionRequestBuilder<ModelHandle>,
28 pub(crate) tool_snapshot: Arc<ToolRegistrySnapshot>,
30 pub(crate) executable_tool_names: BTreeSet<String>,
31 pub(crate) allowed_tool_names: BTreeSet<String>,
32 pub(crate) output_tool_name: Option<String>,
35 pub(crate) max_tokens: Option<u64>,
48}
49
50const DEFAULT_OUTPUT_TOOL_NAME: &str = "final_result";
52
53fn tool_choice_permits_output_tool(tool_choice: Option<&ToolChoice>) -> bool {
58 matches!(
59 tool_choice,
60 None | Some(ToolChoice::Auto | ToolChoice::Required)
61 )
62}
63
64fn output_tool_callable(tool_choice: Option<&ToolChoice>, output_tool_name: &str) -> bool {
74 match tool_choice {
75 Some(ToolChoice::Specific { function_names }) => function_names
76 .iter()
77 .any(|name| name.as_str() == output_tool_name),
78 other => tool_choice_permits_output_tool(other),
79 }
80}
81
82fn resolve_output_mode(
97 has_schema: bool,
98 has_executable_tools: bool,
99 output_tool_callable: bool,
100 provider_composes_native: bool,
101 requested: &OutputMode,
102) -> OutputMode {
103 if !has_schema {
104 return OutputMode::Native;
105 }
106 match requested {
107 OutputMode::Native => OutputMode::Native,
108 OutputMode::Prompted => OutputMode::Prompted,
109 OutputMode::Tool if output_tool_callable => OutputMode::Tool,
110 OutputMode::Tool => OutputMode::Native,
111 OutputMode::Auto
112 if has_executable_tools && output_tool_callable && !provider_composes_native =>
113 {
114 OutputMode::Tool
115 }
116 OutputMode::Auto => OutputMode::Native,
117 }
118}
119
120fn pick_output_tool_name(executable_tool_names: &BTreeSet<String>) -> String {
123 let mut name = DEFAULT_OUTPUT_TOOL_NAME.to_string();
124 let mut suffix = 1u32;
125 while executable_tool_names.contains(&name) {
126 name = format!("{DEFAULT_OUTPUT_TOOL_NAME}_{suffix}");
127 suffix += 1;
128 }
129 name
130}
131
132pub(crate) fn allowed_tool_names_for_choice(
152 executable_tool_names: &BTreeSet<String>,
153 tool_choice: Option<&ToolChoice>,
154 output_tool_name: Option<&str>,
155 pre_filter_tool_names: Option<&BTreeSet<String>>,
156) -> Result<BTreeSet<String>, CompletionError> {
157 let has_advertised_tool = !executable_tool_names.is_empty() || output_tool_name.is_some();
158 let hint = |active_tools_caused: bool| {
159 if active_tools_caused {
160 " A per-turn `active_tools` allow-list narrowed the advertised tools this turn; \
161 set a compatible `tool_choice` in the same `RequestPatch`, or widen `active_tools`."
162 } else {
163 ""
164 }
165 };
166 let advertised = || {
168 executable_tool_names
169 .iter()
170 .map(String::as_str)
171 .chain(output_tool_name)
172 .collect::<Vec<_>>()
173 };
174
175 let allowed = match tool_choice {
176 None | Some(ToolChoice::Auto) => executable_tool_names.clone(),
177 Some(ToolChoice::Required) => {
178 if !has_advertised_tool {
179 let active_tools_caused = pre_filter_tool_names.is_some_and(|pf| !pf.is_empty());
181 return Err(CompletionError::RequestError(
182 format!(
183 "ToolChoice::Required forces the model to call a tool, but no tools are \
184 advertised this turn.{}",
185 hint(active_tools_caused)
186 )
187 .into(),
188 ));
189 }
190 executable_tool_names.clone()
191 }
192 Some(ToolChoice::None) => BTreeSet::new(),
193 Some(ToolChoice::Specific { function_names }) => {
194 if function_names.is_empty() {
195 return Err(CompletionError::RequestError(
196 "ToolChoice::Specific requires at least one function name".into(),
197 ));
198 }
199
200 let requested = function_names.iter().cloned().collect::<BTreeSet<String>>();
201 let missing = function_names
202 .iter()
203 .map(String::as_str)
204 .filter(|name| {
205 !executable_tool_names.contains(*name) && Some(*name) != output_tool_name
206 })
207 .collect::<Vec<_>>();
208
209 if !missing.is_empty() {
210 let active_tools_caused = pre_filter_tool_names
213 .is_some_and(|pf| missing.iter().any(|name| pf.contains(*name)));
214 return Err(CompletionError::RequestError(
215 format!(
216 "ToolChoice::Specific requested tool names not advertised this turn: \
217 {missing:?}. Advertised: {:?}.{}",
218 advertised(),
219 hint(active_tools_caused)
220 )
221 .into(),
222 ));
223 }
224
225 requested
226 }
227 };
228
229 Ok(allowed)
230}
231
232pub(crate) async fn build_prepared_completion_request(
238 runner: &crate::agent::AgentRunner,
239 model: &ModelHandle,
240 prompt: Message,
241 chat_history: &[Message],
242 committed_output_tool: Option<&str>,
243 request_patch: Option<&RequestPatch>,
244) -> Result<PreparedCompletionRequest, CompletionError> {
245 let preamble = runner.config.preamble.as_deref();
246 let static_context = &runner.config.static_context;
247 let temperature = runner.config.temperature;
248 let max_tokens = runner.config.max_tokens;
249 let additional_params = runner.config.additional_params.as_ref();
250 let record_telemetry_content = runner.config.record_telemetry_content;
251 let tool_choice = runner.config.tool_choice.as_ref();
252 let tool_server_handle = &runner.tool_server_handle;
253 let output_schema = runner.config.output_schema.as_ref();
254 let output_mode = &runner.config.output_mode;
255 let output_tool_description = runner.output_tool_description.as_deref();
256 let augment_output_preamble = runner.augment_output_preamble;
257 let preamble = request_patch
263 .and_then(|o| o.preamble.as_deref())
264 .or(preamble);
265 let temperature = request_patch.and_then(|o| o.temperature).or(temperature);
266 let max_tokens = request_patch.and_then(|o| o.max_tokens).or(max_tokens);
267 let tool_choice = request_patch
268 .and_then(|o| o.tool_choice.as_ref())
269 .or(tool_choice);
270 let additional_params: Option<serde_json::Value> = match (
277 additional_params,
278 request_patch.and_then(|o| o.additional_params.as_ref()),
279 ) {
280 (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
281 Some(json_utils::merge(base.clone(), patch.clone()))
282 }
283 (base, patch) => patch.or(base).cloned(),
284 };
285 let active_tools = request_patch.and_then(|o| o.active_tools.as_deref());
286
287 let retrieval_query = prompt.rag_text().or_else(|| {
290 chat_history
291 .iter()
292 .rev()
293 .find_map(|message| message.rag_text())
294 });
295
296 let mut tool_snapshot = tool_server_handle
297 .snapshot_tool_defs(retrieval_query)
298 .await
299 .map_err(|_| CompletionError::RequestError("Failed to get tool definitions".into()))?;
300
301 let pre_filter_tool_names: Option<BTreeSet<String>> = active_tools.map(|_| {
310 tool_snapshot
311 .definitions()
312 .iter()
313 .map(|tool| tool.name.clone())
314 .collect()
315 });
316
317 if let Some(allow) = active_tools {
325 if let Some(missing) = allow.iter().find(|name| {
326 !tool_snapshot
327 .definitions()
328 .iter()
329 .any(|tool| &tool.name == *name)
330 }) {
331 return Err(CompletionError::RequestError(
332 format!(
333 "active_tools requested tool `{missing}`, which is not available this turn"
334 )
335 .into(),
336 ));
337 }
338 let allowed: BTreeSet<String> = allow.iter().cloned().collect();
339 tool_snapshot.retain_names(&allowed);
340 }
341
342 let mut tooldefs = tool_snapshot.definitions().to_vec();
343
344 let executable_tool_names: BTreeSet<String> =
347 tooldefs.iter().map(|tool| tool.name.clone()).collect();
348
349 let resolved_mode = if committed_output_tool.is_some() && output_schema.is_some() {
360 OutputMode::Tool
361 } else {
362 resolve_output_mode(
363 output_schema.is_some(),
364 !executable_tool_names.is_empty(),
365 tool_choice_permits_output_tool(tool_choice),
366 model.capabilities().composes_native_output_with_tools,
367 output_mode,
368 )
369 };
370
371 let output_tool_name = matches!(resolved_mode, OutputMode::Tool).then(|| {
374 committed_output_tool.map(str::to_owned).unwrap_or_else(|| {
375 pick_output_tool_name(
376 pre_filter_tool_names
377 .as_ref()
378 .unwrap_or(&executable_tool_names),
379 )
380 })
381 });
382
383 if let Some(name) = &output_tool_name
390 && executable_tool_names.contains(name)
391 {
392 return Err(CompletionError::RequestError(
393 format!(
394 "real tool `{name}` conflicts with the structured-output tool reserved for this \
395 run; rename or remove the real tool, exclude it with `active_tools`, or make it \
396 visible before starting a new run so Rig can reserve a different output-tool name"
397 )
398 .into(),
399 ));
400 }
401
402 if let Some(name) = &output_tool_name
413 && !output_tool_callable(tool_choice, name)
414 {
415 tracing::warn!(
416 "the active tool_choice forbids calling the structured-output tool while the \
417 run is pinned to Tool output mode; this turn cannot emit the structured \
418 result (check for a `RequestPatch` setting `tool_choice` to None or a \
419 Specific set that excludes the output tool)"
420 );
421 }
422
423 let effective_preamble: Option<String> = {
426 let base = preamble.map(str::to_owned);
427 let instruction = match &resolved_mode {
428 OutputMode::Tool if augment_output_preamble => {
429 output_tool_name.as_deref().map(|name| {
430 format!(
431 "When you have gathered enough information to answer, call the `{name}` \
432 tool exactly once with your final answer. Its arguments are the structured \
433 result and must satisfy the required schema. Do not return the final answer \
434 as plain text."
435 )
436 })
437 }
438 OutputMode::Tool => None,
439 OutputMode::Prompted => output_schema.map(|schema| {
440 let schema_json = serde_json::to_string(schema.as_value()).unwrap_or_default();
441 format!(
442 "Respond with ONLY a single JSON object that conforms to this JSON Schema. \
443 Do not include any prose, explanation, or markdown code fences.\n{schema_json}"
444 )
445 }),
446 OutputMode::Native | OutputMode::Auto => None,
447 };
448 match (base, instruction) {
449 (Some(b), Some(i)) => Some(format!("{b}\n\n{i}")),
450 (Some(b), None) => Some(b),
451 (None, Some(i)) => Some(i),
452 (None, None) => None,
453 }
454 };
455
456 let messages_history: &[Message] = request_patch
461 .and_then(|o| o.history.as_deref())
462 .unwrap_or(chat_history);
463 let chat_history: Vec<Message> = if let Some(preamble) = &effective_preamble {
464 std::iter::once(Message::system(preamble.clone()))
465 .chain(messages_history.iter().cloned())
466 .collect()
467 } else {
468 messages_history.to_vec()
469 };
470
471 if let (Some(name), Some(schema)) = (&output_tool_name, output_schema) {
477 tooldefs.push(crate::completion::ToolDefinition {
478 name: name.clone(),
479 description: output_tool_description
480 .unwrap_or(
481 "Call this tool exactly once with your final answer when you are done. \
482 Its arguments are the structured result and must satisfy the output schema.",
483 )
484 .to_string(),
485 parameters: schema.clone().to_value(),
486 });
487 }
488
489 let mut completion_request = model
490 .completion_request(prompt)
491 .messages(chat_history)
492 .temperature_opt(temperature)
493 .max_tokens_opt(max_tokens)
494 .additional_params_opt(additional_params)
495 .record_content_telemetry(record_telemetry_content)
496 .documents(static_context.to_vec())
497 .tools(tooldefs);
498
499 if let Some(patch) = request_patch
503 && !patch.extra_context.is_empty()
504 {
505 completion_request = completion_request.documents(patch.extra_context.clone());
506 }
507
508 if matches!(resolved_mode, OutputMode::Native) {
510 completion_request = completion_request.output_schema_opt(output_schema.cloned());
511 }
512
513 let completion_request = if let Some(tool_choice) = tool_choice {
514 completion_request.tool_choice(tool_choice.clone())
515 } else {
516 completion_request
517 };
518
519 let mut allowed_tool_names = allowed_tool_names_for_choice(
524 &executable_tool_names,
525 tool_choice,
526 output_tool_name.as_deref(),
527 pre_filter_tool_names.as_ref(),
528 )?;
529 if let Some(name) = &output_tool_name {
532 allowed_tool_names.insert(name.clone());
533 }
534
535 Ok(PreparedCompletionRequest {
536 builder: completion_request,
537 tool_snapshot: Arc::new(tool_snapshot),
538 executable_tool_names,
539 allowed_tool_names,
540 output_tool_name,
541 max_tokens,
544 })
545}
546
547#[derive(Clone)]
573pub struct Agent {
574 pub(crate) config: AgentConfig,
575 pub(crate) tool_server_handle: ToolServerHandle,
576}
577
578#[derive(Clone)]
585pub(crate) struct AgentConfig {
586 pub(crate) name: Option<String>,
588 pub(crate) description: Option<String>,
590 pub(crate) model: ModelHandle,
592 pub(crate) preamble: Option<String>,
594 pub(crate) static_context: Vec<Document>,
596 pub(crate) additional_params: Option<serde_json::Value>,
598 pub(crate) record_telemetry_content: bool,
605 pub(crate) max_tokens: Option<u64>,
607 pub(crate) temperature: Option<f64>,
609 pub(crate) tool_choice: Option<ToolChoice>,
611 pub(crate) max_turns: usize,
614 pub(crate) hooks: HookStack,
617 pub(crate) output_schema: Option<schemars::Schema>,
620 pub(crate) output_mode: OutputMode,
623 pub(crate) memory: Option<Arc<dyn rig_core::memory::ConversationMemory>>,
625 pub(crate) conversation_id: Option<String>,
627}
628
629impl AgentConfig {
630 pub(crate) fn new(model: ModelHandle) -> Self {
632 Self {
633 name: None,
634 description: None,
635 model,
636 preamble: None,
637 static_context: vec![],
638 additional_params: None,
639 record_telemetry_content: false,
640 max_tokens: None,
641 temperature: None,
642 tool_choice: None,
643 max_turns: 1,
644 hooks: HookStack::new(),
645 output_schema: None,
646 output_mode: OutputMode::default(),
647 memory: None,
648 conversation_id: None,
649 }
650 }
651}
652
653impl Agent {
654 pub fn name(&self) -> Option<&str> {
656 self.config.name.as_deref()
657 }
658
659 pub fn description(&self) -> Option<&str> {
661 self.config.description.as_deref()
662 }
663
664 pub(crate) fn name_or_default(&self) -> &str {
665 self.name().unwrap_or(UNKNOWN_AGENT_NAME)
666 }
667
668 pub fn runner(&self, prompt: impl Into<Message>) -> AgentRunner {
672 AgentRunner::from_agent(self, prompt)
673 }
674
675 pub fn model_handle(&self) -> &ModelHandle {
677 &self.config.model
678 }
679
680 pub fn set_model_handle(&mut self, model: ModelHandle) {
686 self.config.model = model;
687 }
688
689 pub fn set_model<M>(&mut self, model: M)
691 where
692 M: CompletionModel + 'static,
693 {
694 self.set_model_handle(ModelHandle::new(model));
695 }
696
697 pub fn with_model_handle(mut self, model: ModelHandle) -> Self {
701 self.set_model_handle(model);
702 self
703 }
704
705 pub fn with_model<M>(mut self, model: M) -> Self
707 where
708 M: CompletionModel + 'static,
709 {
710 self.set_model(model);
711 self
712 }
713
714 pub async fn tool_definitions(
719 &self,
720 prompt: Option<String>,
721 ) -> Result<Vec<ToolDefinition>, ToolServerError> {
722 self.tool_server_handle.get_tool_defs(prompt).await
723 }
724}
725
726#[allow(refining_impl_trait)]
734impl Prompt for Agent {
735 fn prompt(
736 &self,
737 prompt: impl Into<Message> + WasmCompatSend,
738 ) -> PromptRequest<prompt_request::Standard> {
739 PromptRequest::from_agent(self, prompt)
740 }
741}
742
743#[allow(refining_impl_trait)]
744impl Prompt for &Agent {
745 #[tracing::instrument(skip(self, prompt), fields(agent_name = self.name_or_default()))]
746 fn prompt(
747 &self,
748 prompt: impl Into<Message> + WasmCompatSend,
749 ) -> PromptRequest<prompt_request::Standard> {
750 PromptRequest::from_agent(self, prompt)
751 }
752}
753
754#[allow(refining_impl_trait)]
755impl Chat for Agent {
756 #[tracing::instrument(skip(self, prompt, chat_history), fields(agent_name = self.name_or_default()))]
757 async fn chat(
758 &self,
759 prompt: impl Into<Message> + WasmCompatSend,
760 chat_history: &mut Vec<Message>,
761 ) -> Result<String, PromptError> {
762 let response = PromptRequest::from_agent(self, prompt)
763 .history(chat_history.clone())
764 .extended_details()
765 .await?;
766
767 if let Some(messages) = response.messages {
768 chat_history.extend(messages);
769 }
770
771 Ok(response.output)
772 }
773}
774
775impl StreamingPrompt for Agent {
776 fn stream_prompt(&self, prompt: impl Into<Message> + WasmCompatSend) -> StreamingPromptRequest {
777 StreamingPromptRequest::from_agent(self, prompt)
778 }
779}
780
781impl StreamingChat for Agent {
782 fn stream_chat<I, T>(
783 &self,
784 prompt: impl Into<Message> + WasmCompatSend,
785 chat_history: I,
786 ) -> StreamingPromptRequest
787 where
788 I: IntoIterator<Item = T>,
789 T: Into<Message>,
790 {
791 StreamingPromptRequest::from_agent(self, prompt).history(chat_history)
792 }
793}
794
795use crate::agent::prompt_request::TypedPromptRequest;
796use schemars::JsonSchema;
797use serde::de::DeserializeOwned;
798
799#[allow(refining_impl_trait)]
800impl TypedPrompt for Agent {
801 type TypedRequest<T>
802 = TypedPromptRequest<T, prompt_request::Standard>
803 where
804 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
805
806 fn prompt_typed<T>(
839 &self,
840 prompt: impl Into<Message> + WasmCompatSend,
841 ) -> TypedPromptRequest<T, prompt_request::Standard>
842 where
843 T: JsonSchema + DeserializeOwned + WasmCompatSend,
844 {
845 TypedPromptRequest::from_agent(self, prompt)
846 }
847}
848
849#[allow(refining_impl_trait)]
850impl TypedPrompt for &Agent {
851 type TypedRequest<T>
852 = TypedPromptRequest<T, prompt_request::Standard>
853 where
854 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
855
856 fn prompt_typed<T>(
857 &self,
858 prompt: impl Into<Message> + WasmCompatSend,
859 ) -> TypedPromptRequest<T, prompt_request::Standard>
860 where
861 T: JsonSchema + DeserializeOwned + WasmCompatSend,
862 {
863 TypedPromptRequest::from_agent(self, prompt)
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 fn tool_names(names: &[&str]) -> BTreeSet<String> {
872 names.iter().map(|name| (*name).to_string()).collect()
873 }
874
875 #[test]
876 fn allowed_tool_names_defaults_to_all_executable_tools() {
877 let executable = tool_names(&["add", "subtract"]);
878
879 assert_eq!(
880 allowed_tool_names_for_choice(&executable, None, None, None).unwrap(),
881 executable
882 );
883 }
884
885 #[test]
886 fn allowed_tool_names_auto_and_required_allow_all_executable_tools() {
887 let executable = tool_names(&["add", "subtract"]);
888
889 assert_eq!(
890 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Auto), None, None)
891 .unwrap(),
892 executable
893 );
894 assert_eq!(
895 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Required), None, None)
896 .unwrap(),
897 executable
898 );
899 }
900
901 #[test]
902 fn allowed_tool_names_none_allows_no_tools() {
903 let executable = tool_names(&["add", "subtract"]);
904
905 assert!(
906 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::None), None, None)
907 .unwrap()
908 .is_empty()
909 );
910 }
911
912 #[test]
913 fn allowed_tool_names_specific_allows_requested_executable_tools() {
914 let executable = tool_names(&["add", "subtract"]);
915 let choice = ToolChoice::Specific {
916 function_names: vec!["add".to_string()],
917 };
918
919 assert_eq!(
920 allowed_tool_names_for_choice(&executable, Some(&choice), None, None).unwrap(),
921 tool_names(&["add"])
922 );
923 }
924
925 #[test]
926 fn allowed_tool_names_specific_rejects_missing_tools() {
927 let executable = tool_names(&["add"]);
928 let choice = ToolChoice::Specific {
929 function_names: vec!["missing".to_string()],
930 };
931
932 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
933 .expect_err("missing specific tool should fail before provider request");
934
935 assert!(matches!(
936 err,
937 CompletionError::RequestError(err)
938 if err.to_string().contains("missing")
939 && err.to_string().contains("add")
940 ));
941 }
942
943 #[test]
944 fn allowed_tool_names_specific_rejects_empty_names() {
945 let executable = tool_names(&["add"]);
946 let choice = ToolChoice::Specific {
947 function_names: vec![],
948 };
949
950 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
951 .expect_err("empty specific tool choice should fail before provider request");
952
953 assert!(matches!(
954 err,
955 CompletionError::RequestError(err)
956 if err.to_string().contains("requires at least one function name")
957 ));
958 }
959
960 #[test]
961 fn output_tool_callable_honors_specific_naming_the_output_tool() {
962 assert!(output_tool_callable(None, "final_result"));
964 assert!(output_tool_callable(
965 Some(&ToolChoice::Auto),
966 "final_result"
967 ));
968 assert!(output_tool_callable(
969 Some(&ToolChoice::Required),
970 "final_result"
971 ));
972 assert!(output_tool_callable(
976 Some(&ToolChoice::Specific {
977 function_names: vec!["final_result".to_string()],
978 }),
979 "final_result",
980 ));
981 assert!(!output_tool_callable(
984 Some(&ToolChoice::Specific {
985 function_names: vec!["search".to_string()],
986 }),
987 "final_result",
988 ));
989 assert!(!output_tool_callable(
990 Some(&ToolChoice::None),
991 "final_result"
992 ));
993 }
994
995 #[test]
996 fn required_with_no_advertised_tool_is_local_error() {
997 let empty = tool_names(&[]);
998 let err = allowed_tool_names_for_choice(&empty, Some(&ToolChoice::Required), None, None)
999 .expect_err("Required with no advertised tool must fail locally");
1000 assert!(matches!(
1001 err,
1002 CompletionError::RequestError(err) if err.to_string().contains("Required")
1003 ));
1004 }
1005
1006 #[test]
1007 fn required_with_only_the_output_tool_is_allowed() {
1008 let empty = tool_names(&[]);
1011 let allowed = allowed_tool_names_for_choice(
1012 &empty,
1013 Some(&ToolChoice::Required),
1014 Some("final_result"),
1015 None,
1016 )
1017 .expect("Required is satisfiable by the output tool");
1018 assert!(allowed.is_empty());
1021 }
1022
1023 #[test]
1024 fn required_with_active_tools_filter_names_the_filter_in_the_error() {
1025 let empty = tool_names(&[]);
1026 let err = allowed_tool_names_for_choice(
1027 &empty,
1028 Some(&ToolChoice::Required),
1029 None,
1030 Some(&tool_names(&["add"])),
1031 )
1032 .expect_err("Required after active_tools filtered everything must fail locally");
1033 let msg = err.to_string();
1034 assert!(
1035 msg.contains("active_tools"),
1036 "error should name active_tools: {msg}"
1037 );
1038 assert!(
1039 msg.contains("RequestPatch"),
1040 "error should suggest RequestPatch: {msg}"
1041 );
1042 }
1043
1044 #[test]
1045 fn specific_naming_a_filtered_out_tool_is_a_local_error_with_hint() {
1046 let executable = tool_names(&["add"]);
1049 let choice = ToolChoice::Specific {
1050 function_names: vec!["subtract".to_string()],
1051 };
1052 let err = allowed_tool_names_for_choice(
1053 &executable,
1054 Some(&choice),
1055 None,
1056 Some(&tool_names(&["add", "subtract"])),
1057 )
1058 .expect_err("Specific naming a filtered-out tool must fail locally");
1059 let msg = err.to_string();
1060 assert!(
1061 msg.contains("subtract"),
1062 "error should name the missing tool: {msg}"
1063 );
1064 assert!(
1065 msg.contains("active_tools"),
1066 "error should name active_tools: {msg}"
1067 );
1068 }
1069
1070 #[test]
1071 fn specific_may_name_the_output_tool() {
1072 let empty = tool_names(&[]);
1074 let choice = ToolChoice::Specific {
1075 function_names: vec!["final_result".to_string()],
1076 };
1077 let allowed =
1078 allowed_tool_names_for_choice(&empty, Some(&choice), Some("final_result"), None)
1079 .expect("Specific naming the output tool is valid");
1080 assert_eq!(allowed, tool_names(&["final_result"]));
1081 }
1082
1083 #[test]
1084 fn specific_typo_is_not_blamed_on_active_tools() {
1085 let executable = tool_names(&["add"]);
1089 let choice = ToolChoice::Specific {
1090 function_names: vec!["nonexistent".to_string()],
1091 };
1092 let err = allowed_tool_names_for_choice(
1093 &executable,
1094 Some(&choice),
1095 None,
1096 Some(&tool_names(&["add"])),
1097 )
1098 .expect_err("Specific naming a non-existent tool must fail locally");
1099 let msg = err.to_string();
1100 assert!(msg.contains("nonexistent"), "error names the typo: {msg}");
1101 assert!(
1102 !msg.contains("active_tools"),
1103 "a plain typo must not be blamed on active_tools: {msg}"
1104 );
1105 }
1106
1107 #[test]
1108 fn resolve_output_mode_without_schema_is_always_native() {
1109 for requested in [
1111 OutputMode::Auto,
1112 OutputMode::Tool,
1113 OutputMode::Native,
1114 OutputMode::Prompted,
1115 ] {
1116 assert_eq!(
1117 resolve_output_mode(false, true, true, false, &requested),
1118 OutputMode::Native,
1119 "no schema should force Native for {requested:?}"
1120 );
1121 assert_eq!(
1122 resolve_output_mode(false, false, true, false, &requested),
1123 OutputMode::Native,
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn resolve_output_mode_auto_picks_tool_only_when_tools_present() {
1130 assert_eq!(
1134 resolve_output_mode(true, true, true, false, &OutputMode::Auto),
1135 OutputMode::Tool,
1136 );
1137 assert_eq!(
1139 resolve_output_mode(true, false, true, false, &OutputMode::Auto),
1140 OutputMode::Native,
1141 );
1142 }
1143
1144 #[test]
1145 fn resolve_output_mode_auto_keeps_native_when_provider_composes() {
1146 assert_eq!(
1149 resolve_output_mode(true, true, true, true, &OutputMode::Auto),
1150 OutputMode::Native,
1151 );
1152 }
1153
1154 #[test]
1155 fn resolve_output_mode_honors_explicit_choice_with_schema() {
1156 for (requested, expected) in [
1157 (OutputMode::Tool, OutputMode::Tool),
1158 (OutputMode::Native, OutputMode::Native),
1159 (OutputMode::Prompted, OutputMode::Prompted),
1160 ] {
1161 assert_eq!(
1163 resolve_output_mode(true, true, true, false, &requested),
1164 expected
1165 );
1166 assert_eq!(
1167 resolve_output_mode(true, false, true, true, &requested),
1168 expected
1169 );
1170 }
1171 }
1172
1173 #[test]
1174 fn resolve_output_mode_degrades_to_native_when_output_tool_not_callable() {
1175 assert_eq!(
1179 resolve_output_mode(true, true, false, false, &OutputMode::Auto),
1180 OutputMode::Native,
1181 );
1182 assert_eq!(
1183 resolve_output_mode(true, true, false, false, &OutputMode::Tool),
1184 OutputMode::Native,
1185 );
1186 assert_eq!(
1188 resolve_output_mode(true, true, false, false, &OutputMode::Prompted),
1189 OutputMode::Prompted,
1190 );
1191 }
1192
1193 #[test]
1194 fn tool_choice_permits_output_tool_only_for_auto_required_or_unset() {
1195 assert!(tool_choice_permits_output_tool(None));
1196 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Auto)));
1197 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Required)));
1198 assert!(!tool_choice_permits_output_tool(Some(&ToolChoice::None)));
1199 assert!(!tool_choice_permits_output_tool(Some(
1200 &ToolChoice::Specific {
1201 function_names: vec!["add".to_string()],
1202 }
1203 )));
1204 }
1205
1206 #[test]
1207 fn pick_output_tool_name_defaults_when_unused() {
1208 let executable = tool_names(&["add", "subtract"]);
1209 assert_eq!(pick_output_tool_name(&executable), DEFAULT_OUTPUT_TOOL_NAME);
1210 }
1211
1212 #[test]
1213 fn pick_output_tool_name_avoids_collision_with_real_tools() {
1214 let executable = tool_names(&["final_result"]);
1217 assert_eq!(pick_output_tool_name(&executable), "final_result_1");
1218
1219 let executable = tool_names(&["final_result", "final_result_1"]);
1220 assert_eq!(pick_output_tool_name(&executable), "final_result_2");
1221 }
1222}