enable gemma 4 local model (#8366)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+6
-6
@@ -1724,9 +1724,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
version = "1.2.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -5860,9 +5860,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "llama-cpp-2"
|
||||
version = "0.1.140"
|
||||
version = "0.1.142"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5604c13b9c847157470479a64d1d7c94f3089709309f82f2fdcbcd43510f2f2"
|
||||
checksum = "9f04fe7a4b6836bff1966eb8c35e5e121f573641887526d9f997e9c10a64d1bc"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"enumflags2",
|
||||
@@ -5874,9 +5874,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "llama-cpp-sys-2"
|
||||
version = "0.1.140"
|
||||
version = "0.1.142"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbdd3e2c06f3a9a47466a631735946e9ad47fef565b88bc8766a3794474a66f3"
|
||||
checksum = "95ef4708a34c686a2686f9a57050df3b5384ff346dcbfb8ee47b5814f4dcfc90"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
|
||||
@@ -13,8 +13,9 @@ use goose::providers::local_inference::{
|
||||
available_inference_memory_bytes,
|
||||
hf_models::{resolve_model_spec, HfGgufFile},
|
||||
local_model_registry::{
|
||||
get_registry, is_featured_model, model_id_from_repo, LocalModelEntry,
|
||||
ModelDownloadStatus as RegistryDownloadStatus, ModelSettings, FEATURED_MODELS,
|
||||
default_settings_for_model, get_registry, is_featured_model, model_id_from_repo,
|
||||
LocalModelEntry, ModelDownloadStatus as RegistryDownloadStatus, ModelSettings,
|
||||
FEATURED_MODELS,
|
||||
},
|
||||
recommend_local_model,
|
||||
};
|
||||
@@ -51,8 +52,8 @@ pub struct LocalModelResponse {
|
||||
async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
let mut entries_to_add = Vec::new();
|
||||
|
||||
for spec in FEATURED_MODELS {
|
||||
let (repo_id, quantization) = match hf_models::parse_model_spec(spec) {
|
||||
for featured in FEATURED_MODELS {
|
||||
let (repo_id, quantization) = match hf_models::parse_model_spec(featured.spec) {
|
||||
Ok(parts) => parts,
|
||||
Err(_) => continue,
|
||||
};
|
||||
@@ -68,7 +69,7 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
let hf_file = match resolve_model_spec(spec).await {
|
||||
let hf_file = match resolve_model_spec(featured.spec).await {
|
||||
Ok((_repo, file)) => file,
|
||||
Err(_) => {
|
||||
let filename = format!(
|
||||
@@ -91,13 +92,13 @@ async fn ensure_featured_models_in_registry() -> Result<(), ErrorResponse> {
|
||||
let local_path = Paths::in_data_dir("models").join(&hf_file.filename);
|
||||
|
||||
entries_to_add.push(LocalModelEntry {
|
||||
id: model_id,
|
||||
id: model_id.clone(),
|
||||
repo_id,
|
||||
filename: hf_file.filename,
|
||||
quantization,
|
||||
local_path,
|
||||
source_url: hf_file.download_url,
|
||||
settings: ModelSettings::default(),
|
||||
settings: default_settings_for_model(&model_id),
|
||||
size_bytes: hf_file.size_bytes,
|
||||
});
|
||||
}
|
||||
@@ -273,7 +274,7 @@ pub async fn download_hf_model(
|
||||
quantization,
|
||||
local_path: local_path.clone(),
|
||||
source_url: download_url.clone(),
|
||||
settings: ModelSettings::default(),
|
||||
settings: default_settings_for_model(&model_id),
|
||||
size_bytes: hf_file.size_bytes,
|
||||
};
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ tree-sitter-typescript = { workspace = true }
|
||||
which = { workspace = true }
|
||||
pctx_code_mode = { version = "^0.3.0", optional = true }
|
||||
pulldown-cmark = "0.13.0"
|
||||
llama-cpp-2 = { version = "0.1.137", features = ["sampler"], optional = true }
|
||||
llama-cpp-2 = { version = "0.1.142", features = ["sampler"], optional = true }
|
||||
encoding_rs = "0.8.35"
|
||||
pastey = "0.2.1"
|
||||
shell-words = { workspace = true }
|
||||
@@ -197,7 +197,7 @@ keyring = { version = "3.6.2", features = ["windows-native"] }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
candle-core = { version = "0.9", default-features = false, features = ["metal"], optional = true }
|
||||
candle-nn = { version = "0.9", default-features = false, features = ["metal"], optional = true }
|
||||
llama-cpp-2 = { version = "0.1.137", features = ["sampler", "metal"], optional = true }
|
||||
llama-cpp-2 = { version = "0.1.142", features = ["sampler", "metal"], optional = true }
|
||||
keyring = { version = "3.6.2", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -191,7 +191,7 @@ pub fn recommend_local_model(runtime: &InferenceRuntime) -> String {
|
||||
}
|
||||
|
||||
// Fallback to first featured model
|
||||
FEATURED_MODELS[0].to_string()
|
||||
FEATURED_MODELS[0].spec.to_string()
|
||||
}
|
||||
|
||||
fn build_openai_messages_json(system: &str, messages: &[Message]) -> String {
|
||||
@@ -360,7 +360,7 @@ impl LocalInferenceProvider {
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Model loaded successfully");
|
||||
tracing::info!(model_id = model_id, "Model loaded successfully");
|
||||
|
||||
Ok(LoadedModel { model, template })
|
||||
}
|
||||
@@ -377,7 +377,7 @@ impl ProviderDef for LocalInferenceProvider {
|
||||
get_registry, FEATURED_MODELS,
|
||||
};
|
||||
|
||||
let mut known_models: Vec<&str> = FEATURED_MODELS.to_vec();
|
||||
let mut known_models: Vec<&str> = FEATURED_MODELS.iter().map(|m| m.spec).collect();
|
||||
|
||||
// Add any registry models not already in the featured list
|
||||
let mut dynamic_models = Vec::new();
|
||||
@@ -477,13 +477,10 @@ impl Provider for LocalInferenceProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Models that support native OpenAI-compatible tool-call JSON use the
|
||||
// native path (template-based tool calling with JSON output). All other
|
||||
// models use the emulator which parses `$ command` and ```execute blocks.
|
||||
// Only use emulator when there are actually tools to emulate - utility calls
|
||||
// like compaction and session naming pass empty tools and should preserve
|
||||
// their system prompts.
|
||||
let use_emulator = !model_settings.native_tool_calling && !tools.is_empty();
|
||||
// Use the model's native_tool_calling setting to decide the path.
|
||||
// Featured models have this set explicitly; user-added models default to false.
|
||||
let native_tool_calling = model_settings.native_tool_calling;
|
||||
let use_emulator = !native_tool_calling && !tools.is_empty();
|
||||
let system_prompt = if use_emulator {
|
||||
load_tiny_model_prompt()
|
||||
} else {
|
||||
@@ -539,7 +536,7 @@ impl Provider for LocalInferenceProvider {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let oai_messages_json = if model_settings.use_jinja {
|
||||
let oai_messages_json = if model_settings.use_jinja || native_tool_calling {
|
||||
Some(build_openai_messages_json(&system_prompt, messages))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use crate::conversation::message::Message;
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::providers::errors::ProviderError;
|
||||
use llama_cpp_2::model::AddBos;
|
||||
use llama_cpp_2::openai::OpenAIChatTemplateParams;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::finalize_usage;
|
||||
use super::inference_engine::{
|
||||
context_cap, create_and_prefill_context, estimate_max_context_for_memory, generation_loop,
|
||||
validate_and_compute_context, GenerationContext, TokenAction,
|
||||
};
|
||||
use super::tool_parsing::{
|
||||
extract_tool_call_messages, extract_xml_tool_call_messages, safe_stream_end,
|
||||
split_content_and_tool_calls, split_content_and_xml_tool_calls,
|
||||
};
|
||||
|
||||
pub(super) fn generate_with_native_tools(
|
||||
ctx: &mut GenerationContext<'_>,
|
||||
@@ -105,7 +105,20 @@ pub(super) fn generate_with_native_tools(
|
||||
let message_id = ctx.message_id;
|
||||
let tx = ctx.tx;
|
||||
let mut generated_text = String::new();
|
||||
let mut streamed_len: usize = 0;
|
||||
|
||||
// Initialize streaming parser — handles thinking tokens, tool calls, etc.
|
||||
let mut stream_parser = template_result.streaming_state_oaicompat().map_err(|e| {
|
||||
ProviderError::ExecutionError(format!("Failed to init streaming parser: {}", e))
|
||||
})?;
|
||||
|
||||
// Feed the generation prompt to the parser so it knows the context.
|
||||
// The model may echo this prefix; the parser needs to see it to strip it.
|
||||
if !template_result.generation_prompt.is_empty() {
|
||||
let _ = stream_parser.update(&template_result.generation_prompt, true);
|
||||
}
|
||||
|
||||
// Accumulate tool calls across streaming deltas
|
||||
let mut accumulated_tool_calls: Vec<Value> = Vec::new();
|
||||
|
||||
let output_token_count = generation_loop(
|
||||
&ctx.loaded.model,
|
||||
@@ -116,28 +129,35 @@ pub(super) fn generate_with_native_tools(
|
||||
|piece| {
|
||||
generated_text.push_str(piece);
|
||||
|
||||
let has_xml_tc = split_content_and_xml_tool_calls(&generated_text).is_some();
|
||||
let (content, tc) = split_content_and_tool_calls(&generated_text);
|
||||
let stream_up_to = if tc.is_some() {
|
||||
content.len()
|
||||
} else if has_xml_tc {
|
||||
split_content_and_xml_tool_calls(&generated_text)
|
||||
.map(|(c, _)| c.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
safe_stream_end(&generated_text)
|
||||
};
|
||||
if stream_up_to > streamed_len {
|
||||
#[allow(clippy::string_slice)]
|
||||
let new_text = &generated_text[streamed_len..stream_up_to];
|
||||
if !new_text.is_empty() {
|
||||
let mut msg = Message::assistant().with_text(new_text);
|
||||
msg.id = Some(message_id.to_string());
|
||||
if tx.blocking_send(Ok((Some(msg), None))).is_err() {
|
||||
return Ok(TokenAction::Stop);
|
||||
// Feed the new piece to the streaming parser
|
||||
match stream_parser.update(piece, true) {
|
||||
Ok(deltas) => {
|
||||
for delta_json in deltas {
|
||||
if let Ok(delta) = serde_json::from_str::<Value>(&delta_json) {
|
||||
// Stream content text to the UI
|
||||
if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
|
||||
if !content.is_empty() {
|
||||
let mut msg = Message::assistant().with_text(content);
|
||||
msg.id = Some(message_id.to_string());
|
||||
if tx.blocking_send(Ok((Some(msg), None))).is_err() {
|
||||
return Ok(TokenAction::Stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accumulate tool call deltas
|
||||
if let Some(tool_calls) =
|
||||
delta.get("tool_calls").and_then(|v| v.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
accumulated_tool_calls.push(tc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
streamed_len = stream_up_to;
|
||||
Err(e) => {
|
||||
tracing::warn!("Streaming parser error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let should_stop = template_result
|
||||
@@ -152,35 +172,29 @@ pub(super) fn generate_with_native_tools(
|
||||
},
|
||||
)?;
|
||||
|
||||
let (content, tool_call_msgs) =
|
||||
if let Some((xml_content, xml_calls)) = split_content_and_xml_tool_calls(&generated_text) {
|
||||
let msgs = extract_xml_tool_call_messages(xml_calls, message_id);
|
||||
(xml_content, msgs)
|
||||
} else {
|
||||
let (json_content, tool_calls_json) = split_content_and_tool_calls(&generated_text);
|
||||
let msgs = tool_calls_json
|
||||
.map(|tc| extract_tool_call_messages(&tc, message_id))
|
||||
.unwrap_or_default();
|
||||
(json_content, msgs)
|
||||
};
|
||||
|
||||
if content.len() > streamed_len {
|
||||
#[allow(clippy::string_slice)]
|
||||
let remaining = &content[streamed_len..];
|
||||
if !remaining.is_empty() {
|
||||
let mut msg = Message::assistant().with_text(remaining);
|
||||
msg.id = Some(message_id.to_string());
|
||||
let _ = tx.blocking_send(Ok((Some(msg), None)));
|
||||
// Finalize the streaming parser with is_partial=false
|
||||
if let Ok(final_deltas) = stream_parser.update("", false) {
|
||||
for delta_json in final_deltas {
|
||||
if let Ok(delta) = serde_json::from_str::<Value>(&delta_json) {
|
||||
if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
|
||||
if !content.is_empty() {
|
||||
let mut msg = Message::assistant().with_text(content);
|
||||
msg.id = Some(message_id.to_string());
|
||||
let _ = tx.blocking_send(Ok((Some(msg), None)));
|
||||
}
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for tc in tool_calls {
|
||||
accumulated_tool_calls.push(tc.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !tool_call_msgs.is_empty() {
|
||||
for msg in tool_call_msgs {
|
||||
let _ = tx.blocking_send(Ok((Some(msg), None)));
|
||||
}
|
||||
} else if content.is_empty() && !generated_text.is_empty() {
|
||||
let mut msg = Message::assistant().with_text(&generated_text);
|
||||
msg.id = Some(message_id.to_string());
|
||||
// Convert accumulated tool calls to messages
|
||||
let tool_call_msgs = extract_oai_tool_call_messages(&accumulated_tool_calls, message_id);
|
||||
for msg in tool_call_msgs {
|
||||
let _ = tx.blocking_send(Ok((Some(msg), None)));
|
||||
}
|
||||
|
||||
@@ -195,3 +209,194 @@ pub(super) fn generate_with_native_tools(
|
||||
let _ = ctx.tx.blocking_send(Ok((None, Some(provider_usage))));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge OpenAI streaming deltas by `index` into complete tool calls, then
|
||||
/// convert to Goose Message objects.
|
||||
///
|
||||
/// The streaming parser emits partial deltas like:
|
||||
/// {"tool_calls": [{"index": 0, "id": "abc", "function": {"name": "shell"}}]}
|
||||
/// {"tool_calls": [{"index": 0, "function": {"arguments": "{\"command\":"}}]}
|
||||
/// {"tool_calls": [{"index": 0, "function": {"arguments": " \"ls\"}"}}]}
|
||||
///
|
||||
/// These must be merged by `index` before extracting complete tool calls.
|
||||
fn extract_oai_tool_call_messages(deltas: &[Value], message_id: &str) -> Vec<Message> {
|
||||
let mut merged: std::collections::BTreeMap<u64, (String, String, String)> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for delta in deltas {
|
||||
let index = delta.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let entry = merged
|
||||
.entry(index)
|
||||
.or_insert_with(|| (String::new(), String::new(), String::new()));
|
||||
|
||||
if let Some(id) = delta.get("id").and_then(|v| v.as_str()) {
|
||||
if !id.is_empty() {
|
||||
entry.0 = id.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(func) = delta.get("function") {
|
||||
if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
|
||||
if !name.is_empty() {
|
||||
entry.1 = name.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(args) = func.get("arguments").and_then(|v| v.as_str()) {
|
||||
entry.2.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
.into_values()
|
||||
.filter_map(|(id, name, args_str)| {
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = if id.is_empty() {
|
||||
Uuid::new_v4().to_string()
|
||||
} else {
|
||||
id
|
||||
};
|
||||
|
||||
let arguments: Option<serde_json::Map<String, Value>> = if args_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match serde_json::from_str(&args_str) {
|
||||
Ok(args) => Some(args),
|
||||
Err(_) => return None,
|
||||
}
|
||||
};
|
||||
|
||||
let tool_call = match arguments {
|
||||
Some(args) => CallToolRequestParams::new(Cow::Owned(name)).with_arguments(args),
|
||||
None => CallToolRequestParams::new(Cow::Owned(name)),
|
||||
};
|
||||
|
||||
let mut msg = Message::assistant();
|
||||
msg.content
|
||||
.push(MessageContent::tool_request(id, Ok(tool_call)));
|
||||
msg.id = Some(message_id.to_string());
|
||||
Some(msg)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn get_tool_call_name(msg: &Message) -> &str {
|
||||
match &msg.content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
let call = req.tool_call.as_ref().unwrap();
|
||||
&call.name
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tool_call_args(msg: &Message) -> Option<&serde_json::Map<String, Value>> {
|
||||
match &msg.content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
let call = req.tool_call.as_ref().unwrap();
|
||||
call.arguments.as_ref()
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_streaming_deltas() {
|
||||
// Simulates OpenAI streaming: name in first delta, arguments split across multiple
|
||||
let deltas = vec![
|
||||
json!({"index": 0, "id": "call_1", "type": "function", "function": {"name": "developer__shell", "arguments": ""}}),
|
||||
json!({"index": 0, "function": {"arguments": "{\"command\":"}}),
|
||||
json!({"index": 0, "function": {"arguments": " \"ls\"}"}}),
|
||||
];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(get_tool_call_name(&msgs[0]), "developer__shell");
|
||||
let args = get_tool_call_args(&msgs[0]).unwrap();
|
||||
assert_eq!(args.get("command").unwrap(), "ls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_tool_calls_by_index() {
|
||||
let deltas = vec![
|
||||
json!({"index": 0, "id": "call_1", "function": {"name": "developer__shell", "arguments": "{\"command\": \"ls\"}"}}),
|
||||
json!({"index": 1, "id": "call_2", "function": {"name": "developer__shell", "arguments": "{\"command\": \"pwd\"}"}}),
|
||||
];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
let args0 = get_tool_call_args(&msgs[0]).unwrap();
|
||||
let args1 = get_tool_call_args(&msgs[1]).unwrap();
|
||||
assert_eq!(args0.get("command").unwrap(), "ls");
|
||||
assert_eq!(args1.get("command").unwrap(), "pwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_arguments_streamed() {
|
||||
// Arguments with multiple keys streamed token by token
|
||||
let deltas = vec![
|
||||
json!({"index": 0, "id": "call_1", "function": {"name": "developer__shell", "arguments": ""}}),
|
||||
json!({"index": 0, "function": {"arguments": "{\"command\""}}),
|
||||
json!({"index": 0, "function": {"arguments": ": \"ls -la\","}}),
|
||||
json!({"index": 0, "function": {"arguments": " \"timeout\":"}}),
|
||||
json!({"index": 0, "function": {"arguments": " 30}"}}),
|
||||
];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
let args = get_tool_call_args(&msgs[0]).unwrap();
|
||||
assert_eq!(args.get("command").unwrap(), "ls -la");
|
||||
assert_eq!(args.get("timeout").unwrap(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_name_skipped() {
|
||||
let deltas = vec![json!({"index": 0, "function": {"name": "", "arguments": "{}"}})];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_deltas() {
|
||||
let msgs = extract_oai_tool_call_messages(&[], "msg-1");
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_without_arguments() {
|
||||
let deltas = vec![json!({"index": 0, "id": "call_1", "function": {"name": "some_tool"}})];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(get_tool_call_name(&msgs[0]), "some_tool");
|
||||
assert!(get_tool_call_args(&msgs[0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_arguments_drops_tool_call() {
|
||||
let deltas = vec![
|
||||
json!({"index": 0, "id": "call_1", "function": {"name": "developer__shell", "arguments": ""}}),
|
||||
json!({"index": 0, "function": {"arguments": "{\"command\": \"rm -rf"}}),
|
||||
];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generates_id_when_missing() {
|
||||
let deltas =
|
||||
vec![json!({"index": 0, "function": {"name": "some_tool", "arguments": "{}"}})];
|
||||
let msgs = extract_oai_tool_call_messages(&deltas, "msg-1");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(get_tool_call_name(&msgs[0]), "some_tool");
|
||||
match &msgs[0].content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
assert!(!req.id.is_empty());
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,19 +91,60 @@ impl Default for ModelSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Featured models — HuggingFace specs in "author/repo-GGUF:quantization" format.
|
||||
pub const FEATURED_MODELS: &[&str] = &[
|
||||
"bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
|
||||
"bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M",
|
||||
"bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M",
|
||||
"bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M",
|
||||
pub struct FeaturedModel {
|
||||
/// HuggingFace spec in "author/repo-GGUF:quantization" format.
|
||||
pub spec: &'static str,
|
||||
/// Whether this model's GGUF template supports native tool calling via llama.cpp.
|
||||
pub native_tool_calling: bool,
|
||||
}
|
||||
|
||||
pub const FEATURED_MODELS: &[FeaturedModel] = &[
|
||||
FeaturedModel {
|
||||
spec: "bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M",
|
||||
native_tool_calling: false,
|
||||
},
|
||||
FeaturedModel {
|
||||
spec: "bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M",
|
||||
native_tool_calling: false,
|
||||
},
|
||||
FeaturedModel {
|
||||
spec: "bartowski/Hermes-2-Pro-Mistral-7B-GGUF:Q4_K_M",
|
||||
native_tool_calling: false,
|
||||
},
|
||||
FeaturedModel {
|
||||
spec: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF:Q4_K_M",
|
||||
native_tool_calling: false,
|
||||
},
|
||||
FeaturedModel {
|
||||
spec: "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M",
|
||||
native_tool_calling: true,
|
||||
},
|
||||
FeaturedModel {
|
||||
spec: "unsloth/gemma-4-26B-A4B-it-GGUF:Q4_K_M",
|
||||
native_tool_calling: true,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn default_settings_for_model(model_id: &str) -> ModelSettings {
|
||||
use super::hf_models::parse_model_spec;
|
||||
let native = FEATURED_MODELS.iter().any(|m| {
|
||||
if let Ok((repo_id, quant)) = parse_model_spec(m.spec) {
|
||||
model_id_from_repo(&repo_id, &quant) == model_id && m.native_tool_calling
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
ModelSettings {
|
||||
native_tool_calling: native,
|
||||
..ModelSettings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a model ID corresponds to a featured model.
|
||||
pub fn is_featured_model(model_id: &str) -> bool {
|
||||
use super::hf_models::parse_model_spec;
|
||||
FEATURED_MODELS.iter().any(|spec| {
|
||||
if let Ok((repo_id, quant)) = parse_model_spec(spec) {
|
||||
FEATURED_MODELS.iter().any(|m| {
|
||||
if let Ok((repo_id, quant)) = parse_model_spec(m.spec) {
|
||||
model_id_from_repo(&repo_id, &quant) == model_id
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use rmcp::model::{CallToolRequestParams, Tool};
|
||||
use rmcp::model::Tool;
|
||||
use serde_json::{json, Value};
|
||||
use std::borrow::Cow;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) fn compact_tools_json(tools: &[Tool]) -> Option<String> {
|
||||
let compact: Vec<Value> = tools
|
||||
@@ -20,527 +17,9 @@ pub(super) fn compact_tools_json(tools: &[Tool]) -> Option<String> {
|
||||
serde_json::to_string(&compact).ok()
|
||||
}
|
||||
|
||||
/// Split generated text into (content, tool_calls_json).
|
||||
/// Looks for the last top-level JSON object containing `"tool_calls"`.
|
||||
/// Returns the text before it as content, and the JSON string if found.
|
||||
#[allow(clippy::string_slice)]
|
||||
pub(super) fn split_content_and_tool_calls(text: &str) -> (String, Option<String>) {
|
||||
let trimmed = text.trim_end();
|
||||
if !trimmed.ends_with('}') {
|
||||
return (text.to_string(), None);
|
||||
}
|
||||
|
||||
// Scan backwards for the matching '{' of the final '}'.
|
||||
// We only match on ASCII braces so `start` is always a char boundary.
|
||||
let bytes = trimmed.as_bytes();
|
||||
let mut depth = 0i32;
|
||||
let mut json_start = None;
|
||||
for i in (0..bytes.len()).rev() {
|
||||
match bytes[i] {
|
||||
b'}' => depth += 1,
|
||||
b'{' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
json_start = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(start) = json_start else {
|
||||
return (text.to_string(), None);
|
||||
};
|
||||
|
||||
let json_str = &trimmed[start..];
|
||||
let parsed: Value = match serde_json::from_str(json_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (text.to_string(), None),
|
||||
};
|
||||
|
||||
if parsed
|
||||
.get("tool_calls")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_none()
|
||||
{
|
||||
return (text.to_string(), None);
|
||||
}
|
||||
|
||||
let content = trimmed[..start].trim_end().to_string();
|
||||
(content, Some(json_str.to_string()))
|
||||
}
|
||||
|
||||
/// Return the byte length of text that is safe to stream.
|
||||
/// Everything before the last unmatched top-level `{` is safe — the `{` could
|
||||
/// be the start of a tool-call JSON block still being generated.
|
||||
/// If all braces are balanced the entire text is safe.
|
||||
pub(super) fn safe_stream_end(text: &str) -> usize {
|
||||
// Hold back from the start of any incomplete <tool_call> tag.
|
||||
// If we find an unmatched opening, nothing from that point should be streamed.
|
||||
let xml_hold = text.find("<tool_call>").unwrap_or(text.len());
|
||||
|
||||
let bytes = text.as_bytes();
|
||||
let mut safe_end = bytes.len();
|
||||
let mut depth = 0i32;
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
match b {
|
||||
b'{' => {
|
||||
if depth == 0 {
|
||||
safe_end = i;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
safe_end = i + 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if depth == 0 {
|
||||
safe_end = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also hold back a partial `<tool_call` prefix at the end of the text.
|
||||
// The tag is 11 chars; if the last N chars are a prefix of `<tool_call>`, hold them.
|
||||
let tag = b"<tool_call>";
|
||||
let tail_hold = {
|
||||
let mut hold = safe_end;
|
||||
let check_len = tag.len().min(bytes.len());
|
||||
for start in (safe_end.saturating_sub(check_len))..safe_end {
|
||||
let tail = &bytes[start..safe_end];
|
||||
if tag.starts_with(tail) {
|
||||
hold = start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
hold
|
||||
};
|
||||
|
||||
safe_end.min(xml_hold).min(tail_hold)
|
||||
}
|
||||
|
||||
/// Extract tool call messages from a JSON object containing "tool_calls".
|
||||
/// Handles both the model's native format (name/arguments at top level)
|
||||
/// and the OpenAI format (function.name/function.arguments).
|
||||
pub(super) fn extract_tool_call_messages(tool_calls_json: &str, message_id: &str) -> Vec<Message> {
|
||||
let parsed: Value = match serde_json::from_str(tool_calls_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let Some(tool_calls) = parsed.get("tool_calls").and_then(|v| v.as_array()) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut messages = Vec::new();
|
||||
for tc in tool_calls {
|
||||
// Try OpenAI format first: {"function": {"name": ..., "arguments": ...}, "id": ...}
|
||||
// Then model's native format: {"name": ..., "arguments": {...}, "id": ...}
|
||||
let (name, arguments) = if let Some(func) = tc.get("function") {
|
||||
let n = func.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let args_str = func
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("{}");
|
||||
let args: Option<serde_json::Map<String, Value>> = serde_json::from_str(args_str).ok();
|
||||
(n.to_string(), args)
|
||||
} else {
|
||||
let n = tc.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Arguments may be an object directly (model format) or a string (OAI format)
|
||||
let args = if let Some(obj) = tc.get("arguments").and_then(|v| v.as_object()) {
|
||||
Some(obj.clone())
|
||||
} else if let Some(s) = tc.get("arguments").and_then(|v| v.as_str()) {
|
||||
serde_json::from_str(s).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(n.to_string(), args)
|
||||
};
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
let tool_call = CallToolRequestParams::new(Cow::Owned(name))
|
||||
.with_arguments(arguments.unwrap_or_default());
|
||||
|
||||
let mut msg = Message::assistant();
|
||||
msg.content
|
||||
.push(MessageContent::tool_request(id, Ok(tool_call)));
|
||||
msg.id = Some(message_id.to_string());
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Parse XML-style tool calls used by models like qwen3-coder.
|
||||
/// Format:
|
||||
/// ```text
|
||||
/// <tool_call>
|
||||
/// <function=tool_name>
|
||||
/// <parameter=param1>value1</parameter>
|
||||
/// <parameter=param2>value2</parameter>
|
||||
/// </function>
|
||||
/// </tool_call>
|
||||
/// ```
|
||||
/// Returns (content_before_tool_calls, vec_of_tool_calls) or None if no XML tool calls found.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn split_content_and_xml_tool_calls(
|
||||
text: &str,
|
||||
) -> Option<(String, Vec<(String, serde_json::Map<String, Value>)>)> {
|
||||
let (content, first_block_and_rest) = text.split_once("<tool_call>")?;
|
||||
let content = content.trim_end().to_string();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
// Process the first block, then keep splitting on subsequent <tool_call> tags
|
||||
let mut remaining = first_block_and_rest;
|
||||
loop {
|
||||
// Split off the block up to </tool_call> (or take the rest if unclosed)
|
||||
let (block, after_close) = remaining
|
||||
.split_once("</tool_call>")
|
||||
.unwrap_or((remaining, ""));
|
||||
|
||||
if let Some(tool_call) = parse_single_xml_tool_call(block) {
|
||||
tool_calls.push(tool_call);
|
||||
}
|
||||
|
||||
// Try to find the next <tool_call> in what remains
|
||||
match after_close.split_once("<tool_call>") {
|
||||
Some((_between, next_remaining)) => remaining = next_remaining,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((content, tool_calls))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_single_xml_tool_call(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
|
||||
// Try <function=NAME><parameter=K>V</parameter>...</function> format first
|
||||
if let Some(result) = parse_xml_function_format(block) {
|
||||
return Some(result);
|
||||
}
|
||||
// Try GLM-style: TOOL_NAME<arg_key>K</arg_key><arg_value>V</arg_value>...
|
||||
parse_xml_arg_key_value_format(block)
|
||||
}
|
||||
|
||||
fn parse_xml_function_format(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
|
||||
let (_, after_func_eq) = block.split_once("<function=")?;
|
||||
let (func_name, func_body) = after_func_eq.split_once('>')?;
|
||||
let func_name = func_name.trim().to_string();
|
||||
|
||||
let mut args = serde_json::Map::new();
|
||||
let mut rest = func_body;
|
||||
|
||||
while let Some((_, after_param_eq)) = rest.split_once("<parameter=") {
|
||||
let Some((param_name, after_name_close)) = after_param_eq.split_once('>') else {
|
||||
break;
|
||||
};
|
||||
let param_name = param_name.trim().to_string();
|
||||
|
||||
let (value, after_value) = after_name_close
|
||||
.split_once("</parameter>")
|
||||
.unwrap_or((after_name_close, ""));
|
||||
let value = value.trim();
|
||||
|
||||
let json_value =
|
||||
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()));
|
||||
args.insert(param_name, json_value);
|
||||
|
||||
rest = after_value;
|
||||
}
|
||||
|
||||
Some((func_name, args))
|
||||
}
|
||||
|
||||
/// Parse GLM-style tool calls: `NAME<arg_key>K</arg_key><arg_value>V</arg_value>...`
|
||||
/// Also handles zero-argument calls like just `NAME`.
|
||||
fn parse_xml_arg_key_value_format(block: &str) -> Option<(String, serde_json::Map<String, Value>)> {
|
||||
let func_name_end = block.find("<arg_key>").unwrap_or(block.len());
|
||||
// Safe: find returns a byte offset at the start of an ASCII '<' character,
|
||||
// and block.len() is always a valid boundary.
|
||||
#[allow(clippy::string_slice)]
|
||||
let func_name = block[..func_name_end].trim().to_string();
|
||||
if func_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut args = serde_json::Map::new();
|
||||
#[allow(clippy::string_slice)]
|
||||
let mut rest = &block[func_name_end..];
|
||||
|
||||
while let Some((_, after_key_open)) = rest.split_once("<arg_key>") {
|
||||
let Some((key, after_key_close)) = after_key_open.split_once("</arg_key>") else {
|
||||
break;
|
||||
};
|
||||
let key = key.trim().to_string();
|
||||
|
||||
let Some((_, after_val_open)) = after_key_close.split_once("<arg_value>") else {
|
||||
break;
|
||||
};
|
||||
let (value, after_val_close) = after_val_open
|
||||
.split_once("</arg_value>")
|
||||
.unwrap_or((after_val_open, ""));
|
||||
let value = value.trim();
|
||||
|
||||
let json_value =
|
||||
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()));
|
||||
args.insert(key, json_value);
|
||||
|
||||
rest = after_val_close;
|
||||
}
|
||||
|
||||
Some((func_name, args))
|
||||
}
|
||||
|
||||
pub(super) fn extract_xml_tool_call_messages(
|
||||
tool_calls: Vec<(String, serde_json::Map<String, Value>)>,
|
||||
message_id: &str,
|
||||
) -> Vec<Message> {
|
||||
tool_calls
|
||||
.into_iter()
|
||||
.map(|(name, args)| {
|
||||
let tool_call = if args.is_empty() {
|
||||
CallToolRequestParams::new(Cow::Owned(name))
|
||||
} else {
|
||||
CallToolRequestParams::new(Cow::Owned(name)).with_arguments(args)
|
||||
};
|
||||
let mut msg = Message::assistant();
|
||||
msg.content.push(MessageContent::tool_request(
|
||||
Uuid::new_v4().to_string(),
|
||||
Ok(tool_call),
|
||||
));
|
||||
msg.id = Some(message_id.to_string());
|
||||
msg
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const SHELL_TOOL: &str = "developer__shell";
|
||||
|
||||
#[test]
|
||||
fn test_parse_xml_tool_call_single() {
|
||||
let text = "I'll search for that.\n\n<tool_call>\n<function=search__files>\n<parameter=pattern>local.*inference</parameter>\n</function>\n</tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert_eq!(content, "I'll search for that.");
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "search__files");
|
||||
assert_eq!(calls[0].1.get("pattern").unwrap(), "local.*inference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_xml_tool_call_multiple_params() {
|
||||
let text = "<tool_call>\n<function=developer__shell>\n<parameter=command>ls -la</parameter>\n<parameter=timeout>30</parameter>\n</function>\n</tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert!(content.is_empty());
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, SHELL_TOOL);
|
||||
assert_eq!(calls[0].1.get("command").unwrap(), "ls -la");
|
||||
// 30 should be parsed as a number
|
||||
assert_eq!(calls[0].1.get("timeout").unwrap(), &json!(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_xml_tool_call_no_tool_call() {
|
||||
let text = "Just some regular text with no tool calls.";
|
||||
assert!(split_content_and_xml_tool_calls(text).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_xml_tool_call_multiple_calls() {
|
||||
let text = "Doing two things:\n<tool_call>\n<function=foo__bar>\n<parameter=x>1</parameter>\n</function>\n</tool_call>\n<tool_call>\n<function=baz__qux>\n<parameter=y>hello</parameter>\n</function>\n</tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert_eq!(content, "Doing two things:");
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].0, "foo__bar");
|
||||
assert_eq!(calls[1].0, "baz__qux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_xml_tool_call_multiline_value() {
|
||||
let text = "<tool_call>\n<function=developer__write_file>\n<parameter=path>test.py</parameter>\n<parameter=content>def hello():\n print(\"world\")</parameter>\n</function>\n</tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (_content, calls) = result.unwrap();
|
||||
assert_eq!(calls[0].0, "developer__write_file");
|
||||
assert_eq!(
|
||||
calls[0].1.get("content").unwrap(),
|
||||
"def hello():\n print(\"world\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_holds_back_tool_call_tag() {
|
||||
let text = "Some text before <tool_call>\n<function=foo>";
|
||||
let safe = safe_stream_end(text);
|
||||
assert!(safe <= text.find("<tool_call>").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_holds_back_partial_tag() {
|
||||
let text = "Some text <tool_ca";
|
||||
let safe = safe_stream_end(text);
|
||||
// Should hold back the partial tag
|
||||
assert!(safe <= text.find('<').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_glm_style_tool_call() {
|
||||
let text = "<tool_call>developer__shell<arg_key>command</arg_key><arg_value>ls -la</arg_value></tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert!(content.is_empty());
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, SHELL_TOOL);
|
||||
assert_eq!(calls[0].1.get("command").unwrap(), "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_glm_style_tool_call_no_args() {
|
||||
let text = "Some text\n<tool_call>load</tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert_eq!(content, "Some text");
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "load");
|
||||
assert!(calls[0].1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_glm_style_tool_call_multiple_args() {
|
||||
let text = "Let me check.\n<tool_call>execute_typescript<arg_key>code</arg_key><arg_value>async function run() { return 1; }</arg_value><arg_key>tool_graph</arg_key><arg_value>[{\"tool\": \"shell\"}]</arg_value></tool_call>";
|
||||
let result = split_content_and_xml_tool_calls(text);
|
||||
assert!(result.is_some());
|
||||
let (content, calls) = result.unwrap();
|
||||
assert_eq!(content, "Let me check.");
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "execute_typescript");
|
||||
assert_eq!(
|
||||
calls[0].1.get("code").unwrap(),
|
||||
"async function run() { return 1; }"
|
||||
);
|
||||
// tool_graph should be parsed as JSON array
|
||||
assert!(calls[0].1.get("tool_graph").unwrap().is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_xml_tool_call_messages() {
|
||||
let calls = vec![(
|
||||
SHELL_TOOL.to_string(),
|
||||
serde_json::Map::from_iter(vec![("command".to_string(), json!("ls"))]),
|
||||
)];
|
||||
let msgs = extract_xml_tool_call_messages(calls, "test-id");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].id, Some("test-id".to_string()));
|
||||
match &msgs[0].content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
let call = req.tool_call.as_ref().unwrap();
|
||||
assert_eq!(&*call.name, SHELL_TOOL);
|
||||
assert_eq!(
|
||||
call.arguments.as_ref().unwrap().get("command").unwrap(),
|
||||
"ls"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_content_and_tool_calls_with_tool() {
|
||||
let text = "Here is the result.\n{\"tool_calls\": [{\"function\": {\"name\": \"shell\", \"arguments\": \"{}\"}, \"id\": \"abc\"}]}";
|
||||
let (content, tc) = split_content_and_tool_calls(text);
|
||||
assert_eq!(content, "Here is the result.");
|
||||
assert!(tc.is_some());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&tc.unwrap()).unwrap();
|
||||
assert_eq!(parsed["tool_calls"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_content_and_tool_calls_no_tool() {
|
||||
let text = "Just regular text, no JSON.";
|
||||
let (content, tc) = split_content_and_tool_calls(text);
|
||||
assert_eq!(content, text);
|
||||
assert!(tc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_content_and_tool_calls_json_without_tool_calls_key() {
|
||||
let text = "{\"key\": \"value\"}";
|
||||
let (content, tc) = split_content_and_tool_calls(text);
|
||||
assert_eq!(content, text);
|
||||
assert!(tc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_messages_openai_format() {
|
||||
let json_str = r#"{"tool_calls": [{"function": {"name": "developer__shell", "arguments": "{\"command\": \"ls\"}"}, "id": "call-1"}]}"#;
|
||||
let msgs = extract_tool_call_messages(json_str, "msg-1");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
match &msgs[0].content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
let call = req.tool_call.as_ref().unwrap();
|
||||
assert_eq!(&*call.name, SHELL_TOOL);
|
||||
assert_eq!(
|
||||
call.arguments.as_ref().unwrap().get("command").unwrap(),
|
||||
"ls"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_messages_native_format() {
|
||||
let json_str = r#"{"tool_calls": [{"name": "developer__shell", "arguments": {"command": "ls"}, "id": "call-2"}]}"#;
|
||||
let msgs = extract_tool_call_messages(json_str, "msg-2");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
match &msgs[0].content[0] {
|
||||
MessageContent::ToolRequest(req) => {
|
||||
let call = req.tool_call.as_ref().unwrap();
|
||||
assert_eq!(&*call.name, SHELL_TOOL);
|
||||
}
|
||||
_ => panic!("Expected ToolRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_messages_invalid_json() {
|
||||
assert!(extract_tool_call_messages("not json", "msg-3").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_messages_empty_name_skipped() {
|
||||
let json_str = r#"{"tool_calls": [{"name": "", "arguments": {}, "id": "x"}]}"#;
|
||||
assert!(extract_tool_call_messages(json_str, "msg-4").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_tools_json_produces_minimal_output() {
|
||||
@@ -570,27 +49,4 @@ mod tests {
|
||||
let parsed: Vec<serde_json::Value> = serde_json::from_str(&result.unwrap()).unwrap();
|
||||
assert!(parsed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_balanced_braces() {
|
||||
let text = "Result: {\"key\": \"value\"} done";
|
||||
assert_eq!(safe_stream_end(text), text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_unbalanced_open_brace() {
|
||||
let text = "Some text {\"tool_calls\": [";
|
||||
assert_eq!(safe_stream_end(text), "Some text ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_empty() {
|
||||
assert_eq!(safe_stream_end(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_stream_end_no_braces() {
|
||||
let text = "plain text here";
|
||||
assert_eq!(safe_stream_end(text), text.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ModelsBottomBar({
|
||||
|
||||
{isLocalModelSettingsOpen && currentModel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-default rounded-lg shadow-lg w-[480px] max-h-[80vh] flex flex-col">
|
||||
<div className="bg-background-primary border border-border-primary rounded-lg shadow-lg w-[480px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
|
||||
<h3 className="text-sm font-medium text-text-default">
|
||||
{intl.formatMessage(i18n.localModelSettingsTitle, { modelName: getModelDisplayName(currentModel) })}
|
||||
|
||||
Reference in New Issue
Block a user