use super::hook::{HookStack, RequestPatch};
use super::prompt_request::{self, PromptRequest};
use super::run::OutputMode;
use super::runner::AgentRunner;
use crate::{
agent::prompt_request::streaming::StreamingPromptRequest,
completion::{
Chat, Completion, CompletionError, CompletionModel, CompletionRequestBuilder, Document,
GetTokenUsage, Message, Prompt, PromptError, TypedPrompt,
},
json_utils,
message::ToolChoice,
streaming::{StreamingChat, StreamingCompletion, StreamingPrompt},
tool::server::ToolServerHandle,
vector_store::{VectorStoreError, request::VectorSearchRequest},
wasm_compat::WasmCompatSend,
};
use std::{
collections::{BTreeSet, HashMap},
sync::Arc,
};
use super::UNKNOWN_AGENT_NAME;
pub type DynamicContextStore = Arc<
Vec<(
usize,
Arc<dyn crate::vector_store::VectorStoreIndexDyn + Send + Sync>,
)>,
>;
pub(crate) struct PreparedCompletionRequest<M: CompletionModel> {
pub(crate) builder: CompletionRequestBuilder<M>,
pub(crate) executable_tool_names: BTreeSet<String>,
pub(crate) allowed_tool_names: BTreeSet<String>,
pub(crate) output_tool_name: Option<String>,
}
const DEFAULT_OUTPUT_TOOL_NAME: &str = "final_result";
fn tool_choice_permits_output_tool(tool_choice: Option<&ToolChoice>) -> bool {
matches!(
tool_choice,
None | Some(ToolChoice::Auto | ToolChoice::Required)
)
}
fn output_tool_callable(tool_choice: Option<&ToolChoice>, output_tool_name: &str) -> bool {
match tool_choice {
Some(ToolChoice::Specific { function_names }) => function_names
.iter()
.any(|name| name.as_str() == output_tool_name),
other => tool_choice_permits_output_tool(other),
}
}
fn resolve_output_mode(
has_schema: bool,
has_executable_tools: bool,
output_tool_callable: bool,
provider_composes_native: bool,
requested: &OutputMode,
) -> OutputMode {
if !has_schema {
return OutputMode::Native;
}
match requested {
OutputMode::Native => OutputMode::Native,
OutputMode::Prompted => OutputMode::Prompted,
OutputMode::Tool if output_tool_callable => OutputMode::Tool,
OutputMode::Tool => OutputMode::Native,
OutputMode::Auto
if has_executable_tools && output_tool_callable && !provider_composes_native =>
{
OutputMode::Tool
}
OutputMode::Auto => OutputMode::Native,
}
}
fn pick_output_tool_name(executable_tool_names: &BTreeSet<String>) -> String {
let mut name = DEFAULT_OUTPUT_TOOL_NAME.to_string();
let mut suffix = 1u32;
while executable_tool_names.contains(&name) {
name = format!("{DEFAULT_OUTPUT_TOOL_NAME}_{suffix}");
suffix += 1;
}
name
}
pub(crate) fn allowed_tool_names_for_choice(
executable_tool_names: &BTreeSet<String>,
tool_choice: Option<&ToolChoice>,
output_tool_name: Option<&str>,
pre_filter_tool_names: Option<&BTreeSet<String>>,
) -> Result<BTreeSet<String>, CompletionError> {
let has_advertised_tool = !executable_tool_names.is_empty() || output_tool_name.is_some();
let hint = |active_tools_caused: bool| {
if active_tools_caused {
" A per-turn `active_tools` allow-list narrowed the advertised tools this turn; \
set a compatible `tool_choice` in the same `RequestPatch`, or widen `active_tools`."
} else {
""
}
};
let advertised = || {
executable_tool_names
.iter()
.map(String::as_str)
.chain(output_tool_name)
.collect::<Vec<_>>()
};
let allowed = match tool_choice {
None | Some(ToolChoice::Auto) => executable_tool_names.clone(),
Some(ToolChoice::Required) => {
if !has_advertised_tool {
let active_tools_caused = pre_filter_tool_names.is_some_and(|pf| !pf.is_empty());
return Err(CompletionError::RequestError(
format!(
"ToolChoice::Required forces the model to call a tool, but no tools are \
advertised this turn.{}",
hint(active_tools_caused)
)
.into(),
));
}
executable_tool_names.clone()
}
Some(ToolChoice::None) => BTreeSet::new(),
Some(ToolChoice::Specific { function_names }) => {
if function_names.is_empty() {
return Err(CompletionError::RequestError(
"ToolChoice::Specific requires at least one function name".into(),
));
}
let requested = function_names.iter().cloned().collect::<BTreeSet<String>>();
let missing = function_names
.iter()
.map(String::as_str)
.filter(|name| {
!executable_tool_names.contains(*name) && Some(*name) != output_tool_name
})
.collect::<Vec<_>>();
if !missing.is_empty() {
let active_tools_caused = pre_filter_tool_names
.is_some_and(|pf| missing.iter().any(|name| pf.contains(*name)));
return Err(CompletionError::RequestError(
format!(
"ToolChoice::Specific requested tool names not advertised this turn: \
{missing:?}. Advertised: {:?}.{}",
advertised(),
hint(active_tools_caused)
)
.into(),
));
}
requested
}
};
Ok(allowed)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_completion_request<M: CompletionModel>(
model: &Arc<M>,
prompt: Message,
chat_history: &[Message],
preamble: Option<&str>,
static_context: &[Document],
temperature: Option<f64>,
max_tokens: Option<u64>,
additional_params: Option<&serde_json::Value>,
tool_choice: Option<&ToolChoice>,
tool_server_handle: &ToolServerHandle,
dynamic_context: &DynamicContextStore,
output_schema: Option<&schemars::Schema>,
) -> Result<CompletionRequestBuilder<M>, CompletionError> {
Ok(build_prepared_completion_request(
model,
prompt,
chat_history,
preamble,
static_context,
temperature,
max_tokens,
additional_params,
tool_choice,
tool_server_handle,
dynamic_context,
output_schema,
&OutputMode::Native,
None,
None,
)
.await?
.builder)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_prepared_completion_request<M: CompletionModel>(
model: &Arc<M>,
prompt: Message,
chat_history: &[Message],
preamble: Option<&str>,
static_context: &[Document],
temperature: Option<f64>,
max_tokens: Option<u64>,
additional_params: Option<&serde_json::Value>,
tool_choice: Option<&ToolChoice>,
tool_server_handle: &ToolServerHandle,
dynamic_context: &DynamicContextStore,
output_schema: Option<&schemars::Schema>,
output_mode: &OutputMode,
committed_output_tool: Option<&str>,
request_patch: Option<&RequestPatch>,
) -> Result<PreparedCompletionRequest<M>, CompletionError> {
let preamble = request_patch
.and_then(|o| o.preamble.as_deref())
.or(preamble);
let temperature = request_patch.and_then(|o| o.temperature).or(temperature);
let max_tokens = request_patch.and_then(|o| o.max_tokens).or(max_tokens);
let tool_choice = request_patch
.and_then(|o| o.tool_choice.as_ref())
.or(tool_choice);
let additional_params: Option<serde_json::Value> = match (
additional_params,
request_patch.and_then(|o| o.additional_params.as_ref()),
) {
(Some(base), Some(patch)) if base.is_object() && patch.is_object() => {
Some(json_utils::merge(base.clone(), patch.clone()))
}
(base, patch) => patch.or(base).cloned(),
};
let active_tools = request_patch.and_then(|o| o.active_tools.as_deref());
let rag_text = prompt.rag_text();
let rag_text = rag_text.or_else(|| {
chat_history
.iter()
.rev()
.find_map(|message| message.rag_text())
});
let (mut tooldefs, fetched_context): (Vec<crate::completion::ToolDefinition>, Vec<Document>) =
match &rag_text {
Some(text) => {
let search_futures = dynamic_context.iter().map(|(num_sample, index)| {
let text = text.clone();
let num_sample = *num_sample;
let index = index.clone();
async move {
let req = VectorSearchRequest::builder()
.query(text)
.samples(num_sample as u64)
.build();
let docs = index
.top_n(req)
.await?
.into_iter()
.map(|(_, id, doc)| {
let text = serde_json::to_string_pretty(&doc)
.unwrap_or_else(|_| doc.to_string());
Document {
id,
text,
additional_props: HashMap::new(),
}
})
.collect::<Vec<_>>();
Ok::<_, VectorStoreError>(docs)
}
});
let fetched_context: Vec<Document> = futures::future::try_join_all(search_futures)
.await
.map_err(|e| CompletionError::RequestError(Box::new(e)))?
.into_iter()
.flatten()
.collect();
let tooldefs = tool_server_handle
.get_tool_defs(Some(text.to_string()))
.await
.map_err(|_| {
CompletionError::RequestError("Failed to get tool definitions".into())
})?;
(tooldefs, fetched_context)
}
None => {
let tooldefs = tool_server_handle.get_tool_defs(None).await.map_err(|_| {
CompletionError::RequestError("Failed to get tool definitions".into())
})?;
(tooldefs, Vec::new())
}
};
let pre_filter_tool_names: Option<BTreeSet<String>> =
active_tools.map(|_| tooldefs.iter().map(|tool| tool.name.clone()).collect());
if let Some(allow) = active_tools {
if let Some(missing) = allow
.iter()
.find(|name| !tooldefs.iter().any(|tool| &tool.name == *name))
{
return Err(CompletionError::RequestError(
format!(
"active_tools requested tool `{missing}`, which is not available this turn"
)
.into(),
));
}
tooldefs.retain(|tool| allow.iter().any(|name| name == &tool.name));
}
let executable_tool_names: BTreeSet<String> =
tooldefs.iter().map(|tool| tool.name.clone()).collect();
let resolved_mode = if committed_output_tool.is_some() && output_schema.is_some() {
OutputMode::Tool
} else {
resolve_output_mode(
output_schema.is_some(),
!executable_tool_names.is_empty(),
tool_choice_permits_output_tool(tool_choice),
model.composes_native_output_with_tools(),
output_mode,
)
};
let output_tool_name = matches!(resolved_mode, OutputMode::Tool).then(|| {
committed_output_tool.map(str::to_owned).unwrap_or_else(|| {
pick_output_tool_name(
pre_filter_tool_names
.as_ref()
.unwrap_or(&executable_tool_names),
)
})
});
if let Some(name) = &output_tool_name
&& executable_tool_names.contains(name)
{
tracing::warn!(
output_tool = %name,
"a real tool now shares the synthetic output-tool name; a call to it \
will finalize the run instead of being dispatched"
);
}
if let Some(name) = &output_tool_name
&& !output_tool_callable(tool_choice, name)
{
tracing::warn!(
"the active tool_choice forbids calling the structured-output tool while the \
run is pinned to Tool output mode; this turn cannot emit the structured \
result (check for a `RequestPatch` setting `tool_choice` to None or a \
Specific set that excludes the output tool)"
);
}
let effective_preamble: Option<String> = {
let base = preamble.map(str::to_owned);
let instruction = match &resolved_mode {
OutputMode::Tool => output_tool_name.as_deref().map(|name| {
format!(
"When you have gathered enough information to answer, call the `{name}` \
tool exactly once with your final answer. Its arguments are the structured \
result and must satisfy the required schema. Do not return the final answer \
as plain text."
)
}),
OutputMode::Prompted => output_schema.map(|schema| {
let schema_json = serde_json::to_string(schema.as_value()).unwrap_or_default();
format!(
"Respond with ONLY a single JSON object that conforms to this JSON Schema. \
Do not include any prose, explanation, or markdown code fences.\n{schema_json}"
)
}),
OutputMode::Native | OutputMode::Auto => None,
};
match (base, instruction) {
(Some(b), Some(i)) => Some(format!("{b}\n\n{i}")),
(Some(b), None) => Some(b),
(None, Some(i)) => Some(i),
(None, None) => None,
}
};
let messages_history: &[Message] = request_patch
.and_then(|o| o.history.as_deref())
.unwrap_or(chat_history);
let chat_history: Vec<Message> = if let Some(preamble) = &effective_preamble {
std::iter::once(Message::system(preamble.clone()))
.chain(messages_history.iter().cloned())
.collect()
} else {
messages_history.to_vec()
};
if let (Some(name), Some(schema)) = (&output_tool_name, output_schema) {
tooldefs.push(crate::completion::ToolDefinition {
name: name.clone(),
description: "Call this tool exactly once with your final answer when you are done. \
Its arguments are the structured result and must satisfy the output \
schema."
.to_string(),
parameters: schema.clone().to_value(),
});
}
let mut completion_request = model
.completion_request(prompt)
.messages(chat_history)
.temperature_opt(temperature)
.max_tokens_opt(max_tokens)
.additional_params_opt(additional_params)
.documents(static_context.to_vec())
.tools(tooldefs);
if !fetched_context.is_empty() {
completion_request = completion_request.documents(fetched_context);
}
if let Some(patch) = request_patch
&& !patch.extra_context.is_empty()
{
completion_request = completion_request.documents(patch.extra_context.clone());
}
if matches!(resolved_mode, OutputMode::Native) {
completion_request = completion_request.output_schema_opt(output_schema.cloned());
}
let completion_request = if let Some(tool_choice) = tool_choice {
completion_request.tool_choice(tool_choice.clone())
} else {
completion_request
};
let mut allowed_tool_names = allowed_tool_names_for_choice(
&executable_tool_names,
tool_choice,
output_tool_name.as_deref(),
pre_filter_tool_names.as_ref(),
)?;
if let Some(name) = &output_tool_name {
allowed_tool_names.insert(name.clone());
}
Ok(PreparedCompletionRequest {
builder: completion_request,
executable_tool_names,
allowed_tool_names,
output_tool_name,
})
}
#[derive(Clone)]
#[non_exhaustive]
pub struct Agent<M>
where
M: CompletionModel,
{
pub name: Option<String>,
pub description: Option<String>,
pub model: Arc<M>,
pub preamble: Option<String>,
pub static_context: Vec<Document>,
pub temperature: Option<f64>,
pub max_tokens: Option<u64>,
pub additional_params: Option<serde_json::Value>,
pub tool_server_handle: ToolServerHandle,
pub dynamic_context: DynamicContextStore,
pub tool_choice: Option<ToolChoice>,
pub default_max_turns: Option<usize>,
pub hooks: HookStack<M>,
pub output_schema: Option<schemars::Schema>,
pub output_mode: OutputMode,
pub memory: Option<Arc<dyn crate::memory::ConversationMemory>>,
pub default_conversation_id: Option<String>,
}
impl<M> Agent<M>
where
M: CompletionModel,
{
pub(crate) fn name(&self) -> &str {
self.name.as_deref().unwrap_or(UNKNOWN_AGENT_NAME)
}
pub fn runner(&self, prompt: impl Into<Message>) -> AgentRunner<M> {
AgentRunner::from_agent(self, prompt)
}
}
impl<M> Completion<M> for Agent<M>
where
M: CompletionModel,
{
async fn completion<I, T>(
&self,
prompt: impl Into<Message> + WasmCompatSend,
chat_history: I,
) -> Result<CompletionRequestBuilder<M>, CompletionError>
where
I: IntoIterator<Item = T>,
T: Into<Message>,
{
let history: Vec<Message> = chat_history.into_iter().map(Into::into).collect();
build_completion_request(
&self.model,
prompt.into(),
&history,
self.preamble.as_deref(),
&self.static_context,
self.temperature,
self.max_tokens,
self.additional_params.as_ref(),
self.tool_choice.as_ref(),
&self.tool_server_handle,
&self.dynamic_context,
self.output_schema.as_ref(),
)
.await
}
}
#[allow(refining_impl_trait)]
impl<M> Prompt for Agent<M>
where
M: CompletionModel + 'static,
{
fn prompt(
&self,
prompt: impl Into<Message> + WasmCompatSend,
) -> PromptRequest<prompt_request::Standard, M> {
PromptRequest::from_agent(self, prompt)
}
}
#[allow(refining_impl_trait)]
impl<M> Prompt for &Agent<M>
where
M: CompletionModel + 'static,
{
#[tracing::instrument(skip(self, prompt), fields(agent_name = self.name()))]
fn prompt(
&self,
prompt: impl Into<Message> + WasmCompatSend,
) -> PromptRequest<prompt_request::Standard, M> {
PromptRequest::from_agent(*self, prompt)
}
}
#[allow(refining_impl_trait)]
impl<M> Chat for Agent<M>
where
M: CompletionModel + 'static,
{
#[tracing::instrument(skip(self, prompt, chat_history), fields(agent_name = self.name()))]
async fn chat(
&self,
prompt: impl Into<Message> + WasmCompatSend,
chat_history: &mut Vec<Message>,
) -> Result<String, PromptError> {
let response = PromptRequest::from_agent(self, prompt)
.history(chat_history.clone())
.extended_details()
.await?;
if let Some(messages) = response.messages {
chat_history.extend(messages);
}
Ok(response.output)
}
}
impl<M> StreamingCompletion<M> for Agent<M>
where
M: CompletionModel,
{
async fn stream_completion<I, T>(
&self,
prompt: impl Into<Message> + WasmCompatSend,
chat_history: I,
) -> Result<CompletionRequestBuilder<M>, CompletionError>
where
I: IntoIterator<Item = T> + WasmCompatSend,
T: Into<Message>,
{
self.completion(prompt, chat_history).await
}
}
impl<M> StreamingPrompt<M, M::StreamingResponse> for Agent<M>
where
M: CompletionModel + 'static,
M::StreamingResponse: GetTokenUsage,
{
fn stream_prompt(
&self,
prompt: impl Into<Message> + WasmCompatSend,
) -> StreamingPromptRequest<M> {
StreamingPromptRequest::<M>::from_agent(self, prompt)
}
}
impl<M> StreamingChat<M, M::StreamingResponse> for Agent<M>
where
M: CompletionModel + 'static,
M::StreamingResponse: GetTokenUsage,
{
fn stream_chat<I, T>(
&self,
prompt: impl Into<Message> + WasmCompatSend,
chat_history: I,
) -> StreamingPromptRequest<M>
where
I: IntoIterator<Item = T>,
T: Into<Message>,
{
StreamingPromptRequest::<M>::from_agent(self, prompt).history(chat_history)
}
}
use crate::agent::prompt_request::TypedPromptRequest;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
#[allow(refining_impl_trait)]
impl<M> TypedPrompt for Agent<M>
where
M: CompletionModel + 'static,
{
type TypedRequest<T>
= TypedPromptRequest<T, prompt_request::Standard, M>
where
T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
fn prompt_typed<T>(
&self,
prompt: impl Into<Message> + WasmCompatSend,
) -> TypedPromptRequest<T, prompt_request::Standard, M>
where
T: JsonSchema + DeserializeOwned + WasmCompatSend,
{
TypedPromptRequest::from_agent(self, prompt)
}
}
#[allow(refining_impl_trait)]
impl<M> TypedPrompt for &Agent<M>
where
M: CompletionModel + 'static,
{
type TypedRequest<T>
= TypedPromptRequest<T, prompt_request::Standard, M>
where
T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static;
fn prompt_typed<T>(
&self,
prompt: impl Into<Message> + WasmCompatSend,
) -> TypedPromptRequest<T, prompt_request::Standard, M>
where
T: JsonSchema + DeserializeOwned + WasmCompatSend,
{
TypedPromptRequest::from_agent(*self, prompt)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_names(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
#[test]
fn allowed_tool_names_defaults_to_all_executable_tools() {
let executable = tool_names(&["add", "subtract"]);
assert_eq!(
allowed_tool_names_for_choice(&executable, None, None, None).unwrap(),
executable
);
}
#[test]
fn allowed_tool_names_auto_and_required_allow_all_executable_tools() {
let executable = tool_names(&["add", "subtract"]);
assert_eq!(
allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Auto), None, None)
.unwrap(),
executable
);
assert_eq!(
allowed_tool_names_for_choice(&executable, Some(&ToolChoice::Required), None, None)
.unwrap(),
executable
);
}
#[test]
fn allowed_tool_names_none_allows_no_tools() {
let executable = tool_names(&["add", "subtract"]);
assert!(
allowed_tool_names_for_choice(&executable, Some(&ToolChoice::None), None, None)
.unwrap()
.is_empty()
);
}
#[test]
fn allowed_tool_names_specific_allows_requested_executable_tools() {
let executable = tool_names(&["add", "subtract"]);
let choice = ToolChoice::Specific {
function_names: vec!["add".to_string()],
};
assert_eq!(
allowed_tool_names_for_choice(&executable, Some(&choice), None, None).unwrap(),
tool_names(&["add"])
);
}
#[test]
fn allowed_tool_names_specific_rejects_missing_tools() {
let executable = tool_names(&["add"]);
let choice = ToolChoice::Specific {
function_names: vec!["missing".to_string()],
};
let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
.expect_err("missing specific tool should fail before provider request");
assert!(matches!(
err,
CompletionError::RequestError(err)
if err.to_string().contains("missing")
&& err.to_string().contains("add")
));
}
#[test]
fn allowed_tool_names_specific_rejects_empty_names() {
let executable = tool_names(&["add"]);
let choice = ToolChoice::Specific {
function_names: vec![],
};
let err = allowed_tool_names_for_choice(&executable, Some(&choice), None, None)
.expect_err("empty specific tool choice should fail before provider request");
assert!(matches!(
err,
CompletionError::RequestError(err)
if err.to_string().contains("requires at least one function name")
));
}
#[test]
fn output_tool_callable_honors_specific_naming_the_output_tool() {
assert!(output_tool_callable(None, "final_result"));
assert!(output_tool_callable(
Some(&ToolChoice::Auto),
"final_result"
));
assert!(output_tool_callable(
Some(&ToolChoice::Required),
"final_result"
));
assert!(output_tool_callable(
Some(&ToolChoice::Specific {
function_names: vec!["final_result".to_string()],
}),
"final_result",
));
assert!(!output_tool_callable(
Some(&ToolChoice::Specific {
function_names: vec!["search".to_string()],
}),
"final_result",
));
assert!(!output_tool_callable(
Some(&ToolChoice::None),
"final_result"
));
}
#[test]
fn required_with_no_advertised_tool_is_local_error() {
let empty = tool_names(&[]);
let err = allowed_tool_names_for_choice(&empty, Some(&ToolChoice::Required), None, None)
.expect_err("Required with no advertised tool must fail locally");
assert!(matches!(
err,
CompletionError::RequestError(err) if err.to_string().contains("Required")
));
}
#[test]
fn required_with_only_the_output_tool_is_allowed() {
let empty = tool_names(&[]);
let allowed = allowed_tool_names_for_choice(
&empty,
Some(&ToolChoice::Required),
Some("final_result"),
None,
)
.expect("Required is satisfiable by the output tool");
assert!(allowed.is_empty());
}
#[test]
fn required_with_active_tools_filter_names_the_filter_in_the_error() {
let empty = tool_names(&[]);
let err = allowed_tool_names_for_choice(
&empty,
Some(&ToolChoice::Required),
None,
Some(&tool_names(&["add"])),
)
.expect_err("Required after active_tools filtered everything must fail locally");
let msg = err.to_string();
assert!(
msg.contains("active_tools"),
"error should name active_tools: {msg}"
);
assert!(
msg.contains("RequestPatch"),
"error should suggest RequestPatch: {msg}"
);
}
#[test]
fn specific_naming_a_filtered_out_tool_is_a_local_error_with_hint() {
let executable = tool_names(&["add"]);
let choice = ToolChoice::Specific {
function_names: vec!["subtract".to_string()],
};
let err = allowed_tool_names_for_choice(
&executable,
Some(&choice),
None,
Some(&tool_names(&["add", "subtract"])),
)
.expect_err("Specific naming a filtered-out tool must fail locally");
let msg = err.to_string();
assert!(
msg.contains("subtract"),
"error should name the missing tool: {msg}"
);
assert!(
msg.contains("active_tools"),
"error should name active_tools: {msg}"
);
}
#[test]
fn specific_may_name_the_output_tool() {
let empty = tool_names(&[]);
let choice = ToolChoice::Specific {
function_names: vec!["final_result".to_string()],
};
let allowed =
allowed_tool_names_for_choice(&empty, Some(&choice), Some("final_result"), None)
.expect("Specific naming the output tool is valid");
assert_eq!(allowed, tool_names(&["final_result"]));
}
#[test]
fn specific_typo_is_not_blamed_on_active_tools() {
let executable = tool_names(&["add"]);
let choice = ToolChoice::Specific {
function_names: vec!["nonexistent".to_string()],
};
let err = allowed_tool_names_for_choice(
&executable,
Some(&choice),
None,
Some(&tool_names(&["add"])),
)
.expect_err("Specific naming a non-existent tool must fail locally");
let msg = err.to_string();
assert!(msg.contains("nonexistent"), "error names the typo: {msg}");
assert!(
!msg.contains("active_tools"),
"a plain typo must not be blamed on active_tools: {msg}"
);
}
#[test]
fn resolve_output_mode_without_schema_is_always_native() {
for requested in [
OutputMode::Auto,
OutputMode::Tool,
OutputMode::Native,
OutputMode::Prompted,
] {
assert_eq!(
resolve_output_mode(false, true, true, false, &requested),
OutputMode::Native,
"no schema should force Native for {requested:?}"
);
assert_eq!(
resolve_output_mode(false, false, true, false, &requested),
OutputMode::Native,
);
}
}
#[test]
fn resolve_output_mode_auto_picks_tool_only_when_tools_present() {
assert_eq!(
resolve_output_mode(true, true, true, false, &OutputMode::Auto),
OutputMode::Tool,
);
assert_eq!(
resolve_output_mode(true, false, true, false, &OutputMode::Auto),
OutputMode::Native,
);
}
#[test]
fn resolve_output_mode_auto_keeps_native_when_provider_composes() {
assert_eq!(
resolve_output_mode(true, true, true, true, &OutputMode::Auto),
OutputMode::Native,
);
}
#[test]
fn resolve_output_mode_honors_explicit_choice_with_schema() {
for (requested, expected) in [
(OutputMode::Tool, OutputMode::Tool),
(OutputMode::Native, OutputMode::Native),
(OutputMode::Prompted, OutputMode::Prompted),
] {
assert_eq!(
resolve_output_mode(true, true, true, false, &requested),
expected
);
assert_eq!(
resolve_output_mode(true, false, true, true, &requested),
expected
);
}
}
#[test]
fn resolve_output_mode_degrades_to_native_when_output_tool_not_callable() {
assert_eq!(
resolve_output_mode(true, true, false, false, &OutputMode::Auto),
OutputMode::Native,
);
assert_eq!(
resolve_output_mode(true, true, false, false, &OutputMode::Tool),
OutputMode::Native,
);
assert_eq!(
resolve_output_mode(true, true, false, false, &OutputMode::Prompted),
OutputMode::Prompted,
);
}
#[test]
fn tool_choice_permits_output_tool_only_for_auto_required_or_unset() {
assert!(tool_choice_permits_output_tool(None));
assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Auto)));
assert!(tool_choice_permits_output_tool(Some(&ToolChoice::Required)));
assert!(!tool_choice_permits_output_tool(Some(&ToolChoice::None)));
assert!(!tool_choice_permits_output_tool(Some(
&ToolChoice::Specific {
function_names: vec!["add".to_string()],
}
)));
}
#[test]
fn pick_output_tool_name_defaults_when_unused() {
let executable = tool_names(&["add", "subtract"]);
assert_eq!(pick_output_tool_name(&executable), DEFAULT_OUTPUT_TOOL_NAME);
}
#[test]
fn pick_output_tool_name_avoids_collision_with_real_tools() {
let executable = tool_names(&["final_result"]);
assert_eq!(pick_output_tool_name(&executable), "final_result_1");
let executable = tool_names(&["final_result", "final_result_1"]);
assert_eq!(pick_output_tool_name(&executable), "final_result_2");
}
}