Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/nlp-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ repository.workspace = true
# llama.cpp for embeddings and chat via Rust bindings
# Supports Metal (macOS), CUDA (NVIDIA), Vulkan (cross-platform)
# v0.1.141 includes the pooling fix for nomic-embed-text (llama.cpp #12561)
# v0.1.146 ships apply_chat_template_oaicompat with Jinja support required for Gemma 4
# v0.1.146 ships apply_chat_template_oaicompat with Jinja support required for Gemma 4,
# and vendors llama.cpp commit e21cdc11 (2026-04-13) — 123 commits past build b8660
# (2026-04-03), fixing tool-call failures in Ministral 8B and Gemma 4 12B (issue #1355)
llama-cpp-2 = { version = ">=0.1.146", optional = true }

# UTF-8 decoder for token_to_piece API (llama-cpp-2 >= 0.1.140)
Expand Down
46 changes: 46 additions & 0 deletions packages/nlp-engine/src/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1273,4 +1273,50 @@ mod tests {
assert_eq!(v["tool_call_id"], "tc_1");
assert_eq!(v["content"], "result text");
}

// -----------------------------------------------------------------------
// Regression fixture — Gemma 4 12B tool-call delta (issue #1355)
//
// With the old vendored engine (pre-b8660) chat_format=0 caused tool calls
// to be emitted as plain content, never reaching emit_oai_delta as
// tool_calls arrays. With the upgraded engine (>=v0.1.146) chat_format=3
// (PEG_GEMMA4) routes through ChatParseStateOaicompat and produces the
// delta shape below. This test calls emit_oai_delta directly with that
// shape and verifies ToolCallStart + ToolCallArgs events are produced.
// -----------------------------------------------------------------------

#[cfg(feature = "chat-service")]
#[test]
fn regression_gemma4_12b_oai_delta_emits_tool_call_chunks() {
// OAI-compat delta the upgraded engine produces for a Gemma 4 create_schema call.
// With chat_format=0 this `tool_calls` key would never appear in the stream.
let delta = r#"{"role":"assistant","tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"create_schema","arguments":"{\"name\":\"Invoice\",\"fields\":[]}"}}]}"#;

// emit_oai_delta takes `impl Fn(ChatChunk) + Send` so we collect via Mutex.
let chunks = std::sync::Mutex::new(Vec::<ChatChunk>::new());
let mut ids = std::collections::HashMap::new();
let mut splitter = ChannelSplitter::default();
emit_oai_delta(delta, &mut ids, &mut splitter, &|chunk| {
chunks.lock().unwrap().push(chunk);
});
let chunks = chunks.into_inner().unwrap();

let start = chunks.iter().find(
|c| matches!(c, ChatChunk::ToolCallStart { name, .. } if name == "create_schema"),
);
assert!(
start.is_some(),
"emit_oai_delta must emit ToolCallStart for create_schema; got: {:?}",
chunks
);

let args = chunks.iter().find(
|c| matches!(c, ChatChunk::ToolCallArgs { json, .. } if json.contains("Invoice")),
);
assert!(
args.is_some(),
"emit_oai_delta must emit ToolCallArgs containing 'Invoice'; got: {:?}",
chunks
);
}
}
89 changes: 89 additions & 0 deletions packages/nlp-engine/src/chat/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1180,4 +1180,93 @@ mod tests {
fn test_balanced_brace_unbalanced() {
assert_eq!(find_balanced_brace(r#"{"a": 1"#), None);
}

// -----------------------------------------------------------------------
// Regression fixtures — historical engine failures (issue #1355)
//
// These tests pin the two tool-call failures that were misattributed to
// model capability but were caused by the old vendored llama.cpp engine
// (pre-build-8660). The upgraded engine (llama-cpp-2 ≥ 0.1.146, vendored
// at a commit 123 commits past b8660) produces correct output; the tests
// confirm our parser handles both the broken and fixed shapes correctly.
// -----------------------------------------------------------------------

/// Ministral 8B regression: the old engine dropped the `type` key from a
/// JSON object field, producing malformed JSON:
/// {"name":"amount_due","number"}
/// — a bare string value after a comma is not valid JSON. The parser must
/// reject this rather than silently swallowing it.
///
/// With the upgraded engine the field is emitted correctly as:
/// {"name":"amount_due","type":"number"}
#[test]
fn regression_ministral8b_dropped_type_key_is_rejected() {
// This is the exact malformed shape the old engine produced for a
// create_schema field definition inside a [TOOL_CALLS] response.
let broken = r#"[TOOL_CALLS]create_schema[ARGS]{"name":"Invoice","fields":[{"name":"amount_due","number"}]}"#;
let result = parse_tool_calls(broken);
assert!(
matches!(result, ParseResult::Error(_)),
"Malformed JSON with dropped 'type' key must be a parse Error, got: {:?}",
result
);
}

/// Ministral 8B regression (fixed): the upgraded engine now emits the
/// complete, valid `create_schema` call with both `name` and `type` keys.
/// This is the shape the fixed engine produces — the parser must accept it.
#[test]
fn regression_ministral8b_complete_create_schema_accepted() {
let fixed = r#"[TOOL_CALLS]create_schema[ARGS]{"name":"Invoice","fields":[{"name":"amount_due","type":"number","description":"Total amount due"}]}"#;
let result = parse_tool_calls(fixed);
match result {
ParseResult::ToolCalls(calls) => {
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "create_schema");
assert_eq!(calls[0].args["name"], "Invoice");
let fields = calls[0].args["fields"].as_array().expect("fields array");
assert_eq!(fields.len(), 1);
assert_eq!(fields[0]["name"], "amount_due");
assert_eq!(fields[0]["type"], "number");
}
other => panic!("Expected ToolCalls, got {:?}", other),
}
}

/// Ministral 8B streaming regression: the dropped-type failure also
/// occurred mid-stream. Verify the streaming parser surfaces a parse
/// Error (not a crash) on the malformed shape.
#[test]
fn regression_ministral8b_streaming_broken_json_errors_cleanly() {
let mut parser = StreamingToolCallParser::new();
// Feed all at once — the sentinel is present so we enter tool-call mode.
parser.feed(r#"[TOOL_CALLS]create_schema[ARGS]{"name":"Invoice","fields":[{"name":"amount_due","number"}]}"#);
let result = parser.finish();
assert!(
matches!(result, ParseResult::Error(_)),
"Streaming parser must surface Error for malformed JSON, got: {:?}",
result
);
}

/// Gemma 4 12B regression: the `[TOOL_CALLS]` Mistral-format parser must
/// continue to handle Gemma 4 Format-C (JSON object after sentinel) exactly
/// as it did before the engine upgrade. The upgrade must not break this path.
#[test]
fn regression_gemma4_mistral_sentinel_format_still_works() {
// Gemma 4 can also emit via the [TOOL_CALLS] sentinel path (Format C).
// Confirm parser still handles it correctly post-upgrade.
let input = r#"[TOOL_CALLS]{"tool_name":"create_schema","tool_args":{"name":"Invoice","fields":[{"name":"amount_due","type":"number"}]}}"#;
let result = parse_tool_calls(input);
match result {
ParseResult::ToolCalls(calls) => {
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "create_schema");
assert_eq!(calls[0].args["name"], "Invoice");
let fields = calls[0].args["fields"].as_array().expect("fields array");
assert_eq!(fields[0]["type"], "number");
}
other => panic!("Expected ToolCalls, got {:?}", other),
}
}
}