feat(ai): unify model discovery and effort handling
This commit is contained in:
1 parent
c54b95e274
commit
c7e7b7c9d9
41 files changed
+3291
-634
No files matched your search
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user