feat(agent): add normalized stop reasons and max-token continuation

This commit is contained in:
xj 2026-03-01 01:48:37 -08:00 committed by Chummy
parent e4fc97f5f2
commit f7167ea485
No known key found for this signature in database
22 changed files with 773 additions and 119 deletions

View File

@ -796,6 +796,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -834,6 +836,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -874,6 +878,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]),
});
@ -915,6 +921,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
crate::providers::ChatResponse {
text: Some("done".into()),
@ -922,6 +930,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
]),
});
@ -964,6 +974,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]),
seen_models: seen_models.clone(),
});

View File

@ -264,6 +264,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let dispatcher = XmlToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
@ -283,6 +285,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let dispatcher = NativeToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);

View File

@ -6,7 +6,8 @@ use crate::memory::{self, Memory, MemoryCategory};
use crate::multimodal;
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
use crate::providers::{
self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
self, ChatMessage, ChatRequest, NormalizedStopReason, Provider, ProviderCapabilityError,
ToolCall,
};
use crate::runtime;
use crate::security::SecurityPolicy;
@ -61,6 +62,16 @@ const STREAM_CHUNK_MIN_CHARS: usize = 80;
/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
const DEFAULT_MAX_TOOL_ITERATIONS: usize = 20;
/// Maximum continuation retries when a provider reports max-token truncation.
const MAX_TOKENS_CONTINUATION_MAX_ATTEMPTS: usize = 3;
/// Absolute safety cap for merged continuation output.
const MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS: usize = 120_000;
/// Deterministic continuation instruction appended as a user message.
const MAX_TOKENS_CONTINUATION_PROMPT: &str = "Previous response was truncated by token limit.\nContinue exactly from where you left off.\nIf you intended a tool call, emit one complete tool call payload only.\nDo not repeat already-sent text.";
/// Notice appended when continuation budget is exhausted before completion.
const MAX_TOKENS_CONTINUATION_NOTICE: &str =
"\n\n[Response may be truncated due to continuation limits. Reply \"continue\" to resume.]";
/// Minimum user-message length (in chars) for auto-save to memory.
/// Matches the channel-side constant in `channels/mod.rs`.
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
@ -559,6 +570,43 @@ fn looks_like_deferred_action_without_tool_call(text: &str) -> bool {
&& CJK_DEFERRED_ACTION_VERB_REGEX.is_match(trimmed)
}
fn merge_continuation_text(existing: &str, next: &str) -> String {
if next.is_empty() {
return existing.to_string();
}
if existing.is_empty() {
return next.to_string();
}
if existing.ends_with(next) {
return existing.to_string();
}
if next.starts_with(existing) {
return next.to_string();
}
format!("{existing}{next}")
}
fn add_optional_u64(lhs: Option<u64>, rhs: Option<u64>) -> Option<u64> {
match (lhs, rhs) {
(Some(left), Some(right)) => Some(left.saturating_add(right)),
(Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
}
}
fn stop_reason_name(reason: &NormalizedStopReason) -> &'static str {
match reason {
NormalizedStopReason::EndTurn => "end_turn",
NormalizedStopReason::ToolCall => "tool_call",
NormalizedStopReason::MaxTokens => "max_tokens",
NormalizedStopReason::ContextWindowExceeded => "context_window_exceeded",
NormalizedStopReason::SafetyBlocked => "safety_blocked",
NormalizedStopReason::Cancelled => "cancelled",
NormalizedStopReason::Unknown(_) => "unknown",
}
}
fn maybe_inject_cron_add_delivery(
tool_name: &str,
tool_args: &mut serde_json::Value,
@ -1340,12 +1388,171 @@ pub(crate) async fn run_tool_call_loop(
parse_issue_detected,
) = match chat_result {
Ok(resp) => {
let (resp_input_tokens, resp_output_tokens) = resp
let mut response_text = resp.text_or_empty().to_string();
let mut native_calls = resp.tool_calls;
let mut reasoning_content = resp.reasoning_content.clone();
let mut stop_reason = resp.stop_reason.clone();
let mut raw_stop_reason = resp.raw_stop_reason.clone();
let (mut resp_input_tokens, mut resp_output_tokens) = resp
.usage
.as_ref()
.map(|u| (u.input_tokens, u.output_tokens))
.unwrap_or((None, None));
if let Some(reason) = stop_reason.as_ref() {
runtime_trace::record_event(
"stop_reason_observed",
Some(channel_name),
Some(provider_name),
Some(active_model.as_str()),
Some(&turn_id),
Some(true),
None,
serde_json::json!({
"iteration": iteration + 1,
"normalized_reason": stop_reason_name(reason),
"raw_reason": raw_stop_reason.clone(),
}),
);
}
let mut continuation_attempts = 0usize;
let mut continuation_termination_reason: Option<&'static str> = None;
let mut continuation_error: Option<String> = None;
while matches!(stop_reason, Some(NormalizedStopReason::MaxTokens))
&& native_calls.is_empty()
&& continuation_attempts < MAX_TOKENS_CONTINUATION_MAX_ATTEMPTS
&& response_text.chars().count() < MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS
{
continuation_attempts += 1;
runtime_trace::record_event(
"continuation_attempt",
Some(channel_name),
Some(provider_name),
Some(active_model.as_str()),
Some(&turn_id),
Some(true),
None,
serde_json::json!({
"iteration": iteration + 1,
"attempt": continuation_attempts,
"output_chars": response_text.chars().count(),
"max_output_chars": MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS,
}),
);
let mut continuation_messages = request_messages.clone();
continuation_messages.push(ChatMessage::assistant(response_text.clone()));
continuation_messages.push(ChatMessage::user(
MAX_TOKENS_CONTINUATION_PROMPT.to_string(),
));
let continuation_future = provider.chat(
ChatRequest {
messages: &continuation_messages,
tools: request_tools,
},
active_model.as_str(),
temperature,
);
let continuation_result = if let Some(token) = cancellation_token.as_ref() {
tokio::select! {
() = token.cancelled() => return Err(ToolLoopCancelled.into()),
result = continuation_future => result,
}
} else {
continuation_future.await
};
let continuation_resp = match continuation_result {
Ok(response) => response,
Err(error) => {
continuation_termination_reason = Some("provider_error");
continuation_error =
Some(crate::providers::sanitize_api_error(&error.to_string()));
break;
}
};
if let Some(usage) = continuation_resp.usage.as_ref() {
resp_input_tokens = add_optional_u64(resp_input_tokens, usage.input_tokens);
resp_output_tokens =
add_optional_u64(resp_output_tokens, usage.output_tokens);
}
let next_text = continuation_resp.text_or_empty().to_string();
response_text = merge_continuation_text(&response_text, &next_text);
if continuation_resp.reasoning_content.is_some() {
reasoning_content = continuation_resp.reasoning_content.clone();
}
if !continuation_resp.tool_calls.is_empty() {
native_calls = continuation_resp.tool_calls;
}
stop_reason = continuation_resp.stop_reason;
raw_stop_reason = continuation_resp.raw_stop_reason;
if let Some(reason) = stop_reason.as_ref() {
runtime_trace::record_event(
"stop_reason_observed",
Some(channel_name),
Some(provider_name),
Some(active_model.as_str()),
Some(&turn_id),
Some(true),
None,
serde_json::json!({
"iteration": iteration + 1,
"continuation_attempt": continuation_attempts,
"normalized_reason": stop_reason_name(reason),
"raw_reason": raw_stop_reason.clone(),
}),
);
}
}
if continuation_attempts > 0 && continuation_termination_reason.is_none() {
continuation_termination_reason =
if matches!(stop_reason, Some(NormalizedStopReason::MaxTokens)) {
if response_text.chars().count()
>= MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS
{
Some("output_cap")
} else {
Some("retry_limit")
}
} else {
Some("completed")
};
}
if let Some(terminal_reason) = continuation_termination_reason {
runtime_trace::record_event(
"continuation_terminated",
Some(channel_name),
Some(provider_name),
Some(active_model.as_str()),
Some(&turn_id),
Some(terminal_reason == "completed"),
continuation_error.as_deref(),
serde_json::json!({
"iteration": iteration + 1,
"attempts": continuation_attempts,
"terminal_reason": terminal_reason,
"output_chars": response_text.chars().count(),
}),
);
}
if continuation_attempts > 0
&& matches!(stop_reason, Some(NormalizedStopReason::MaxTokens))
&& native_calls.is_empty()
&& !response_text.ends_with(MAX_TOKENS_CONTINUATION_NOTICE)
{
response_text.push_str(MAX_TOKENS_CONTINUATION_NOTICE);
}
observer.record_event(&ObserverEvent::LlmResponse {
provider: provider_name.to_string(),
model: active_model.clone(),
@ -1356,12 +1563,11 @@ pub(crate) async fn run_tool_call_loop(
output_tokens: resp_output_tokens,
});
let response_text = resp.text_or_empty().to_string();
// First try native structured tool calls (OpenAI-format).
// Fall back to text-based parsing (XML tags, markdown blocks,
// GLM format) only if the provider returned no native calls —
// this ensures we support both native and prompt-guided models.
let mut calls = parse_structured_tool_calls(&resp.tool_calls);
let mut calls = parse_structured_tool_calls(&native_calls);
let mut parsed_text = String::new();
if calls.is_empty() {
@ -1406,15 +1612,17 @@ pub(crate) async fn run_tool_call_loop(
"input_tokens": resp_input_tokens,
"output_tokens": resp_output_tokens,
"raw_response": scrub_credentials(&response_text),
"native_tool_calls": resp.tool_calls.len(),
"native_tool_calls": native_calls.len(),
"parsed_tool_calls": calls.len(),
"continuation_attempts": continuation_attempts,
"stop_reason": stop_reason.as_ref().map(stop_reason_name),
"raw_stop_reason": raw_stop_reason,
}),
);
// Preserve native tool call IDs in assistant history so role=tool
// follow-up messages can reference the exact call id.
let reasoning_content = resp.reasoning_content.clone();
let assistant_history_content = if resp.tool_calls.is_empty() {
let assistant_history_content = if native_calls.is_empty() {
if use_native_tools {
build_native_assistant_history_from_parsed_calls(
&response_text,
@ -1428,12 +1636,11 @@ pub(crate) async fn run_tool_call_loop(
} else {
build_native_assistant_history(
&response_text,
&resp.tool_calls,
&native_calls,
reasoning_content.as_deref(),
)
};
let native_calls = resp.tool_calls;
(
response_text,
parsed_text,
@ -3223,6 +3430,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}
@ -3233,6 +3442,13 @@ mod tests {
}
impl ScriptedProvider {
fn from_scripted_responses(responses: Vec<ChatResponse>) -> Self {
Self {
responses: Arc::new(Mutex::new(VecDeque::from(responses))),
capabilities: ProviderCapabilities::default(),
}
}
fn from_text_responses(responses: Vec<&str>) -> Self {
let scripted = responses
.into_iter()
@ -3242,12 +3458,11 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
.collect();
Self {
responses: Arc::new(Mutex::new(scripted)),
capabilities: ProviderCapabilities::default(),
}
Self::from_scripted_responses(scripted)
}
fn with_native_tool_support(mut self) -> Self {
@ -4249,6 +4464,140 @@ mod tests {
);
}
#[tokio::test]
async fn run_tool_call_loop_continues_when_stop_reason_is_max_tokens() {
let provider = ScriptedProvider::from_scripted_responses(vec![
ChatResponse {
text: Some("part 1 ".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::MaxTokens),
raw_stop_reason: Some("length".to_string()),
},
ChatResponse {
text: Some("part 2".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::EndTurn),
raw_stop_reason: Some("stop".to_string()),
},
]);
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("continue this"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
)
.await
.expect("max-token continuation should complete");
assert_eq!(result, "part 1 part 2");
assert!(
!result.contains("Response may be truncated"),
"continuation should not emit truncation notice when it ends cleanly"
);
}
#[tokio::test]
async fn run_tool_call_loop_appends_notice_when_continuation_budget_exhausts() {
let provider = ScriptedProvider::from_scripted_responses(vec![
ChatResponse {
text: Some("A".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::MaxTokens),
raw_stop_reason: Some("length".to_string()),
},
ChatResponse {
text: Some("B".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::MaxTokens),
raw_stop_reason: Some("length".to_string()),
},
ChatResponse {
text: Some("C".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::MaxTokens),
raw_stop_reason: Some("length".to_string()),
},
ChatResponse {
text: Some("D".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: Some(NormalizedStopReason::MaxTokens),
raw_stop_reason: Some("length".to_string()),
},
]);
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("long output"),
];
let observer = NoopObserver;
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
)
.await
.expect("continuation should degrade to partial output");
assert!(result.starts_with("ABCD"));
assert!(
result.contains("Response may be truncated due to continuation limits"),
"result should include truncation notice when continuation cap is hit"
);
}
#[tokio::test]
async fn run_tool_call_loop_preserves_failed_tool_error_for_after_hook() {
let provider = ScriptedProvider::from_text_responses(vec![

View File

@ -169,6 +169,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}

View File

@ -96,6 +96,8 @@ impl Provider for ScriptedProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -334,6 +336,8 @@ fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -345,6 +349,8 @@ fn text_response(text: &str) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -358,6 +364,8 @@ fn xml_tool_response(name: &str, args: &str) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -754,6 +762,8 @@ async fn turn_handles_empty_text_response() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
@ -770,6 +780,8 @@ async fn turn_handles_none_text_response() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
@ -796,6 +808,8 @@ async fn turn_preserves_text_alongside_tool_calls() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
text_response("Here are the results"),
]));
@ -1035,6 +1049,8 @@ async fn native_dispatcher_handles_stringified_arguments() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let (_, calls) = dispatcher.parse_response(&response);
@ -1063,6 +1079,8 @@ fn xml_dispatcher_handles_nested_json() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let dispatcher = XmlToolDispatcher;
@ -1083,6 +1101,8 @@ fn xml_dispatcher_handles_empty_tool_call_tag() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let dispatcher = XmlToolDispatcher;
@ -1099,6 +1119,8 @@ fn xml_dispatcher_handles_unclosed_tool_call() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
let dispatcher = XmlToolDispatcher;

View File

@ -1,6 +1,6 @@
use crate::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
NormalizedStopReason, Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
};
use crate::tools::ToolSpec;
use async_trait::async_trait;
@ -139,6 +139,8 @@ struct NativeChatResponse {
#[serde(default)]
content: Vec<NativeContentIn>,
#[serde(default)]
stop_reason: Option<String>,
#[serde(default)]
usage: Option<AnthropicUsage>,
}
@ -416,6 +418,10 @@ impl AnthropicProvider {
fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
let raw_stop_reason = response.stop_reason.clone();
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_anthropic_stop_reason);
let usage = response.usage.map(|u| TokenUsage {
input_tokens: u.input_tokens,
@ -459,6 +465,8 @@ impl AnthropicProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason,
raw_stop_reason,
}
}

View File

@ -6,8 +6,8 @@
use crate::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, ProviderCapabilities, StreamChunk, StreamError, StreamOptions, StreamResult,
TokenUsage, ToolCall as ProviderToolCall, ToolsPayload,
NormalizedStopReason, Provider, ProviderCapabilities, StreamChunk, StreamError, StreamOptions,
StreamResult, TokenUsage, ToolCall as ProviderToolCall, ToolsPayload,
};
use crate::tools::ToolSpec;
use async_trait::async_trait;
@ -512,7 +512,6 @@ struct ConverseResponse {
#[serde(default)]
output: Option<ConverseOutput>,
#[serde(default)]
#[allow(dead_code)]
stop_reason: Option<String>,
#[serde(default)]
usage: Option<BedrockUsage>,
@ -941,6 +940,10 @@ impl BedrockProvider {
fn parse_converse_response(response: ConverseResponse) -> ProviderChatResponse {
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
let raw_stop_reason = response.stop_reason.clone();
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_bedrock_stop_reason);
let usage = response.usage.map(|u| TokenUsage {
input_tokens: u.input_tokens,
@ -982,6 +985,8 @@ impl BedrockProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason,
raw_stop_reason,
}
}

View File

@ -5,8 +5,8 @@
use crate::multimodal;
use crate::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, StreamChunk, StreamError, StreamOptions, StreamResult, TokenUsage,
ToolCall as ProviderToolCall,
NormalizedStopReason, Provider, StreamChunk, StreamError, StreamOptions, StreamResult,
TokenUsage, ToolCall as ProviderToolCall,
};
use async_trait::async_trait;
use futures_util::{stream, SinkExt, StreamExt};
@ -479,6 +479,8 @@ struct UsageInfo {
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
/// Remove `<think>...</think>` blocks from model output.
@ -968,6 +970,8 @@ fn parse_responses_chat_response(response: ResponsesResponse) -> ProviderChatRes
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -1576,7 +1580,12 @@ impl OpenAiCompatibleProvider {
modified_messages
}
fn parse_native_response(message: ResponseMessage) -> ProviderChatResponse {
fn parse_native_response(choice: Choice) -> ProviderChatResponse {
let raw_stop_reason = choice.finish_reason;
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_openai_finish_reason);
let message = choice.message;
let text = message.effective_content_optional();
let reasoning_content = message.reasoning_content.clone();
let tool_calls = message
@ -1611,6 +1620,8 @@ impl OpenAiCompatibleProvider {
usage: None,
reasoning_content,
quota_metadata: None,
stop_reason,
raw_stop_reason,
}
}
@ -1983,6 +1994,8 @@ impl Provider for OpenAiCompatibleProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
};
@ -2030,6 +2043,11 @@ impl Provider for OpenAiCompatibleProvider {
.next()
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
let raw_stop_reason = choice.finish_reason;
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_openai_finish_reason);
let text = choice.message.effective_content_optional();
let reasoning_content = choice.message.reasoning_content;
let tool_calls = choice
@ -2055,6 +2073,8 @@ impl Provider for OpenAiCompatibleProvider {
usage,
reasoning_content,
quota_metadata: None,
stop_reason,
raw_stop_reason,
})
}
@ -2176,14 +2196,13 @@ impl Provider for OpenAiCompatibleProvider {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let message = native_response
let choice = native_response
.choices
.into_iter()
.next()
.map(|choice| choice.message)
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
let mut result = Self::parse_native_response(message);
let mut result = Self::parse_native_response(choice);
result.usage = usage;
Ok(result)
}
@ -2920,26 +2939,31 @@ mod tests {
#[test]
fn parse_native_response_preserves_tool_call_id() {
let message = ResponseMessage {
content: None,
tool_calls: Some(vec![ToolCall {
id: Some("call_123".to_string()),
kind: Some("function".to_string()),
function: Some(Function {
name: Some("shell".to_string()),
arguments: Some(r#"{"command":"pwd"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
reasoning_content: None,
let choice = Choice {
message: ResponseMessage {
content: None,
tool_calls: Some(vec![ToolCall {
id: Some("call_123".to_string()),
kind: Some("function".to_string()),
function: Some(Function {
name: Some("shell".to_string()),
arguments: Some(r#"{"command":"pwd"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
reasoning_content: None,
},
finish_reason: Some("tool_calls".to_string()),
};
let parsed = OpenAiCompatibleProvider::parse_native_response(message);
let parsed = OpenAiCompatibleProvider::parse_native_response(choice);
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].id, "call_123");
assert_eq!(parsed.tool_calls[0].name, "shell");
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::ToolCall));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("tool_calls"));
}
#[test]
@ -3968,39 +3992,49 @@ mod tests {
#[test]
fn parse_native_response_captures_reasoning_content() {
let message = ResponseMessage {
content: Some("answer".to_string()),
reasoning_content: Some("thinking step".to_string()),
tool_calls: Some(vec![ToolCall {
id: Some("call_1".to_string()),
kind: Some("function".to_string()),
function: Some(Function {
name: Some("shell".to_string()),
arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
let choice = Choice {
message: ResponseMessage {
content: Some("answer".to_string()),
reasoning_content: Some("thinking step".to_string()),
tool_calls: Some(vec![ToolCall {
id: Some("call_1".to_string()),
kind: Some("function".to_string()),
function: Some(Function {
name: Some("shell".to_string()),
arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
},
finish_reason: Some("length".to_string()),
};
let parsed = OpenAiCompatibleProvider::parse_native_response(message);
let parsed = OpenAiCompatibleProvider::parse_native_response(choice);
assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
assert_eq!(parsed.text.as_deref(), Some("answer"));
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::MaxTokens));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("length"));
}
#[test]
fn parse_native_response_none_reasoning_content_for_normal_model() {
let message = ResponseMessage {
content: Some("hello".to_string()),
reasoning_content: None,
tool_calls: None,
let choice = Choice {
message: ResponseMessage {
content: Some("hello".to_string()),
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some("stop".to_string()),
};
let parsed = OpenAiCompatibleProvider::parse_native_response(message);
let parsed = OpenAiCompatibleProvider::parse_native_response(choice);
assert!(parsed.reasoning_content.is_none());
assert_eq!(parsed.text.as_deref(), Some("hello"));
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::EndTurn));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("stop"));
}
#[test]

View File

@ -400,6 +400,8 @@ impl CopilotProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}

View File

@ -236,6 +236,8 @@ impl Provider for CursorProvider {
usage: Some(TokenUsage::default()),
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}

View File

@ -5,7 +5,9 @@
//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`)
use crate::auth::AuthService;
use crate::providers::traits::{ChatMessage, ChatResponse, Provider, TokenUsage};
use crate::providers::traits::{
ChatMessage, ChatResponse, NormalizedStopReason, Provider, TokenUsage,
};
use async_trait::async_trait;
use base64::Engine;
use directories::UserDirs;
@ -175,6 +177,8 @@ struct InternalGenerateContentResponse {
struct Candidate {
#[serde(default)]
content: Option<CandidateContent>,
#[serde(default, rename = "finishReason")]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -939,7 +943,12 @@ impl GeminiProvider {
system_instruction: Option<Content>,
model: &str,
temperature: f64,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
) -> anyhow::Result<(
String,
Option<TokenUsage>,
Option<NormalizedStopReason>,
Option<String>,
)> {
let auth = self.auth.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"Gemini API key not found. Options:\n\
@ -1132,14 +1141,21 @@ impl GeminiProvider {
output_tokens: u.candidates_token_count,
});
let text = result
let candidate = result
.candidates
.and_then(|c| c.into_iter().next())
.and_then(|c| c.content)
.ok_or_else(|| anyhow::anyhow!("No response from Gemini"))?;
let raw_stop_reason = candidate.finish_reason.clone();
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_gemini_finish_reason);
let text = candidate
.content
.and_then(|c| c.effective_text())
.ok_or_else(|| anyhow::anyhow!("No response from Gemini"))?;
Ok((text, usage))
Ok((text, usage, stop_reason, raw_stop_reason))
}
}
@ -1166,7 +1182,7 @@ impl Provider for GeminiProvider {
}],
}];
let (text, _usage) = self
let (text, _usage, _stop_reason, _raw_stop_reason) = self
.send_generate_content(contents, system_instruction, model, temperature)
.await?;
Ok(text)
@ -1218,7 +1234,7 @@ impl Provider for GeminiProvider {
})
};
let (text, _usage) = self
let (text, _usage, _stop_reason, _raw_stop_reason) = self
.send_generate_content(contents, system_instruction, model, temperature)
.await?;
Ok(text)
@ -1263,7 +1279,7 @@ impl Provider for GeminiProvider {
})
};
let (text, usage) = self
let (text, usage, stop_reason, raw_stop_reason) = self
.send_generate_content(contents, system_instruction, model, temperature)
.await?;
@ -1273,6 +1289,8 @@ impl Provider for GeminiProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason,
raw_stop_reason,
})
}

View File

@ -39,8 +39,8 @@ pub mod traits;
#[allow(unused_imports)]
pub use traits::{
is_user_or_assistant_role, ChatMessage, ChatRequest, ChatResponse, ConversationMessage,
Provider, ProviderCapabilityError, ToolCall, ToolResultMessage, ROLE_ASSISTANT, ROLE_SYSTEM,
ROLE_TOOL, ROLE_USER,
NormalizedStopReason, Provider, ProviderCapabilityError, ToolCall, ToolResultMessage,
ROLE_ASSISTANT, ROLE_SYSTEM, ROLE_TOOL, ROLE_USER,
};
use crate::auth::AuthService;

View File

@ -650,6 +650,8 @@ impl Provider for OllamaProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
@ -669,6 +671,8 @@ impl Provider for OllamaProvider {
usage,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
@ -717,6 +721,8 @@ impl Provider for OllamaProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}

View File

@ -1,6 +1,6 @@
use crate::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, TokenUsage, ToolCall as ProviderToolCall,
NormalizedStopReason, Provider, TokenUsage, ToolCall as ProviderToolCall,
};
use crate::tools::ToolSpec;
use async_trait::async_trait;
@ -36,6 +36,8 @@ struct ChatResponse {
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -145,6 +147,8 @@ struct UsageInfo {
#[derive(Debug, Deserialize)]
struct NativeChoice {
message: NativeResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -282,7 +286,12 @@ impl OpenAiProvider {
.collect()
}
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
fn parse_native_response(choice: NativeChoice) -> ProviderChatResponse {
let raw_stop_reason = choice.finish_reason;
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_openai_finish_reason);
let message = choice.message;
let text = message.effective_content();
let reasoning_content = message.reasoning_content.clone();
let tool_calls = message
@ -302,6 +311,8 @@ impl OpenAiProvider {
usage: None,
reasoning_content,
quota_metadata: None,
stop_reason,
raw_stop_reason,
}
}
@ -407,13 +418,12 @@ impl Provider for OpenAiProvider {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let message = native_response
let choice = native_response
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
let mut result = Self::parse_native_response(message);
let mut result = Self::parse_native_response(choice);
result.usage = usage;
result.quota_metadata = quota_metadata;
Ok(result)
@ -476,13 +486,12 @@ impl Provider for OpenAiProvider {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let message = native_response
let choice = native_response
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
let mut result = Self::parse_native_response(message);
let mut result = Self::parse_native_response(choice);
result.usage = usage;
result.quota_metadata = quota_metadata;
Ok(result)
@ -773,21 +782,25 @@ mod tests {
"content":"answer",
"reasoning_content":"thinking step",
"tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{}"}}]
}}]}"#;
},"finish_reason":"length"}]}"#;
let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
let message = resp.choices.into_iter().next().unwrap().message;
let parsed = OpenAiProvider::parse_native_response(message);
let choice = resp.choices.into_iter().next().unwrap();
let parsed = OpenAiProvider::parse_native_response(choice);
assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::MaxTokens));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("length"));
}
#[test]
fn parse_native_response_none_reasoning_content_for_normal_model() {
let json = r#"{"choices":[{"message":{"content":"hello"}}]}"#;
let json = r#"{"choices":[{"message":{"content":"hello"},"finish_reason":"stop"}]}"#;
let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
let message = resp.choices.into_iter().next().unwrap().message;
let parsed = OpenAiProvider::parse_native_response(message);
let choice = resp.choices.into_iter().next().unwrap();
let parsed = OpenAiProvider::parse_native_response(choice);
assert!(parsed.reasoning_content.is_none());
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::EndTurn));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("stop"));
}
#[test]

