feat(ai): unify model discovery and effort handling

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-07-28 21:37:55 +08:00
1 parent c54b95e274
commit c7e7b7c9d9
41 files changed
+3291 -634

No files matched your search

+127 -15
View File
@@ -84,14 +84,33 @@ pub struct AgentLoopContext {
pub max_agent_turns: u32,
}
/// Check if the provider supports function calling / tool use.
/// Returns false for providers that are known to lack reliable tool support.
fn provider_supports_function_calling(config: &AiConfig) -> bool {
match config.provider {
// Ollama function calling support varies by model/version; conservative default is false.
// Users with capable models can override via openai-compatible with an Ollama endpoint.
AiProvider::Ollama => false,
_ => true,
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FunctionCallingSupport {
Supported,
Unsupported,
Unknown,
}
/// Resolve function calling support for the selected provider/model.
///
/// Native Ollama capabilities are model-specific. Missing metadata is kept as
/// unknown so older servers and compatible proxies can still attempt tools.
async fn provider_function_calling_support(config: &AiConfig) -> FunctionCallingSupport {
if !matches!(config.provider, AiProvider::Ollama) {
return FunctionCallingSupport::Supported;
}
match ai::ollama_selected_model_tool_support(config).await {
Ok(Some(true)) => FunctionCallingSupport::Supported,
Ok(Some(false)) => FunctionCallingSupport::Unsupported,
Ok(None) => FunctionCallingSupport::Unknown,
Err(error) => {
log::debug!(
"[agent][ollama] tool capability unavailable for model {}: {error}; attempting tool call",
config.model
);
FunctionCallingSupport::Unknown
}
}
}
@@ -100,8 +119,9 @@ fn provider_supports_function_calling(config: &AiConfig) -> bool {
/// The `on_event` callback receives streaming events for the frontend.
/// Returns the final accumulated assistant text.
///
/// If the provider does not support function calling (e.g., Ollama), automatically
/// degrades to a text-only completion with schema context injected into the system prompt.
/// If the selected model explicitly does not support function calling,
/// automatically degrades to a text-only completion with schema context
/// injected into the system prompt.
#[allow(clippy::too_many_arguments)]
pub async fn run_agent_loop(
config: &AiConfig,
@@ -149,8 +169,19 @@ pub async fn run_agent_loop(
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
}
// Auto-degrade: providers without function calling fall back to text-only completion.
if !provider_supports_function_calling(config) {
// Auto-degrade only models that explicitly advertise no tool support.
// Unknown Ollama capabilities still get a chance to use tools so older
// servers and compatible proxies are not disabled provider-wide.
let function_calling_support = tokio::select! {
support = provider_function_calling_support(config) => support,
_ = cancelled.notified() => {
let message = "Agent run was cancelled before producing output.".to_string();
on_event(AgentEvent::TextDelta { delta: message.clone() });
on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
return Ok(message);
}
};
if function_calling_support == FunctionCallingSupport::Unsupported {
return run_agent_loop_text_only(
config,
system_prompt,
@@ -248,6 +279,29 @@ pub async fn run_agent_loop(
stream_result = Some((tool_calls, usage, accumulated_text));
break;
}
Err(err)
if turn == 0
&& matches!(config.provider, AiProvider::Ollama)
&& is_tool_unsupported_error(&err)
&& !emitted_any_chunk.load(Ordering::Relaxed) =>
{
log::debug!(
"[agent][ollama] model {} rejected tools before producing output; falling back to text-only mode",
config.model
);
on_event(AgentEvent::TurnEnd { turn });
return run_agent_loop_text_only(
config,
system_prompt,
messages,
agent_ctx,
on_event,
cancelled,
max_tokens,
task_contract.as_ref(),
)
.await;
}
Err(err)
if attempt == 0 && is_context_length_error(&err) && !emitted_any_chunk.load(Ordering::Relaxed) =>
{
@@ -315,7 +369,12 @@ pub async fn run_agent_loop(
tool_call_id: None,
tool_calls: collected_tool_calls
.iter()
.map(|tc| ai::ToolCallRef { id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone() })
.map(|tc| ai::ToolCallRef {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
provider_payload: tc.provider_payload.clone(),
})
.collect(),
});
@@ -377,8 +436,12 @@ pub async fn run_agent_loop(
let (parallel_indices, sequential_indices): (Vec<usize>, Vec<usize>) = (0..collected_tool_calls.len())
.partition(|&i| *tool_parallel_map.get(collected_tool_calls[i].name.as_str()).unwrap_or(&false));
let make_tc =
|tc: &ToolCall| ToolCall { id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone() };
let make_tc = |tc: &ToolCall| ToolCall {
id: tc.id.clone(),
name: tc.name.clone(),
arguments: tc.arguments.clone(),
provider_payload: tc.provider_payload.clone(),
};
// Run parallel group
let parallel_futures: Vec<_> = parallel_indices
@@ -692,6 +755,24 @@ fn is_context_length_error(error: &str) -> bool {
.any(|marker| lower.contains(marker))
}
fn is_tool_unsupported_error(error: &str) -> bool {
let lower = error.to_lowercase();
let mentions_tool_use = lower.contains("tool") || lower.contains("function call");
let rejects_capability = [
"does not support",
"doesn't support",
"not supported",
"unsupported",
"unknown field",
"unknown parameter",
"unrecognized field",
"unrecognized parameter",
]
.iter()
.any(|marker| lower.contains(marker));
mentions_tool_use && rejects_capability
}
/// Text-only fallback for providers that don't support function calling.
///
/// Injects database schema context into the system prompt so the LLM can still
@@ -836,6 +917,11 @@ fn estimate_message_tokens(message: &AiMessage) -> u32 {
if let Ok(args) = serde_json::to_string(&tool_call.arguments) {
tokens += estimate_text_tokens(&args);
}
if let Some(provider_payload) = &tool_call.provider_payload {
if let Ok(payload) = serde_json::to_string(provider_payload) {
tokens += estimate_text_tokens(&payload);
}
}
}
tokens
@@ -1201,6 +1287,32 @@ mod tests {
assert_eq!(clamp_max_agent_turns(u32::MAX), MAX_MAX_AGENT_TURNS);
}
#[test]
fn identifies_explicit_tool_rejection_errors() {
for error in [
"model does not support tools",
"tool use is not supported by this model",
"unsupported parameter: tools",
"unknown field `tools`",
"function calling is not supported",
] {
assert!(is_tool_unsupported_error(error), "{error}");
}
}
#[test]
fn does_not_hide_unrelated_ollama_errors_as_tool_rejections() {
for error in [
"connection refused",
"model not found",
"context length exceeded",
"invalid tool arguments returned by model",
"request timed out",
] {
assert!(!is_tool_unsupported_error(error), "{error}");
}
}
fn generate_contract(user_request: &str, mode: &str) -> AiTaskContract {
AiTaskContract {
action: Some("generate".to_string()),