Some checks failed
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 28s
CI / Format (push) Successful in 4m7s
CI / Test (push) Failing after 1m2s
build-prerelease / Build neuron-blackwell (push) Successful in 6m10s
CI / Clippy (push) Successful in 2m37s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Build cortex binary (push) Successful in 4m24s
build-prerelease / Build neuron-ampere (push) Successful in 8m18s
build-prerelease / Package cortex RPM (push) Successful in 1m22s
build-prerelease / Build neuron-ada (push) Successful in 5m23s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m54s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m56s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Two related fixes for cases where Qwen3 sometimes emits slightly-off JSON inside <tool_call> blocks: 1. JSON repair pass in qwen3::parse_tool_call_body — strip up to three trailing extra `}` characters (model overshoots its closing braces), and hoist `name` out of `arguments` when it lands nested instead of as a sibling. Both observed in the field; both trivially repairable; both now dispatch as normal tool calls instead of falling back to the malformed path. 2. New CompletionEvent::MalformedToolCall variant for the cases repair can't fix. decode_stream now emits it instead of wrapping the raw body in a TextDelta, and agent.rs surfaces each one as a Failed SessionUpdate::ToolCall card (so Zed renders it as a structured failure UI element rather than dumping the body inline) plus a synthetic tool-call/tool-result history pair so the model gets clear feedback for self-correction on the next round. Empty <tool_call></tool_call> blocks are now a no-op too (no Malformed event), matching the existing empty-<think> behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1019 lines
38 KiB
Rust
1019 lines
38 KiB
Rust
//! Qwen3 tool-call wire conventions.
|
|
//!
|
|
//! Qwen3 (and the Hermes-derived chat templates it inherits) wires tool
|
|
//! use entirely through the prompt and the model's text output —
|
|
//! nothing on the server cares about the OpenAI `tools` API field.
|
|
//! This module owns both sides of that convention so the rest of
|
|
//! helexa-acp can stay generic.
|
|
//!
|
|
//! **System prompt** — a `# Tools` block is appended to the system
|
|
//! message describing every available function. Models trained on
|
|
//! this template recognise it and emit calls as
|
|
//! `<tool_call>{"name":"…","arguments":{…}}</tool_call>` inside the
|
|
//! normal content stream.
|
|
//!
|
|
//! **Streaming parse** — [`ToolCallParser`] is a small state machine
|
|
//! fed SSE content chunks. It emits a sequence of
|
|
//! [`ParserEvent`]s — plain text outside tool calls; `Start` + `Args`
|
|
//! events for each `<tool_call>` block. Marker detection is split-safe:
|
|
//! a chunk that ends with `<tool` is buffered until the next chunk
|
|
//! arrives, so even a one-byte-at-a-time stream produces the same
|
|
//! events as a single-buffer reparse would.
|
|
//!
|
|
//! **Multi-turn replay** — when helexa-acp re-sends the conversation
|
|
//! after a tool dispatch, the assistant turn that called the tool and
|
|
//! the tool result need to go back to the model in Qwen3 wire shape:
|
|
//! the assistant turn carries `<tool_call>` blocks inline in its
|
|
//! content, and the tool result rides in a user turn wrapped in
|
|
//! `<tool_response>…</tool_response>`. [`render_assistant_with_tool_calls`]
|
|
//! and [`render_tool_response`] handle those.
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::provider::{ToolCall, ToolSpec};
|
|
|
|
/// One opening marker. Length 11.
|
|
const TOOL_CALL_OPEN: &str = "<tool_call>";
|
|
/// One closing marker. Length 12.
|
|
const TOOL_CALL_CLOSE: &str = "</tool_call>";
|
|
|
|
/// Reasoning open. Length 7.
|
|
const THINK_OPEN: &str = "<think>";
|
|
/// Reasoning close. Length 8.
|
|
const THINK_CLOSE: &str = "</think>";
|
|
|
|
// ── System-prompt-side rendering ────────────────────────────────────
|
|
|
|
/// Append-this-to-the-system-prompt block describing the available
|
|
/// tools in Qwen3's expected format. Returns the empty string if
|
|
/// `tools` is empty (no separator, no `# Tools` header — keeps the
|
|
/// prompt clean when tools are absent for any reason).
|
|
pub fn render_tool_block(tools: &[ToolSpec]) -> String {
|
|
if tools.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut out = String::new();
|
|
out.push_str("\n\n# Tools\n\n");
|
|
out.push_str(
|
|
"You may call one or more functions to assist with the user query.\n\n\
|
|
You are provided with function signatures within <tools></tools> XML tags:\n",
|
|
);
|
|
out.push_str("<tools>\n");
|
|
for spec in tools {
|
|
// Each entry is one JSON object on its own line — newline-
|
|
// delimited, no commas between entries. This is the format
|
|
// Qwen3's training tokenisation expects.
|
|
let entry = json!({
|
|
"type": "function",
|
|
"function": {
|
|
"name": spec.name,
|
|
"description": spec.description,
|
|
"parameters": spec.parameters,
|
|
}
|
|
});
|
|
out.push_str(&serde_json::to_string(&entry).unwrap_or_default());
|
|
out.push('\n');
|
|
}
|
|
out.push_str("</tools>\n\n");
|
|
out.push_str(
|
|
"For each function call, return a json object with function name \
|
|
and arguments within <tool_call></tool_call> XML tags:\n\
|
|
<tool_call>\n\
|
|
{\"name\": <function-name>, \"arguments\": <args-json-object>}\n\
|
|
</tool_call>",
|
|
);
|
|
out
|
|
}
|
|
|
|
// ── Multi-turn replay rendering ─────────────────────────────────────
|
|
|
|
/// Build the assistant-turn content the model expects when we replay
|
|
/// a turn that included tool calls. Format: any visible text first,
|
|
/// then one `<tool_call>{json}</tool_call>` block per call, joined by
|
|
/// newlines.
|
|
pub fn render_assistant_with_tool_calls(text: Option<&str>, calls: &[ToolCall]) -> String {
|
|
let mut out = String::new();
|
|
if let Some(t) = text
|
|
&& !t.is_empty()
|
|
{
|
|
out.push_str(t);
|
|
if !calls.is_empty() {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
for (i, call) in calls.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push('\n');
|
|
}
|
|
// The arguments field on a `ToolCall` is a JSON-encoded
|
|
// string; we want it inlined as an object inside the
|
|
// tool_call body. Best-effort parse; if it isn't valid JSON,
|
|
// pass the raw string through wrapped in quotes so the
|
|
// emission stays well-formed.
|
|
let args_value: serde_json::Value = serde_json::from_str(&call.arguments)
|
|
.unwrap_or_else(|_| serde_json::Value::String(call.arguments.clone()));
|
|
let body = json!({ "name": call.name, "arguments": args_value });
|
|
out.push_str(TOOL_CALL_OPEN);
|
|
out.push('\n');
|
|
out.push_str(&serde_json::to_string(&body).unwrap_or_default());
|
|
out.push('\n');
|
|
out.push_str(TOOL_CALL_CLOSE);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Wrap a tool-result string in the Qwen3 `<tool_response>` block
|
|
/// that goes inside a `user` role message on the next turn.
|
|
pub fn render_tool_response(content: &str) -> String {
|
|
format!("<tool_response>\n{content}\n</tool_response>")
|
|
}
|
|
|
|
// ── Streaming parser ────────────────────────────────────────────────
|
|
|
|
/// Events produced by [`ToolCallParser`]. Distinct from the
|
|
/// `CompletionEvent` enum because the parser is provider-agnostic —
|
|
/// the caller decides how to translate these into
|
|
/// `CompletionEvent::ToolCall*` and `TextDelta`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParserEvent {
|
|
/// Plain text that lives outside any tool_call block.
|
|
Text(String),
|
|
/// Beginning of a tool call. The index increments per call within
|
|
/// the same parser lifetime.
|
|
Start { index: usize, name: String },
|
|
/// JSON-encoded arguments for the most recent `Start`. Always
|
|
/// follows its `Start` immediately; never split across multiple
|
|
/// `Args` events for a single call (the parser buffers the whole
|
|
/// `<tool_call>` body before emitting).
|
|
Args { index: usize, args_json: String },
|
|
/// Parser encountered a malformed `<tool_call>` body. Emitted so
|
|
/// the agent can log and continue rather than crashing the
|
|
/// conversation.
|
|
Malformed { raw: String },
|
|
}
|
|
|
|
/// Streaming parser for Qwen3 tool calls embedded in the model's text
|
|
/// output. Feed it chunks via [`feed`](Self::feed); call
|
|
/// [`finish`](Self::finish) at end-of-stream to drain any trailing
|
|
/// buffered bytes.
|
|
///
|
|
/// Design notes:
|
|
///
|
|
/// - Markers (`<tool_call>` / `</tool_call>`) can be split across
|
|
/// chunks at any byte. The parser holds back exactly as much suffix
|
|
/// as could be the start of the marker it's currently looking for,
|
|
/// and no more.
|
|
/// - JSON inside a tool_call is held in a separate buffer until the
|
|
/// closing marker arrives. We don't try to stream-parse JSON; the
|
|
/// bodies are tiny (one function call) and assembling first
|
|
/// yields a much simpler implementation.
|
|
/// - Index is monotonic across the parser's lifetime — one
|
|
/// conversation turn can contain multiple `<tool_call>` blocks and
|
|
/// each gets its own index.
|
|
#[derive(Debug, Default)]
|
|
pub struct ToolCallParser {
|
|
/// Unprocessed input bytes carried over between feeds.
|
|
buffer: String,
|
|
/// True while we're between `<tool_call>` and `</tool_call>`.
|
|
in_tool_call: bool,
|
|
/// Bytes accumulated inside the current `<tool_call>` block.
|
|
tool_call_buf: String,
|
|
/// Next tool-call index to assign.
|
|
next_index: usize,
|
|
}
|
|
|
|
impl ToolCallParser {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn feed(&mut self, chunk: &str) -> Vec<ParserEvent> {
|
|
self.buffer.push_str(chunk);
|
|
self.drain()
|
|
}
|
|
|
|
/// End-of-stream: emit anything still in the buffers. An
|
|
/// unterminated tool_call is reported as `Malformed` so the
|
|
/// caller can decide what to surface to the user.
|
|
pub fn finish(&mut self) -> Vec<ParserEvent> {
|
|
let mut events = self.drain();
|
|
if self.in_tool_call {
|
|
let raw = std::mem::take(&mut self.tool_call_buf) + &std::mem::take(&mut self.buffer);
|
|
events.push(ParserEvent::Malformed { raw });
|
|
self.in_tool_call = false;
|
|
} else if !self.buffer.is_empty() {
|
|
events.push(ParserEvent::Text(std::mem::take(&mut self.buffer)));
|
|
}
|
|
events
|
|
}
|
|
|
|
fn drain(&mut self) -> Vec<ParserEvent> {
|
|
let mut events = Vec::new();
|
|
loop {
|
|
if self.in_tool_call {
|
|
if let Some(end) = self.buffer.find(TOOL_CALL_CLOSE) {
|
|
let body = &self.buffer[..end];
|
|
self.tool_call_buf.push_str(body);
|
|
self.buffer.drain(..end + TOOL_CALL_CLOSE.len());
|
|
self.emit_completed_tool_call(&mut events);
|
|
self.in_tool_call = false;
|
|
} else {
|
|
// Hold back exactly the suffix that could be the
|
|
// start of `</tool_call>`. Everything before it
|
|
// is safely part of the call body.
|
|
let hold = longest_marker_prefix_suffix(&self.buffer, TOOL_CALL_CLOSE);
|
|
let safe = self.buffer.len() - hold;
|
|
if safe > 0 {
|
|
self.tool_call_buf.push_str(&self.buffer[..safe]);
|
|
self.buffer.drain(..safe);
|
|
}
|
|
return events;
|
|
}
|
|
} else if let Some(start) = self.buffer.find(TOOL_CALL_OPEN) {
|
|
let text = &self.buffer[..start];
|
|
if !text.is_empty() {
|
|
events.push(ParserEvent::Text(text.to_string()));
|
|
}
|
|
self.buffer.drain(..start + TOOL_CALL_OPEN.len());
|
|
self.in_tool_call = true;
|
|
} else {
|
|
let hold = longest_marker_prefix_suffix(&self.buffer, TOOL_CALL_OPEN);
|
|
let safe = self.buffer.len() - hold;
|
|
if safe > 0 {
|
|
let text: String = self.buffer.drain(..safe).collect();
|
|
events.push(ParserEvent::Text(text));
|
|
}
|
|
return events;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn emit_completed_tool_call(&mut self, events: &mut Vec<ParserEvent>) {
|
|
let body = std::mem::take(&mut self.tool_call_buf);
|
|
// <tool_call></tool_call> with nothing inside: nothing to do.
|
|
if body.trim().is_empty() {
|
|
return;
|
|
}
|
|
match parse_tool_call_body(&body) {
|
|
Some(call) => {
|
|
let index = self.next_index;
|
|
self.next_index += 1;
|
|
let args_json =
|
|
serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_string());
|
|
events.push(ParserEvent::Start {
|
|
index,
|
|
name: call.name,
|
|
});
|
|
events.push(ParserEvent::Args { index, args_json });
|
|
}
|
|
None => {
|
|
events.push(ParserEvent::Malformed { raw: body });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Best-effort parse of a `<tool_call>` body, with repair for common
|
|
/// model misemissions. Returns `None` only when no reasonable
|
|
/// interpretation produces a tool call with a name.
|
|
///
|
|
/// Repairs handled (in order of attempt):
|
|
///
|
|
/// 1. The body parses cleanly as `{name, arguments}` — done.
|
|
/// 2. The body has trailing extra `}` characters (model overshoots
|
|
/// the closing brace count). Strip up to three trailing `}` and
|
|
/// retry. Common for chunked-sampling models that emit the closing
|
|
/// of `arguments` then re-emit the closing of the outer object.
|
|
/// 3. The body parses as JSON but has no top-level `name`; instead
|
|
/// `name` is nested inside `arguments`. Hoist it out. Common when
|
|
/// the model's training distribution had `{name, arguments}`
|
|
/// inverted in some examples.
|
|
fn parse_tool_call_body(body: &str) -> Option<ToolCallBody> {
|
|
let trimmed = body.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
if let Ok(call) = serde_json::from_str::<ToolCallBody>(trimmed) {
|
|
return Some(call);
|
|
}
|
|
// Repair 1: strip 1..=3 trailing closing braces. We only consider
|
|
// this if the body actually ends with `}`, so the strip is
|
|
// structural not random.
|
|
for n in 1..=3 {
|
|
if trimmed.len() <= n {
|
|
break;
|
|
}
|
|
let candidate = &trimmed[..trimmed.len() - n];
|
|
if !candidate.ends_with('}') {
|
|
// Stripping further would leave the JSON without a
|
|
// closing brace at all — definitely worse, not better.
|
|
break;
|
|
}
|
|
if let Ok(call) = serde_json::from_str::<ToolCallBody>(candidate) {
|
|
tracing::debug!(
|
|
stripped = n,
|
|
"qwen3: recovered tool call by trimming trailing brace(s)"
|
|
);
|
|
return Some(call);
|
|
}
|
|
}
|
|
// Repair 2: hoist `name` from inside `arguments`.
|
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
|
|
&& value.get("name").is_none()
|
|
&& let Some(args_value) = value.get("arguments")
|
|
&& let Some(name) = args_value.get("name").and_then(|n| n.as_str())
|
|
{
|
|
let mut hoisted = args_value.clone();
|
|
if let Some(obj) = hoisted.as_object_mut() {
|
|
obj.remove("name");
|
|
}
|
|
tracing::debug!(
|
|
name = %name,
|
|
"qwen3: recovered tool call by hoisting name out of arguments"
|
|
);
|
|
return Some(ToolCallBody {
|
|
name: name.to_string(),
|
|
arguments: hoisted,
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Returns the length of the longest suffix of `haystack` that is a
|
|
/// proper prefix of `needle`. Used to decide how many trailing bytes
|
|
/// to hold back when scanning for `needle`: anything that could
|
|
/// possibly be the start of `needle` is held; everything else is
|
|
/// safe to emit.
|
|
fn longest_marker_prefix_suffix(haystack: &str, needle: &str) -> usize {
|
|
// Try prefixes of needle from longest to shortest; the first one
|
|
// that matches as a suffix of haystack wins. O(|needle|^2) which
|
|
// is fine — both markers are < 20 chars.
|
|
let max = needle.len().min(haystack.len());
|
|
for n in (1..=max).rev() {
|
|
if !haystack.is_char_boundary(haystack.len() - n) || !needle.is_char_boundary(n) {
|
|
continue;
|
|
}
|
|
if haystack.ends_with(&needle[..n]) {
|
|
return n;
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct ToolCallBody {
|
|
name: String,
|
|
// The model is supposed to emit a JSON object here; in practice
|
|
// some Qwen3 variants stringify it. Deserialize-as-value handles
|
|
// both.
|
|
#[serde(default)]
|
|
arguments: serde_json::Value,
|
|
}
|
|
|
|
// ── Think-block parser ──────────────────────────────────────────────
|
|
|
|
/// Events from [`ThinkParser`]. Plain text outside any `<think>`
|
|
/// block stays `Text`; bytes between `<think>` and `</think>` become
|
|
/// `Reasoning` so the agent can route them to a thought-channel
|
|
/// notification (Zed surfaces these in a dedicated UI affordance
|
|
/// rather than the main message pane).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ThinkEvent {
|
|
Text(String),
|
|
Reasoning(String),
|
|
}
|
|
|
|
/// Streaming parser for Qwen3-style inline reasoning. Same
|
|
/// chunk-boundary discipline as [`ToolCallParser`]: hold back only
|
|
/// the suffix that could be the start of the marker we're scanning
|
|
/// for. Markers (`<think>`, `</think>`) never nest; a stray
|
|
/// `</think>` outside a block is emitted as text (the model
|
|
/// occasionally writes the tag conversationally).
|
|
#[derive(Debug, Default)]
|
|
pub struct ThinkParser {
|
|
buffer: String,
|
|
in_think: bool,
|
|
}
|
|
|
|
impl ThinkParser {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn feed(&mut self, chunk: &str) -> Vec<ThinkEvent> {
|
|
self.buffer.push_str(chunk);
|
|
self.drain()
|
|
}
|
|
|
|
/// Flush any buffered tail at end-of-stream. If we end mid-think
|
|
/// (no closing tag arrived), emit what we have as reasoning so
|
|
/// the partial thought isn't silently dropped.
|
|
pub fn finish(&mut self) -> Vec<ThinkEvent> {
|
|
let mut events = self.drain();
|
|
if !self.buffer.is_empty() {
|
|
let raw = std::mem::take(&mut self.buffer);
|
|
if self.in_think {
|
|
events.push(ThinkEvent::Reasoning(raw));
|
|
} else {
|
|
events.push(ThinkEvent::Text(raw));
|
|
}
|
|
}
|
|
self.in_think = false;
|
|
events
|
|
}
|
|
|
|
fn drain(&mut self) -> Vec<ThinkEvent> {
|
|
let mut events = Vec::new();
|
|
loop {
|
|
if self.in_think {
|
|
if let Some(end) = self.buffer.find(THINK_CLOSE) {
|
|
let body = self.buffer[..end].to_string();
|
|
if !body.is_empty() {
|
|
events.push(ThinkEvent::Reasoning(body));
|
|
}
|
|
self.buffer.drain(..end + THINK_CLOSE.len());
|
|
self.in_think = false;
|
|
} else {
|
|
let hold = longest_marker_prefix_suffix(&self.buffer, THINK_CLOSE);
|
|
let safe = self.buffer.len() - hold;
|
|
if safe > 0 {
|
|
let r: String = self.buffer.drain(..safe).collect();
|
|
events.push(ThinkEvent::Reasoning(r));
|
|
}
|
|
return events;
|
|
}
|
|
} else if let Some(start) = self.buffer.find(THINK_OPEN) {
|
|
let text = self.buffer[..start].to_string();
|
|
if !text.is_empty() {
|
|
events.push(ThinkEvent::Text(text));
|
|
}
|
|
self.buffer.drain(..start + THINK_OPEN.len());
|
|
self.in_think = true;
|
|
} else {
|
|
let hold = longest_marker_prefix_suffix(&self.buffer, THINK_OPEN);
|
|
let safe = self.buffer.len() - hold;
|
|
if safe > 0 {
|
|
let t: String = self.buffer.drain(..safe).collect();
|
|
events.push(ThinkEvent::Text(t));
|
|
}
|
|
return events;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn tool(name: &str) -> ToolSpec {
|
|
ToolSpec {
|
|
name: name.to_string(),
|
|
description: format!("desc of {name}"),
|
|
parameters: json!({
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}},
|
|
"required": ["path"]
|
|
}),
|
|
}
|
|
}
|
|
|
|
// ── render_tool_block ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn empty_tools_renders_empty() {
|
|
assert_eq!(render_tool_block(&[]), "");
|
|
}
|
|
|
|
#[test]
|
|
fn tool_block_contains_hermes_markers_and_each_function() {
|
|
let block = render_tool_block(&[tool("read_file"), tool("write_file")]);
|
|
assert!(block.contains("# Tools"));
|
|
assert!(block.contains("<tools>"));
|
|
assert!(block.contains("</tools>"));
|
|
assert!(block.contains("\"name\":\"read_file\""));
|
|
assert!(block.contains("\"name\":\"write_file\""));
|
|
assert!(block.contains("<tool_call>"));
|
|
assert!(block.contains("</tool_call>"));
|
|
}
|
|
|
|
// ── render_assistant_with_tool_calls ────────────────────────────
|
|
|
|
#[test]
|
|
fn renders_pure_text_when_no_calls() {
|
|
let out = render_assistant_with_tool_calls(Some("hi"), &[]);
|
|
assert_eq!(out, "hi");
|
|
}
|
|
|
|
#[test]
|
|
fn renders_text_then_tool_call_block() {
|
|
let calls = vec![ToolCall {
|
|
id: "call_0".into(),
|
|
name: "read_file".into(),
|
|
arguments: r#"{"path":"/etc/hostname"}"#.into(),
|
|
}];
|
|
let out = render_assistant_with_tool_calls(Some("reading"), &calls);
|
|
assert!(out.starts_with("reading\n<tool_call>"));
|
|
assert!(out.contains(r#""name":"read_file""#));
|
|
assert!(out.contains(r#""path":"/etc/hostname""#));
|
|
assert!(out.ends_with("</tool_call>"));
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_calls_separated_by_newlines() {
|
|
let calls = vec![
|
|
ToolCall {
|
|
id: "call_0".into(),
|
|
name: "a".into(),
|
|
arguments: "{}".into(),
|
|
},
|
|
ToolCall {
|
|
id: "call_1".into(),
|
|
name: "b".into(),
|
|
arguments: "{}".into(),
|
|
},
|
|
];
|
|
let out = render_assistant_with_tool_calls(None, &calls);
|
|
assert_eq!(out.matches("<tool_call>").count(), 2);
|
|
assert_eq!(out.matches("</tool_call>").count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_arguments_json_is_wrapped_as_string() {
|
|
let calls = vec![ToolCall {
|
|
id: "call_0".into(),
|
|
name: "x".into(),
|
|
arguments: "not even json".into(),
|
|
}];
|
|
let out = render_assistant_with_tool_calls(None, &calls);
|
|
// Wrapped as JSON string rather than breaking the envelope.
|
|
assert!(out.contains(r#""arguments":"not even json""#));
|
|
}
|
|
|
|
// ── render_tool_response ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn tool_response_wraps_content() {
|
|
let out = render_tool_response("hello world");
|
|
assert_eq!(out, "<tool_response>\nhello world\n</tool_response>");
|
|
}
|
|
|
|
// ── longest_marker_prefix_suffix ────────────────────────────────
|
|
|
|
#[test]
|
|
fn marker_prefix_suffix_returns_longest_match() {
|
|
assert_eq!(longest_marker_prefix_suffix("foo<tool", "<tool_call>"), 5);
|
|
assert_eq!(longest_marker_prefix_suffix("foo<", "<tool_call>"), 1);
|
|
assert_eq!(longest_marker_prefix_suffix("foo<bar", "<tool_call>"), 0);
|
|
assert_eq!(longest_marker_prefix_suffix("foo", "<tool_call>"), 0);
|
|
assert_eq!(longest_marker_prefix_suffix("", "<tool_call>"), 0);
|
|
// Exact prefix length matches.
|
|
assert_eq!(
|
|
longest_marker_prefix_suffix("foo<tool_call", "<tool_call>"),
|
|
10
|
|
);
|
|
}
|
|
|
|
// ── ToolCallParser ──────────────────────────────────────────────
|
|
|
|
fn drive(parser: &mut ToolCallParser, chunks: &[&str]) -> Vec<ParserEvent> {
|
|
let mut events = Vec::new();
|
|
for c in chunks {
|
|
events.extend(parser.feed(c));
|
|
}
|
|
events.extend(parser.finish());
|
|
events
|
|
}
|
|
|
|
#[test]
|
|
fn plain_text_passes_through() {
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &["hello ", "world"]);
|
|
assert_eq!(events.len(), 2);
|
|
assert_eq!(events[0], ParserEvent::Text("hello ".to_string()));
|
|
assert_eq!(events[1], ParserEvent::Text("world".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn single_complete_tool_call() {
|
|
let mut p = ToolCallParser::new();
|
|
let input =
|
|
r#"before <tool_call>{"name":"read_file","arguments":{"path":"/x"}}</tool_call> after"#;
|
|
let events = drive(&mut p, &[input]);
|
|
// "before " (text) → Start → Args → " after" (text)
|
|
assert_eq!(events[0], ParserEvent::Text("before ".to_string()));
|
|
assert!(matches!(
|
|
&events[1],
|
|
ParserEvent::Start { index: 0, name } if name == "read_file"
|
|
));
|
|
assert!(matches!(
|
|
&events[2],
|
|
ParserEvent::Args { index: 0, args_json } if args_json.contains(r#""path":"/x""#)
|
|
));
|
|
assert_eq!(events[3], ParserEvent::Text(" after".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn open_marker_split_across_chunks_is_buffered() {
|
|
// The chunk boundary lands inside the opening marker.
|
|
let chunks = [
|
|
"before <",
|
|
"tool_call>",
|
|
r#"{"name":"a","arguments":{}}"#,
|
|
"</tool_call> after",
|
|
];
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &chunks);
|
|
// Despite the split, we get exactly:
|
|
// - "before " as text (the "<" suffix was held)
|
|
// - Start { name: "a" }
|
|
// - Args
|
|
// - " after"
|
|
let texts: Vec<&str> = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(texts.join(""), "before after");
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { name, .. } if name == "a"))
|
|
);
|
|
assert!(events.iter().any(|e| matches!(e, ParserEvent::Args { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn close_marker_split_across_chunks() {
|
|
let chunks = [
|
|
r#"<tool_call>{"name":"a","arguments":{}}<"#,
|
|
"/tool_",
|
|
"call>tail",
|
|
];
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &chunks);
|
|
// Tail should arrive as text after the call is fully parsed.
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { name, .. } if name == "a"))
|
|
);
|
|
let last_text = events.iter().rev().find_map(|e| match e {
|
|
ParserEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
});
|
|
assert_eq!(last_text, Some("tail"));
|
|
}
|
|
|
|
#[test]
|
|
fn one_byte_at_a_time_produces_same_events_as_one_chunk() {
|
|
let input = r#"a<tool_call>{"name":"f","arguments":{"k":1}}</tool_call>b"#;
|
|
|
|
let mut single = ToolCallParser::new();
|
|
let single_events = drive(&mut single, &[input]);
|
|
|
|
let chunks: Vec<String> = input.chars().map(|c| c.to_string()).collect();
|
|
let chunk_refs: Vec<&str> = chunks.iter().map(|s| s.as_str()).collect();
|
|
let mut byte = ToolCallParser::new();
|
|
let byte_events = drive(&mut byte, &chunk_refs);
|
|
|
|
// Concatenated text equals on both paths.
|
|
let text = |evs: &[ParserEvent]| -> String {
|
|
evs.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
};
|
|
assert_eq!(text(&single_events), text(&byte_events));
|
|
// Both paths see exactly one Start and one Args, with the
|
|
// same name and arguments payload.
|
|
let starts: Vec<&str> = byte_events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Start { name, .. } => Some(name.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(starts, vec!["f"]);
|
|
let args: Vec<&str> = byte_events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Args { args_json, .. } => Some(args_json.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(args.len(), 1);
|
|
assert!(args[0].contains(r#""k":1"#));
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_tool_calls_get_distinct_indices() {
|
|
let input = concat!(
|
|
"lead ",
|
|
r#"<tool_call>{"name":"a","arguments":{}}</tool_call>"#,
|
|
" mid ",
|
|
r#"<tool_call>{"name":"b","arguments":{}}</tool_call>"#,
|
|
" tail",
|
|
);
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[input]);
|
|
let starts: Vec<(usize, String)> = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Start { index, name } => Some((*index, name.clone())),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(starts, vec![(0, "a".into()), (1, "b".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn recovers_from_extra_trailing_close_brace() {
|
|
// Real failure case captured in the field: model emits one
|
|
// extra `}` after closing the outer object. Parser should
|
|
// recover, not surface as Malformed.
|
|
let body = r#"<tool_call>{"name":"read_file","arguments":{"path":"/tmp/a"}}}</tool_call>"#;
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[body]);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { name, .. } if name == "read_file")),
|
|
"expected recovered Start event; got {events:?}"
|
|
);
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Malformed { .. })),
|
|
"should not have produced Malformed: {events:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recovers_from_two_extra_trailing_close_braces() {
|
|
let body = r#"<tool_call>{"name":"bash","arguments":{"command":"ls"}}}}</tool_call>"#;
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[body]);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { name, .. } if name == "bash"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recovers_from_name_nested_in_arguments() {
|
|
// Another field-observed failure: the model puts `name`
|
|
// inside `arguments`. Parser should hoist it.
|
|
let body = r#"<tool_call>{"arguments":{"command":"head -100 readme.md","name":"bash"}}</tool_call>"#;
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[body]);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { name, .. } if name == "bash")),
|
|
"expected hoisted Start event; got {events:?}"
|
|
);
|
|
let args: Vec<&str> = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Args { args_json, .. } => Some(args_json.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(args.len(), 1);
|
|
// After hoisting, the `name` key should be removed from
|
|
// arguments so it's not double-counted.
|
|
assert!(!args[0].contains(r#""name""#));
|
|
assert!(args[0].contains(r#""command""#));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_tool_call_block_is_noop() {
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &["before<tool_call></tool_call>after"]);
|
|
// No Start/Args/Malformed events for an empty body; just the
|
|
// surrounding text passes through.
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Start { .. }))
|
|
);
|
|
assert!(
|
|
!events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Malformed { .. }))
|
|
);
|
|
let text: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(text, "beforeafter");
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_tool_call_does_not_crash() {
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[r#"x<tool_call>not valid json</tool_call>y"#]);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Malformed { .. }))
|
|
);
|
|
// Bracketing text still flows.
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Text(t) if t == "x"))
|
|
);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Text(t) if t == "y"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unterminated_tool_call_is_reported_on_finish() {
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[r#"x<tool_call>{"name":"a""#]);
|
|
assert!(
|
|
events
|
|
.iter()
|
|
.any(|e| matches!(e, ParserEvent::Malformed { .. }))
|
|
);
|
|
}
|
|
|
|
// ── ThinkParser ─────────────────────────────────────────────────
|
|
|
|
fn drive_think(parser: &mut ThinkParser, chunks: &[&str]) -> Vec<ThinkEvent> {
|
|
let mut events = Vec::new();
|
|
for c in chunks {
|
|
events.extend(parser.feed(c));
|
|
}
|
|
events.extend(parser.finish());
|
|
events
|
|
}
|
|
|
|
#[test]
|
|
fn think_plain_text_passes_through() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["hello ", "world"]);
|
|
assert_eq!(events.len(), 2);
|
|
assert_eq!(events[0], ThinkEvent::Text("hello ".into()));
|
|
assert_eq!(events[1], ThinkEvent::Text("world".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn think_splits_text_reasoning_text() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["before <think>thinking now</think> after"]);
|
|
assert_eq!(events[0], ThinkEvent::Text("before ".into()));
|
|
assert_eq!(events[1], ThinkEvent::Reasoning("thinking now".into()));
|
|
assert_eq!(events[2], ThinkEvent::Text(" after".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn think_open_marker_split_across_chunks() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["pre <", "think>middle</think> post"]);
|
|
let texts: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ThinkEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let reasoning: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ThinkEvent::Reasoning(r) => Some(r.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(texts, "pre post");
|
|
assert_eq!(reasoning, "middle");
|
|
}
|
|
|
|
#[test]
|
|
fn think_close_marker_split_across_chunks() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["a<think>b<", "/think>c"]);
|
|
let reasoning: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ThinkEvent::Reasoning(r) => Some(r.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(reasoning, "b");
|
|
let last_text = events.iter().rev().find_map(|e| match e {
|
|
ThinkEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
});
|
|
assert_eq!(last_text, Some("c"));
|
|
}
|
|
|
|
#[test]
|
|
fn think_one_byte_at_a_time_matches_single_chunk() {
|
|
let input = "x<think>internal</think>y";
|
|
let mut single = ThinkParser::new();
|
|
let single_events = drive_think(&mut single, &[input]);
|
|
|
|
let chunks: Vec<String> = input.chars().map(|c| c.to_string()).collect();
|
|
let chunk_refs: Vec<&str> = chunks.iter().map(|s| s.as_str()).collect();
|
|
let mut byte = ThinkParser::new();
|
|
let byte_events = drive_think(&mut byte, &chunk_refs);
|
|
|
|
let text = |evs: &[ThinkEvent]| -> (String, String) {
|
|
let mut t = String::new();
|
|
let mut r = String::new();
|
|
for e in evs {
|
|
match e {
|
|
ThinkEvent::Text(s) => t.push_str(s),
|
|
ThinkEvent::Reasoning(s) => r.push_str(s),
|
|
}
|
|
}
|
|
(t, r)
|
|
};
|
|
assert_eq!(text(&single_events), text(&byte_events));
|
|
assert_eq!(text(&byte_events), ("xy".into(), "internal".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn think_empty_block_emits_no_reasoning_event() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["<think></think>real"]);
|
|
// No Reasoning event for an empty <think></think>; just the
|
|
// trailing text.
|
|
assert!(
|
|
!events.iter().any(|e| matches!(e, ThinkEvent::Reasoning(_))),
|
|
"events: {events:?}"
|
|
);
|
|
assert_eq!(events[0], ThinkEvent::Text("real".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn think_unterminated_block_flushes_as_reasoning_on_finish() {
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["x<think>thinking but no close"]);
|
|
assert_eq!(events[0], ThinkEvent::Text("x".into()));
|
|
let reasoning: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ThinkEvent::Reasoning(r) => Some(r.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(reasoning, "thinking but no close");
|
|
}
|
|
|
|
#[test]
|
|
fn think_bare_close_marker_passes_through_as_text() {
|
|
// Model emits </think> with no preceding <think>. Treat the
|
|
// bare close as ordinary text — the agent doesn't try to
|
|
// retroactively reclassify earlier deltas.
|
|
let mut p = ThinkParser::new();
|
|
let events = drive_think(&mut p, &["hello </think> world"]);
|
|
let text: String = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ThinkEvent::Text(t) => Some(t.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(text, "hello </think> world");
|
|
assert!(!events.iter().any(|e| matches!(e, ThinkEvent::Reasoning(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn quoted_lt_inside_args_does_not_trigger_marker() {
|
|
// Sanity: a string value that happens to contain "<tool" is
|
|
// not a marker. (Our marker search is on the literal byte
|
|
// sequence "<tool_call>" / "</tool_call>", so this would
|
|
// only break if a literal "</tool_call>" appeared in args
|
|
// — which the model has no reason to emit.)
|
|
let input = r#"<tool_call>{"name":"f","arguments":{"q":"why <tool emit?"}}</tool_call>"#;
|
|
let mut p = ToolCallParser::new();
|
|
let events = drive(&mut p, &[input]);
|
|
let starts: Vec<&str> = events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
ParserEvent::Start { name, .. } => Some(name.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(starts, vec!["f"]);
|
|
}
|
|
}
|