View File

@ -1,7 +1,7 @@
use crate::multimodal;
use crate::providers::traits::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
NormalizedStopReason, Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
};
use crate::tools::ToolSpec;
use async_trait::async_trait;
@ -55,6 +55,8 @@ struct ApiChatResponse {
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -137,6 +139,8 @@ struct UsageInfo {
#[derive(Debug, Deserialize)]
struct NativeChoice {
message: NativeResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -284,7 +288,12 @@ impl OpenRouterProvider {
MessageContent::Parts(parts)
}
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
fn parse_native_response(choice: NativeChoice) -> ProviderChatResponse {
let raw_stop_reason = choice.finish_reason;
let stop_reason = raw_stop_reason
.as_deref()
.map(NormalizedStopReason::from_openai_finish_reason);
let message = choice.message;
let reasoning_content = message.reasoning_content.clone();
let tool_calls = message
.tool_calls
@ -303,6 +312,8 @@ impl OpenRouterProvider {
usage: None,
reasoning_content,
quota_metadata: None,
stop_reason,
raw_stop_reason,
}
}
@ -487,13 +498,12 @@ impl Provider for OpenRouterProvider {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let message = native_response
let choice = native_response
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
let mut result = Self::parse_native_response(message);
let mut result = Self::parse_native_response(choice);
result.usage = usage;
Ok(result)
}
@ -582,13 +592,12 @@ impl Provider for OpenRouterProvider {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
});
let message = native_response
let choice = native_response
.choices
.into_iter()
.next()
.map(|c| c.message)
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
let mut result = Self::parse_native_response(message);
let mut result = Self::parse_native_response(choice);
result.usage = usage;
Ok(result)
}
@ -828,25 +837,30 @@ mod tests {
#[test]
fn parse_native_response_converts_to_chat_response() {
let message = NativeResponseMessage {
content: Some("Here you go.".into()),
reasoning_content: None,
tool_calls: Some(vec![NativeToolCall {
id: Some("call_789".into()),
kind: Some("function".into()),
function: NativeFunctionCall {
name: "file_read".into(),
arguments: r#"{"path":"test.txt"}"#.into(),
},
}]),
let choice = NativeChoice {
message: NativeResponseMessage {
content: Some("Here you go.".into()),
reasoning_content: None,
tool_calls: Some(vec![NativeToolCall {
id: Some("call_789".into()),
kind: Some("function".into()),
function: NativeFunctionCall {
name: "file_read".into(),
arguments: r#"{"path":"test.txt"}"#.into(),
},
}]),
},
finish_reason: Some("stop".into()),
};
let response = OpenRouterProvider::parse_native_response(message);
let response = OpenRouterProvider::parse_native_response(choice);
assert_eq!(response.text.as_deref(), Some("Here you go."));
assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].id, "call_789");
assert_eq!(response.tool_calls[0].name, "file_read");
assert_eq!(response.stop_reason, Some(NormalizedStopReason::EndTurn));
assert_eq!(response.raw_stop_reason.as_deref(), Some("stop"));
}
#[test]
@ -942,32 +956,42 @@ mod tests {
#[test]
fn parse_native_response_captures_reasoning_content() {
let message = NativeResponseMessage {
content: Some("answer".into()),
reasoning_content: Some("thinking step".into()),
tool_calls: Some(vec![NativeToolCall {
id: Some("call_1".into()),
kind: Some("function".into()),
function: NativeFunctionCall {
name: "shell".into(),
arguments: "{}".into(),
},
}]),
let choice = NativeChoice {
message: NativeResponseMessage {
content: Some("answer".into()),
reasoning_content: Some("thinking step".into()),
tool_calls: Some(vec![NativeToolCall {
id: Some("call_1".into()),
kind: Some("function".into()),
function: NativeFunctionCall {
name: "shell".into(),
arguments: "{}".into(),
},
}]),
},
finish_reason: Some("length".into()),
};
let parsed = OpenRouterProvider::parse_native_response(message);
let parsed = OpenRouterProvider::parse_native_response(choice);
assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::MaxTokens));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("length"));
}
#[test]
fn parse_native_response_none_reasoning_content_for_normal_model() {
let message = NativeResponseMessage {
content: Some("hello".into()),
reasoning_content: None,
tool_calls: None,
let choice = NativeChoice {
message: NativeResponseMessage {
content: Some("hello".into()),
reasoning_content: None,
tool_calls: None,
},
finish_reason: Some("stop".into()),
};
let parsed = OpenRouterProvider::parse_native_response(message);
let parsed = OpenRouterProvider::parse_native_response(choice);
assert!(parsed.reasoning_content.is_none());
assert_eq!(parsed.stop_reason, Some(NormalizedStopReason::EndTurn));
assert_eq!(parsed.raw_stop_reason.as_deref(), Some("stop"));
}
#[test]

View File

@ -1876,6 +1876,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}
@ -2070,6 +2072,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}

