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, Completion, CompletionError, CompletionModel, CompletionRequestBuilder, Document,
9 GetTokenUsage, Message, Prompt, PromptError, TypedPrompt,
10 },
11 json_utils,
12 message::ToolChoice,
13 streaming::{StreamingChat, StreamingCompletion, StreamingPrompt},
14 tool::server::ToolServerHandle,
15 vector_store::{VectorStoreError, request::VectorSearchRequest},
16 wasm_compat::WasmCompatSend,
17};
18use std::{
19 collections::{BTreeSet, HashMap},
20 sync::Arc,
21};
22
23use super::UNKNOWN_AGENT_NAME;
24
25pub type DynamicContextStore = Arc<
26 Vec<(
27 usize,
28 Arc<dyn crate::vector_store::VectorStoreIndexDyn + Send + Sync>,
29 )>,
30>;
31
32pub(crate) struct PreparedCompletionRequest<M: CompletionModel> {
35 pub(crate) builder: CompletionRequestBuilder<M>,
36 pub(crate) executable_tool_names: BTreeSet<String>,
37 pub(crate) allowed_tool_names: BTreeSet<String>,
38 pub(crate) output_tool_name: Option<String>,
41}
42
43const DEFAULT_OUTPUT_TOOL_NAME: &str = "final_result";
45
46fn tool_choice_permits_output_tool(tool_choice: Option<&ToolChoice>) -> bool {
51 matches!(
52 tool_choice,
53 None | Some(ToolChoice::Auto | ToolChoice::Required)
54 )
55}
56
57fn output_tool_callable(tool_choice: Option<&ToolChoice>, output_tool_name: &str) -> bool {
67 match tool_choice {
68 Some(ToolChoice::Specific { function_names }) => function_names
69 .iter()
70 .any(|name| name.as_str() == output_tool_name),
71 other => tool_choice_permits_output_tool(other),
72 }
73}
74
75fn resolve_output_mode(
90 has_schema: bool,
91 has_executable_tools: bool,
92 output_tool_callable: bool,
93 provider_composes_native: bool,
94 requested: &OutputMode,
95) -> OutputMode {
96 if !has_schema {
97 return OutputMode::Native;
98 }
99 match requested {
100 OutputMode::Native => OutputMode::Native,
101 OutputMode::Prompted => OutputMode::Prompted,
102 OutputMode::Tool if output_tool_callable => OutputMode::Tool,
103 OutputMode::Tool => OutputMode::Native,
104 OutputMode::Auto
105 if has_executable_tools && output_tool_callable && !provider_composes_native =>
106 {
107 OutputMode::Tool
108 }
109 OutputMode::Auto => OutputMode::Native,
110 }
111}
112
113fn pick_output_tool_name(executable_tool_names: &BTreeSet<String>) -> String {
116 let mut name = DEFAULT_OUTPUT_TOOL_NAME.to_string();
117 let mut suffix = 1u32;
118 while executable_tool_names.contains(&name) {
119 name = format!("{DEFAULT_OUTPUT_TOOL_NAME}_{suffix}");
120 suffix += 1;
121 }
122 name
123}
124
125pub(crate) fn allowed_tool_names_for_choice(
145 executable_tool_names: &BTreeSet<String>,
146 tool_choice: Option<&ToolChoice>,
147 output_tool_name: Option<&str>,
148 pre_filter_tool_names: Option<&BTreeSet<String>>,
149) -> Result<BTreeSet<String>, CompletionError> {
150 let has_advertised_tool = !executable_tool_names.is_empty() || output_tool_name.is_some();
151 let hint = |active_tools_caused: bool| {
152 if active_tools_caused {
153 " A per-turn `active_tools` allow-list narrowed the advertised tools this turn; \
154 set a compatible `tool_choice` in the same `RequestPatch`, or widen `active_tools`."
155 } else {
156 ""
157 }
158 };
159 let advertised = || {
161 executable_tool_names
162 .iter()
163 .map(String::as_str)
164 .chain(output_tool_name)
165 .collect::<Vec<_>>()
166 };
167
168 let allowed = match tool_choice {
169 None | Some(ToolChoice::Auto) => executable_tool_names.clone(),
170 Some(ToolChoice::Required) => {
171 if !has_advertised_tool {
172 let active_tools_caused = pre_filter_tool_names.is_some_and(|pf| !pf.is_empty());
174 return Err(CompletionError::RequestError(
175 format!(
176 "ToolChoice::Required forces the model to call a tool, but no tools are \
177 advertised this turn.{}",
178 hint(active_tools_caused)
179 )
180 .into(),
181 ));
182 }
183 executable_tool_names.clone()
184 }
185 Some(ToolChoice::None) => BTreeSet::new(),
186 Some(ToolChoice::Specific { function_names }) => {
187 if function_names.is_empty() {
188 return Err(CompletionError::RequestError(
189 "ToolChoice::Specific requires at least one function name".into(),
190 ));
191 }
192
193 let requested = function_names.iter().cloned().collect::<BTreeSet<String>>();
194 let missing = function_names
195 .iter()
196 .map(String::as_str)
197 .filter(|name| {
198 !executable_tool_names.contains(*name) && Some(*name) != output_tool_name
199 })
200 .collect::<Vec<_>>();
201
202 if !missing.is_empty() {
203 let active_tools_caused = pre_filter_tool_names
206 .is_some_and(|pf| missing.iter().any(|name| pf.contains(*name)));
207 return Err(CompletionError::RequestError(
208 format!(
209 "ToolChoice::Specific requested tool names not advertised this turn: \
210 {missing:?}. Advertised: {:?}.{}",
211 advertised(),
212 hint(active_tools_caused)
213 )
214 .into(),
215 ));
216 }
217
218 requested
219 }
220 };
221
222 Ok(allowed)
223}
224
225#[allow(clippy::too_many_arguments)]
228pub(crate) async fn build_completion_request<M: CompletionModel>(
229 model: &Arc<M>,
230 prompt: Message,
231 chat_history: &[Message],
232 preamble: Option<&str>,
233 static_context: &[Document],
234 temperature: Option<f64>,
235 max_tokens: Option<u64>,
236 additional_params: Option<&serde_json::Value>,
237 tool_choice: Option<&ToolChoice>,
238 tool_server_handle: &ToolServerHandle,
239 dynamic_context: &DynamicContextStore,
240 output_schema: Option<&schemars::Schema>,
241) -> Result<CompletionRequestBuilder<M>, CompletionError> {
242 Ok(build_prepared_completion_request(
243 model,
244 prompt,
245 chat_history,
246 preamble,
247 static_context,
248 temperature,
249 max_tokens,
250 additional_params,
251 tool_choice,
252 tool_server_handle,
253 dynamic_context,
254 output_schema,
255 &OutputMode::Native,
258 None,
259 None,
261 )
262 .await?
263 .builder)
264}
265
266#[allow(clippy::too_many_arguments)]
269pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
270 model: &Arc<M>,
271 prompt: Message,
272 chat_history: &[Message],
273 preamble: Option<&str>,
274 static_context: &[Document],
275 temperature: Option<f64>,
276 max_tokens: Option<u64>,
277 additional_params: Option<&serde_json::Value>,
278 tool_choice: Option<&ToolChoice>,
279 tool_server_handle: &ToolServerHandle,
280 dynamic_context: &DynamicContextStore,
281 output_schema: Option<&schemars::Schema>,
282 output_mode: &OutputMode,
283 committed_output_tool: Option<&str>,
284 request_patch: Option<&RequestPatch>,
285) -> Result<PreparedCompletionRequest<M>, CompletionError> {
286 let preamble = request_patch
292 .and_then(|o| o.preamble.as_deref())
293 .or(preamble);
294 let temperature = request_patch.and_then(|o| o.temperature).or(temperature);
295 let max_tokens = request_patch.and_then(|o| o.max_tokens).or(max_tokens);
296 let tool_choice = request_patch
297 .and_then(|o| o.tool_choice.as_ref())
298 .or(tool_choice);
299 let additional_params: Option<serde_json::Value> = match (
306 additional_params,
307 request_patch.and_then(|o| o.additional_params.as_ref()),
308 ) {
309 (Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
310 Some(json_utils::merge(base.clone(), patch.clone()))
311 }
312 (base, patch) => patch.or(base).cloned(),
313 };
314 let active_tools = request_patch.and_then(|o| o.active_tools.as_deref());
315
316 let rag_text = prompt.rag_text();
318 let rag_text = rag_text.or_else(|| {
319 chat_history
320 .iter()
321 .rev()
322 .find_map(|message| message.rag_text())
323 });
324
325 let (mut tooldefs, fetched_context): (Vec<crate::completion::ToolDefinition>, Vec<Document>) =
329 match &rag_text {
330 Some(text) => {
331 let search_futures = dynamic_context.iter().map(|(num_sample, index)| {
332 let text = text.clone();
333 let num_sample = *num_sample;
334 let index = index.clone();
335
336 async move {
337 let req = VectorSearchRequest::builder()
338 .query(text)
339 .samples(num_sample as u64)
340 .build();
341
342 let docs = index
343 .top_n(req)
344 .await?
345 .into_iter()
346 .map(|(_, id, doc)| {
347 let text = serde_json::to_string_pretty(&doc)
348 .unwrap_or_else(|_| doc.to_string());
349
350 Document {
351 id,
352 text,
353 additional_props: HashMap::new(),
354 }
355 })
356 .collect::<Vec<_>>();
357
358 Ok::<_, VectorStoreError>(docs)
359 }
360 });
361
362 let fetched_context: Vec<Document> = futures::future::try_join_all(search_futures)
363 .await
364 .map_err(|e| CompletionError::RequestError(Box::new(e)))?
365 .into_iter()
366 .flatten()
367 .collect();
368
369 let tooldefs = tool_server_handle
370 .get_tool_defs(Some(text.to_string()))
371 .await
372 .map_err(|_| {
373 CompletionError::RequestError("Failed to get tool definitions".into())
374 })?;
375
376 (tooldefs, fetched_context)
377 }
378 None => {
379 let tooldefs = tool_server_handle.get_tool_defs(None).await.map_err(|_| {
380 CompletionError::RequestError("Failed to get tool definitions".into())
381 })?;
382
383 (tooldefs, Vec::new())
384 }
385 };
386
387 let pre_filter_tool_names: Option<BTreeSet<String>> =
396 active_tools.map(|_| tooldefs.iter().map(|tool| tool.name.clone()).collect());
397
398 if let Some(allow) = active_tools {
406 if let Some(missing) = allow
407 .iter()
408 .find(|name| !tooldefs.iter().any(|tool| &tool.name == *name))
409 {
410 return Err(CompletionError::RequestError(
411 format!(
412 "active_tools requested tool `{missing}`, which is not available this turn"
413 )
414 .into(),
415 ));
416 }
417 tooldefs.retain(|tool| allow.iter().any(|name| name == &tool.name));
418 }
419
420 let executable_tool_names: BTreeSet<String> =
423 tooldefs.iter().map(|tool| tool.name.clone()).collect();
424
425 let resolved_mode = if committed_output_tool.is_some() && output_schema.is_some() {
436 OutputMode::Tool
437 } else {
438 resolve_output_mode(
439 output_schema.is_some(),
440 !executable_tool_names.is_empty(),
441 tool_choice_permits_output_tool(tool_choice),
442 model.composes_native_output_with_tools(),
443 output_mode,
444 )
445 };
446
447 let output_tool_name = matches!(resolved_mode, OutputMode::Tool).then(|| {
450 committed_output_tool.map(str::to_owned).unwrap_or_else(|| {
451 pick_output_tool_name(
452 pre_filter_tool_names
453 .as_ref()
454 .unwrap_or(&executable_tool_names),
455 )
456 })
457 });
458
459 if let Some(name) = &output_tool_name
464 && executable_tool_names.contains(name)
465 {
466 tracing::warn!(
467 output_tool = %name,
468 "a real tool now shares the synthetic output-tool name; a call to it \
469 will finalize the run instead of being dispatched"
470 );
471 }
472
473 if let Some(name) = &output_tool_name
484 && !output_tool_callable(tool_choice, name)
485 {
486 tracing::warn!(
487 "the active tool_choice forbids calling the structured-output tool while the \
488 run is pinned to Tool output mode; this turn cannot emit the structured \
489 result (check for a `RequestPatch` setting `tool_choice` to None or a \
490 Specific set that excludes the output tool)"
491 );
492 }
493
494 let effective_preamble: Option<String> = {
497 let base = preamble.map(str::to_owned);
498 let instruction = match &resolved_mode {
499 OutputMode::Tool => output_tool_name.as_deref().map(|name| {
500 format!(
501 "When you have gathered enough information to answer, call the `{name}` \
502 tool exactly once with your final answer. Its arguments are the structured \
503 result and must satisfy the required schema. Do not return the final answer \
504 as plain text."
505 )
506 }),
507 OutputMode::Prompted => output_schema.map(|schema| {
508 let schema_json = serde_json::to_string(schema.as_value()).unwrap_or_default();
509 format!(
510 "Respond with ONLY a single JSON object that conforms to this JSON Schema. \
511 Do not include any prose, explanation, or markdown code fences.\n{schema_json}"
512 )
513 }),
514 OutputMode::Native | OutputMode::Auto => None,
515 };
516 match (base, instruction) {
517 (Some(b), Some(i)) => Some(format!("{b}\n\n{i}")),
518 (Some(b), None) => Some(b),
519 (None, Some(i)) => Some(i),
520 (None, None) => None,
521 }
522 };
523
524 let messages_history: &[Message] = request_patch
529 .and_then(|o| o.history.as_deref())
530 .unwrap_or(chat_history);
531 let chat_history: Vec<Message> = if let Some(preamble) = &effective_preamble {
532 std::iter::once(Message::system(preamble.clone()))
533 .chain(messages_history.iter().cloned())
534 .collect()
535 } else {
536 messages_history.to_vec()
537 };
538
539 if let (Some(name), Some(schema)) = (&output_tool_name, output_schema) {
545 tooldefs.push(crate::completion::ToolDefinition {
546 name: name.clone(),
547 description: "Call this tool exactly once with your final answer when you are done. \
548 Its arguments are the structured result and must satisfy the output \
549 schema."
550 .to_string(),
551 parameters: schema.clone().to_value(),
552 });
553 }
554
555 let mut completion_request = model
556 .completion_request(prompt)
557 .messages(chat_history)
558 .temperature_opt(temperature)
559 .max_tokens_opt(max_tokens)
560 .additional_params_opt(additional_params)
561 .documents(static_context.to_vec())
562 .tools(tooldefs);
563
564 if !fetched_context.is_empty() {
565 completion_request = completion_request.documents(fetched_context);
566 }
567
568 if let Some(patch) = request_patch
573 && !patch.extra_context.is_empty()
574 {
575 completion_request = completion_request.documents(patch.extra_context.clone());
576 }
577
578 if matches!(resolved_mode, OutputMode::Native) {
580 completion_request = completion_request.output_schema_opt(output_schema.cloned());
581 }
582
583 let completion_request = if let Some(tool_choice) = tool_choice {
584 completion_request.tool_choice(tool_choice.clone())
585 } else {
586 completion_request
587 };
588
589 let mut allowed_tool_names = allowed_tool_names_for_choice(
594 &executable_tool_names,
595 tool_choice,
596 output_tool_name.as_deref(),
597 pre_filter_tool_names.as_ref(),
598 )?;
599 if let Some(name) = &output_tool_name {
602 allowed_tool_names.insert(name.clone());
603 }
604
605 Ok(PreparedCompletionRequest {
606 builder: completion_request,
607 executable_tool_names,
608 allowed_tool_names,
609 output_tool_name,
610 })
611}
612
613#[derive(Clone)]
642#[non_exhaustive]
643pub struct Agent<M>
644where
645 M: CompletionModel,
646{
647 pub name: Option<String>,
649 pub description: Option<String>,
651 pub model: Arc<M>,
653 pub preamble: Option<String>,
655 pub static_context: Vec<Document>,
657 pub temperature: Option<f64>,
659 pub max_tokens: Option<u64>,
661 pub additional_params: Option<serde_json::Value>,
663 pub tool_server_handle: ToolServerHandle,
664 pub dynamic_context: DynamicContextStore,
666 pub tool_choice: Option<ToolChoice>,
668 pub default_max_turns: Option<usize>,
671 pub hooks: HookStack<M>,
674 pub output_schema: Option<schemars::Schema>,
677 pub output_mode: OutputMode,
680 pub memory: Option<Arc<dyn crate::memory::ConversationMemory>>,
682 pub default_conversation_id: Option<String>,
684}
685
686impl<M> Agent<M>
687where
688 M: CompletionModel,
689{
690 pub(crate) fn name(&self) -> &str {
692 self.name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
693 }
694
695 pub fn runner(&self, prompt: impl Into<Message>) -> AgentRunner<M> {
699 AgentRunner::from_agent(self, prompt)
700 }
701}
702
703impl<M> Completion<M> for Agent<M>
704where
705 M: CompletionModel,
706{
707 async fn completion<I, T>(
708 &self,
709 prompt: impl Into<Message> + WasmCompatSend,
710 chat_history: I,
711 ) -> Result<CompletionRequestBuilder<M>, CompletionError>
712 where
713 I: IntoIterator<Item = T>,
714 T: Into<Message>,
715 {
716 let history: Vec<Message> = chat_history.into_iter().map(Into::into).collect();
717 build_completion_request(
718 &self.model,
719 prompt.into(),
720 &history,
721 self.preamble.as_deref(),
722 &self.static_context,
723 self.temperature,
724 self.max_tokens,
725 self.additional_params.as_ref(),
726 self.tool_choice.as_ref(),
727 &self.tool_server_handle,
728 &self.dynamic_context,
729 self.output_schema.as_ref(),
730 )
731 .await
732 }
733}
734
735#[allow(refining_impl_trait)]
743impl<M> Prompt for Agent<M>
744where
745 M: CompletionModel + 'static,
746{
747 fn prompt(
748 &self,
749 prompt: impl Into<Message> + WasmCompatSend,
750 ) -> PromptRequest<prompt_request::Standard, M> {
751 PromptRequest::from_agent(self, prompt)
752 }
753}
754
755#[allow(refining_impl_trait)]
756impl<M> Prompt for &Agent<M>
757where
758 M: CompletionModel + 'static,
759{
760 #[tracing::instrument(skip(self, prompt), fields(agent_name = self.name()))]
761 fn prompt(
762 &self,
763 prompt: impl Into<Message> + WasmCompatSend,
764 ) -> PromptRequest<prompt_request::Standard, M> {
765 PromptRequest::from_agent(*self, prompt)
766 }
767}
768
769#[allow(refining_impl_trait)]
770impl<M> Chat for Agent<M>
771where
772 M: CompletionModel + 'static,
773{
774 #[tracing::instrument(skip(self, prompt, chat_history), fields(agent_name = self.name()))]
775 async fn chat(
776 &self,
777 prompt: impl Into<Message> + WasmCompatSend,
778 chat_history: &mut Vec<Message>,
779 ) -> Result<String, PromptError> {
780 let response = PromptRequest::from_agent(self, prompt)
781 .history(chat_history.clone())
782 .extended_details()
783 .await?;
784
785 if let Some(messages) = response.messages {
786 chat_history.extend(messages);
787 }
788
789 Ok(response.output)
790 }
791}
792
793impl<M> StreamingCompletion<M> for Agent<M>
794where
795 M: CompletionModel,
796{
797 async fn stream_completion<I, T>(
798 &self,
799 prompt: impl Into<Message> + WasmCompatSend,
800 chat_history: I,
801 ) -> Result<CompletionRequestBuilder<M>, CompletionError>
802 where
803 I: IntoIterator<Item = T> + WasmCompatSend,
804 T: Into<Message>,
805 {
806 self.completion(prompt, chat_history).await
809 }
810}
811
812impl<M> StreamingPrompt<M, M::StreamingResponse> for Agent<M>
813where
814 M: CompletionModel + 'static,
815 M::StreamingResponse: GetTokenUsage,
816{
817 fn stream_prompt(
818 &self,
819 prompt: impl Into<Message> + WasmCompatSend,
820 ) -> StreamingPromptRequest<M> {
821 StreamingPromptRequest::<M>::from_agent(self, prompt)
822 }
823}
824
825impl<M> StreamingChat<M, M::StreamingResponse> for Agent<M>
826where
827 M: CompletionModel + 'static,
828 M::StreamingResponse: GetTokenUsage,
829{
830 fn stream_chat<I, T>(
831 &self,
832 prompt: impl Into<Message> + WasmCompatSend,
833 chat_history: I,
834 ) -> StreamingPromptRequest<M>
835 where
836 I: IntoIterator<Item = T>,
837 T: Into<Message>,
838 {
839 StreamingPromptRequest::<M>::from_agent(self, prompt).history(chat_history)
840 }
841}
842
843use crate::agent::prompt_request::TypedPromptRequest;
844use schemars::JsonSchema;
845use serde::de::DeserializeOwned;
846
847#[allow(refining_impl_trait)]
848impl<M> TypedPrompt for Agent<M>
849where
850 M: CompletionModel + 'static,
851{
852 type TypedRequest<T>
853 = TypedPromptRequest<T, prompt_request::Standard, M>
854 where
855 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
856
857 fn prompt_typed<T>(
890 &self,
891 prompt: impl Into<Message> + WasmCompatSend,
892 ) -> TypedPromptRequest<T, prompt_request::Standard, M>
893 where
894 T: JsonSchema + DeserializeOwned + WasmCompatSend,
895 {
896 TypedPromptRequest::from_agent(self, prompt)
897 }
898}
899
900#[allow(refining_impl_trait)]
901impl<M> TypedPrompt for &Agent<M>
902where
903 M: CompletionModel + 'static,
904{
905 type TypedRequest<T>
906 = TypedPromptRequest<T, prompt_request::Standard, M>
907 where
908 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
909
910 fn prompt_typed<T>(
911 &self,
912 prompt: impl Into<Message> + WasmCompatSend,
913 ) -> TypedPromptRequest<T, prompt_request::Standard, M>
914 where
915 T: JsonSchema + DeserializeOwned + WasmCompatSend,
916 {
917 TypedPromptRequest::from_agent(*self, prompt)
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn tool_names(names: &[&str]) -> BTreeSet<String> {
926 names.iter().map(|name| (*name).to_string()).collect()
927 }
928
929 #[test]
930 fn allowed_tool_names_defaults_to_all_executable_tools() {
931 let executable = tool_names(&["add", "subtract"]);
932
933 assert_eq!(
934 allowed_tool_names_for_choice(&executable, None, None, None).unwrap(),
935 executable
936 );
937 }
938
939 #[test]
940 fn allowed_tool_names_auto_and_required_allow_all_executable_tools() {
941 let executable = tool_names(&["add", "subtract"]);
942
943 assert_eq!(
944 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Auto), None, None)
945 .unwrap(),
946 executable
947 );
948 assert_eq!(
949 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Required), None, None)
950 .unwrap(),
951 executable
952 );
953 }
954
955 #[test]
956 fn allowed_tool_names_none_allows_no_tools() {
957 let executable = tool_names(&["add", "subtract"]);
958
959 assert!(
960 allowed_tool_names_for_choice(&executable, Some(&ToolChoice::None), None, None)
961 .unwrap()
962 .is_empty()
963 );
964 }
965
966 #[test]
967 fn allowed_tool_names_specific_allows_requested_executable_tools() {
968 let executable = tool_names(&["add", "subtract"]);
969 let choice = ToolChoice::Specific {
970 function_names: vec!["add".to_string()],
971 };
972
973 assert_eq!(
974 allowed_tool_names_for_choice(&executable, Some(&choice), None, None).unwrap(),
975 tool_names(&["add"])
976 );
977 }
978
979 #[test]
980 fn allowed_tool_names_specific_rejects_missing_tools() {
981 let executable = tool_names(&["add"]);
982 let choice = ToolChoice::Specific {
983 function_names: vec!["missing".to_string()],
984 };
985
986 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
987 .expect_err("missing specific tool should fail before provider request");
988
989 assert!(matches!(
990 err,
991 CompletionError::RequestError(err)
992 if err.to_string().contains("missing")
993 && err.to_string().contains("add")
994 ));
995 }
996
997 #[test]
998 fn allowed_tool_names_specific_rejects_empty_names() {
999 let executable = tool_names(&["add"]);
1000 let choice = ToolChoice::Specific {
1001 function_names: vec![],
1002 };
1003
1004 let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
1005 .expect_err("empty specific tool choice should fail before provider request");
1006
1007 assert!(matches!(
1008 err,
1009 CompletionError::RequestError(err)
1010 if err.to_string().contains("requires at least one function name")
1011 ));
1012 }
1013
1014 #[test]
1015 fn output_tool_callable_honors_specific_naming_the_output_tool() {
1016 assert!(output_tool_callable(None, "final_result"));
1018 assert!(output_tool_callable(
1019 Some(&ToolChoice::Auto),
1020 "final_result"
1021 ));
1022 assert!(output_tool_callable(
1023 Some(&ToolChoice::Required),
1024 "final_result"
1025 ));
1026 assert!(output_tool_callable(
1030 Some(&ToolChoice::Specific {
1031 function_names: vec!["final_result".to_string()],
1032 }),
1033 "final_result",
1034 ));
1035 assert!(!output_tool_callable(
1038 Some(&ToolChoice::Specific {
1039 function_names: vec!["search".to_string()],
1040 }),
1041 "final_result",
1042 ));
1043 assert!(!output_tool_callable(
1044 Some(&ToolChoice::None),
1045 "final_result"
1046 ));
1047 }
1048
1049 #[test]
1050 fn required_with_no_advertised_tool_is_local_error() {
1051 let empty = tool_names(&[]);
1052 let err = allowed_tool_names_for_choice(&empty, Some(&ToolChoice::Required), None, None)
1053 .expect_err("Required with no advertised tool must fail locally");
1054 assert!(matches!(
1055 err,
1056 CompletionError::RequestError(err) if err.to_string().contains("Required")
1057 ));
1058 }
1059
1060 #[test]
1061 fn required_with_only_the_output_tool_is_allowed() {
1062 let empty = tool_names(&[]);
1065 let allowed = allowed_tool_names_for_choice(
1066 &empty,
1067 Some(&ToolChoice::Required),
1068 Some("final_result"),
1069 None,
1070 )
1071 .expect("Required is satisfiable by the output tool");
1072 assert!(allowed.is_empty());
1075 }
1076
1077 #[test]
1078 fn required_with_active_tools_filter_names_the_filter_in_the_error() {
1079 let empty = tool_names(&[]);
1080 let err = allowed_tool_names_for_choice(
1081 &empty,
1082 Some(&ToolChoice::Required),
1083 None,
1084 Some(&tool_names(&["add"])),
1085 )
1086 .expect_err("Required after active_tools filtered everything must fail locally");
1087 let msg = err.to_string();
1088 assert!(
1089 msg.contains("active_tools"),
1090 "error should name active_tools: {msg}"
1091 );
1092 assert!(
1093 msg.contains("RequestPatch"),
1094 "error should suggest RequestPatch: {msg}"
1095 );
1096 }
1097
1098 #[test]
1099 fn specific_naming_a_filtered_out_tool_is_a_local_error_with_hint() {
1100 let executable = tool_names(&["add"]);
1103 let choice = ToolChoice::Specific {
1104 function_names: vec!["subtract".to_string()],
1105 };
1106 let err = allowed_tool_names_for_choice(
1107 &executable,
1108 Some(&choice),
1109 None,
1110 Some(&tool_names(&["add", "subtract"])),
1111 )
1112 .expect_err("Specific naming a filtered-out tool must fail locally");
1113 let msg = err.to_string();
1114 assert!(
1115 msg.contains("subtract"),
1116 "error should name the missing tool: {msg}"
1117 );
1118 assert!(
1119 msg.contains("active_tools"),
1120 "error should name active_tools: {msg}"
1121 );
1122 }
1123
1124 #[test]
1125 fn specific_may_name_the_output_tool() {
1126 let empty = tool_names(&[]);
1128 let choice = ToolChoice::Specific {
1129 function_names: vec!["final_result".to_string()],
1130 };
1131 let allowed =
1132 allowed_tool_names_for_choice(&empty, Some(&choice), Some("final_result"), None)
1133 .expect("Specific naming the output tool is valid");
1134 assert_eq!(allowed, tool_names(&["final_result"]));
1135 }
1136
1137 #[test]
1138 fn specific_typo_is_not_blamed_on_active_tools() {
1139 let executable = tool_names(&["add"]);
1143 let choice = ToolChoice::Specific {
1144 function_names: vec!["nonexistent".to_string()],
1145 };
1146 let err = allowed_tool_names_for_choice(
1147 &executable,
1148 Some(&choice),
1149 None,
1150 Some(&tool_names(&["add"])),
1151 )
1152 .expect_err("Specific naming a non-existent tool must fail locally");
1153 let msg = err.to_string();
1154 assert!(msg.contains("nonexistent"), "error names the typo: {msg}");
1155 assert!(
1156 !msg.contains("active_tools"),
1157 "a plain typo must not be blamed on active_tools: {msg}"
1158 );
1159 }
1160
1161 #[test]
1162 fn resolve_output_mode_without_schema_is_always_native() {
1163 for requested in [
1165 OutputMode::Auto,
1166 OutputMode::Tool,
1167 OutputMode::Native,
1168 OutputMode::Prompted,
1169 ] {
1170 assert_eq!(
1171 resolve_output_mode(false, true, true, false, &requested),
1172 OutputMode::Native,
1173 "no schema should force Native for {requested:?}"
1174 );
1175 assert_eq!(
1176 resolve_output_mode(false, false, true, false, &requested),
1177 OutputMode::Native,
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn resolve_output_mode_auto_picks_tool_only_when_tools_present() {
1184 assert_eq!(
1188 resolve_output_mode(true, true, true, false, &OutputMode::Auto),
1189 OutputMode::Tool,
1190 );
1191 assert_eq!(
1193 resolve_output_mode(true, false, true, false, &OutputMode::Auto),
1194 OutputMode::Native,
1195 );
1196 }
1197
1198 #[test]
1199 fn resolve_output_mode_auto_keeps_native_when_provider_composes() {
1200 assert_eq!(
1203 resolve_output_mode(true, true, true, true, &OutputMode::Auto),
1204 OutputMode::Native,
1205 );
1206 }
1207
1208 #[test]
1209 fn resolve_output_mode_honors_explicit_choice_with_schema() {
1210 for (requested, expected) in [
1211 (OutputMode::Tool, OutputMode::Tool),
1212 (OutputMode::Native, OutputMode::Native),
1213 (OutputMode::Prompted, OutputMode::Prompted),
1214 ] {
1215 assert_eq!(
1217 resolve_output_mode(true, true, true, false, &requested),
1218 expected
1219 );
1220 assert_eq!(
1221 resolve_output_mode(true, false, true, true, &requested),
1222 expected
1223 );
1224 }
1225 }
1226
1227 #[test]
1228 fn resolve_output_mode_degrades_to_native_when_output_tool_not_callable() {
1229 assert_eq!(
1233 resolve_output_mode(true, true, false, false, &OutputMode::Auto),
1234 OutputMode::Native,
1235 );
1236 assert_eq!(
1237 resolve_output_mode(true, true, false, false, &OutputMode::Tool),
1238 OutputMode::Native,
1239 );
1240 assert_eq!(
1242 resolve_output_mode(true, true, false, false, &OutputMode::Prompted),
1243 OutputMode::Prompted,
1244 );
1245 }
1246
1247 #[test]
1248 fn tool_choice_permits_output_tool_only_for_auto_required_or_unset() {
1249 assert!(tool_choice_permits_output_tool(None));
1250 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Auto)));
1251 assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Required)));
1252 assert!(!tool_choice_permits_output_tool(Some(&ToolChoice::None)));
1253 assert!(!tool_choice_permits_output_tool(Some(
1254 &ToolChoice::Specific {
1255 function_names: vec!["add".to_string()],
1256 }
1257 )));
1258 }
1259
1260 #[test]
1261 fn pick_output_tool_name_defaults_when_unused() {
1262 let executable = tool_names(&["add", "subtract"]);
1263 assert_eq!(pick_output_tool_name(&executable), DEFAULT_OUTPUT_TOOL_NAME);
1264 }
1265
1266 #[test]
1267 fn pick_output_tool_name_avoids_collision_with_real_tools() {
1268 let executable = tool_names(&["final_result"]);
1271 assert_eq!(pick_output_tool_name(&executable), "final_result_1");
1272
1273 let executable = tool_names(&["final_result", "final_result_1"]);
1274 assert_eq!(pick_output_tool_name(&executable), "final_result_2");
1275 }
1276}