View File

@ -65,6 +65,65 @@ pub struct TokenUsage {
pub output_tokens: Option<u64>,
}
/// Provider-agnostic stop reasons used by the agent loop.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum NormalizedStopReason {
EndTurn,
ToolCall,
MaxTokens,
ContextWindowExceeded,
SafetyBlocked,
Cancelled,
Unknown(String),
}
impl NormalizedStopReason {
pub fn from_openai_finish_reason(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"stop" => Self::EndTurn,
"tool_calls" | "function_call" => Self::ToolCall,
"length" | "max_tokens" => Self::MaxTokens,
"content_filter" => Self::SafetyBlocked,
"cancelled" | "canceled" => Self::Cancelled,
_ => Self::Unknown(raw.trim().to_string()),
}
}
pub fn from_anthropic_stop_reason(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"end_turn" | "stop_sequence" => Self::EndTurn,
"tool_use" => Self::ToolCall,
"max_tokens" => Self::MaxTokens,
"model_context_window_exceeded" => Self::ContextWindowExceeded,
"safety" => Self::SafetyBlocked,
"cancelled" | "canceled" => Self::Cancelled,
_ => Self::Unknown(raw.trim().to_string()),
}
}
pub fn from_bedrock_stop_reason(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"end_turn" => Self::EndTurn,
"tool_use" => Self::ToolCall,
"max_tokens" => Self::MaxTokens,
"guardrail_intervened" => Self::SafetyBlocked,
"cancelled" | "canceled" => Self::Cancelled,
_ => Self::Unknown(raw.trim().to_string()),
}
}
pub fn from_gemini_finish_reason(raw: &str) -> Self {
match raw.trim().to_ascii_uppercase().as_str() {
"STOP" => Self::EndTurn,
"MAX_TOKENS" => Self::MaxTokens,
"SAFETY" | "RECITATION" => Self::SafetyBlocked,
"CANCELLED" => Self::Cancelled,
_ => Self::Unknown(raw.trim().to_string()),
}
}
}
/// An LLM response that may contain text, tool calls, or both.
#[derive(Debug, Clone)]
pub struct ChatResponse {
@ -82,6 +141,10 @@ pub struct ChatResponse {
/// Quota metadata extracted from response headers (if available).
/// Populated by providers that support quota tracking.
pub quota_metadata: Option<super::quota_types::QuotaMetadata>,
/// Normalized provider stop reason (if surfaced by the upstream API).
pub stop_reason: Option<NormalizedStopReason>,
/// Raw provider-native stop reason string for diagnostics.
pub raw_stop_reason: Option<String>,
}
impl ChatResponse {
@ -376,6 +439,8 @@ pub trait Provider: Send + Sync {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
}
@ -389,6 +454,8 @@ pub trait Provider: Send + Sync {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
@ -425,6 +492,8 @@ pub trait Provider: Send + Sync {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
@ -555,6 +624,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert!(!empty.has_tool_calls());
assert_eq!(empty.text_or_empty(), "");
@ -569,6 +640,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert!(with_tools.has_tool_calls());
assert_eq!(with_tools.text_or_empty(), "Let me check");
@ -592,6 +665,8 @@ mod tests {
}),
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert_eq!(resp.usage.as_ref().unwrap().input_tokens, Some(100));
assert_eq!(resp.usage.as_ref().unwrap().output_tokens, Some(50));
@ -661,6 +736,30 @@ mod tests {
assert!(provider.supports_vision());
}
#[test]
fn normalized_stop_reason_mappings_cover_core_provider_values() {
assert_eq!(
NormalizedStopReason::from_openai_finish_reason("length"),
NormalizedStopReason::MaxTokens
);
assert_eq!(
NormalizedStopReason::from_openai_finish_reason("tool_calls"),
NormalizedStopReason::ToolCall
);
assert_eq!(
NormalizedStopReason::from_anthropic_stop_reason("model_context_window_exceeded"),
NormalizedStopReason::ContextWindowExceeded
);
assert_eq!(
NormalizedStopReason::from_bedrock_stop_reason("guardrail_intervened"),
NormalizedStopReason::SafetyBlocked
);
assert_eq!(
NormalizedStopReason::from_gemini_finish_reason("MAX_TOKENS"),
NormalizedStopReason::MaxTokens
);
}
#[test]
fn tools_payload_variants() {
// Test Gemini variant

View File

@ -881,6 +881,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
} else {
Ok(ChatResponse {
@ -893,6 +895,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}
@ -928,6 +932,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
})
}
}

View File

@ -936,6 +936,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -997,6 +999,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
// Turn 1 continued: provider sees tool result and answers
ChatResponse {
@ -1005,6 +1009,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
]);
@ -1092,6 +1098,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
ChatResponse {
text: Some("The file appears to be binary data.".into()),
@ -1099,6 +1107,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
]);

View File

@ -67,6 +67,8 @@ impl Provider for MockProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -194,6 +196,8 @@ impl Provider for RecordingProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -244,6 +248,8 @@ fn text_response(text: &str) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -254,6 +260,8 @@ fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -380,6 +388,8 @@ async fn e2e_xml_dispatcher_tool_call() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
},
text_response("XML tool executed"),
]));
@ -1019,6 +1029,8 @@ async fn e2e_agent_research_prompt_guided() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -1038,6 +1050,8 @@ async fn e2e_agent_research_prompt_guided() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
// Response 2: Research complete
@ -1047,6 +1061,8 @@ async fn e2e_agent_research_prompt_guided() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
// Response 3: Main turn response

View File

@ -62,6 +62,8 @@ impl Provider for MockProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@ -185,6 +187,8 @@ fn text_response(text: &str) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -195,6 +199,8 @@ fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}
}
@ -365,6 +371,8 @@ async fn agent_handles_empty_provider_response() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
@ -381,6 +389,8 @@ async fn agent_handles_none_text_response() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
}]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);

View File

@ -156,6 +156,8 @@ fn chat_response_text_only() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert_eq!(resp.text_or_empty(), "Hello world");
@ -174,6 +176,8 @@ fn chat_response_with_tool_calls() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert!(resp.has_tool_calls());
@ -189,6 +193,8 @@ fn chat_response_text_or_empty_handles_none() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert_eq!(resp.text_or_empty(), "");
@ -213,6 +219,8 @@ fn chat_response_multiple_tool_calls() {
usage: None,
reasoning_content: None,
quota_metadata: None,
stop_reason: None,
raw_stop_reason: None,
};
assert!(resp.has_tool_calls());