tool-parser 1.7.0

Tool/function call parser for LLM model outputs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Streaming buffered-content flush tests
//!
//! Regression tests for the streaming content-loss bug observed on PR #2261's
//! e2e runs (all four engines, deterministic): Llama-3.2-1B with
//! `--tool-call-parser llama`, tools present, `tool_choice=auto`, streaming.
//! When the model emits `{`-prefixed text that never becomes a valid declared
//! tool call, the incremental parser buffered everything while waiting for a
//! tool call that never materialized and the client received a completely
//! empty stream — no tool_call deltas AND no content deltas — while the
//! non-streaming path correctly fell back to returning the text as content.
//!
//! Four properties are covered here:
//! 1. Text that is definitively not a declared tool call (complete JSON with
//!    a missing or undeclared name) is surfaced as normal text mid-stream
//!    instead of being buffered forever or silently dropped, while legitimate
//!    tool calls keep streaming unchanged.
//! 2. Text still buffered at end of stream (truncated JSON, partial start
//!    markers) is recoverable via the end-of-stream flush instead of being
//!    swallowed — and nothing is invented once a tool call was announced.
//! 3. A declared tool call trailing a non-tool value in the same chunk still
//!    becomes tool-call deltas rather than stranded text.
//! 4. A call whose name and arguments both land in one chunk streams *both*
//!    in that chunk. There is no later parser call to fall back on, so a name
//!    without arguments would reach the client as an unusable tool call.
mod common;

use common::{create_test_tools, streaming_helpers};
use tool_parser::{
    types::ToolCallItem, CohereParser, JsonParser, LlamaParser, MistralParser, QwenParser,
    ToolParser,
};

/// Representative Llama-3.2-1B output for "What's the weather in Tokyo?" with
/// declared tools: `{`-prefixed JSON that never becomes a valid declared tool
/// call because the function name is under a non-standard key (the parser
/// only recognizes `name`). The CI worker-log artifacts do not record raw
/// generations, so this reproduces the failure class rather than the literal
/// string.
const NON_TOOL_JSON: &str =
    r#"{"type": "function", "function": "get_weather", "parameters": {"city": "Tokyo"}}"#;

/// Valid, complete JSON in llama tool shape, but the name is not among the
/// declared tools.
const UNDECLARED_TOOL_JSON: &str =
    r#"{"name": "get_stock_price", "parameters": {"ticker": "AAPL"}}"#;

type MakeParser = fn() -> Box<dyn ToolParser>;

fn llama() -> Box<dyn ToolParser> {
    Box::new(LlamaParser::new())
}
fn json() -> Box<dyn ToolParser> {
    Box::new(JsonParser::new())
}
fn mistral() -> Box<dyn ToolParser> {
    Box::new(MistralParser::new())
}
fn qwen() -> Box<dyn ToolParser> {
    Box::new(QwenParser::new())
}
fn cohere() -> Box<dyn ToolParser> {
    Box::new(CohereParser::new())
}

/// How a case chunks its input.
enum Feed {
    /// Explicit chunk boundaries, chosen to split mid-token.
    Chunks(&'static [&'static str]),
    /// Realistic 2-3 char chunks: many chunk boundaries inside the JSON.
    Realistic(&'static str),
}

/// Drive a parser through chunked streaming and collect everything it emits.
#[expect(
    clippy::unwrap_used,
    reason = "test helper; allow-unwrap-in-tests only covers #[test] fns"
)]
async fn stream(parser: &mut dyn ToolParser, feed: &Feed) -> (String, Vec<ToolCallItem>) {
    let owned_chunks;
    let owned_refs;
    let chunks: &[&str] = match feed {
        Feed::Chunks(chunks) => chunks,
        Feed::Realistic(input) => {
            owned_chunks = streaming_helpers::create_realistic_chunks(input);
            owned_refs = owned_chunks.iter().map(String::as_str).collect::<Vec<_>>();
            &owned_refs
        }
    };
    let tools = create_test_tools();
    let mut normal_text = String::new();
    let mut calls = Vec::new();
    for chunk in chunks {
        let result = parser.parse_incremental(chunk, &tools).await.unwrap();
        normal_text.push_str(&result.normal_text);
        calls.extend(result.calls);
    }
    (normal_text, calls)
}

fn announced(calls: &[ToolCallItem]) -> Option<&str> {
    calls.iter().find_map(|c| c.name.as_deref())
}

/// Everything the parser streamed as tool-call argument fragments.
fn streamed_args(calls: &[ToolCallItem]) -> String {
    calls.iter().map(|c| c.parameters.as_str()).collect()
}

// ============================================================================
// One table, three properties per row: what the client sees streamed, which
// declared tool (if any) was announced, and what the end-of-stream flush
// yields. Asserting all three for every case is what pins the bug: the
// regression produced an empty stream *and* an empty flush.
// ============================================================================

struct Case {
    label: &'static str,
    make: MakeParser,
    feed: Feed,
    /// Exactly what the client must receive as content.
    text: &'static str,
    /// The declared tool the parser must announce, if any.
    call: Option<&'static str>,
    /// A substring the streamed arguments must contain, when a call is made.
    args_contain: Option<&'static str>,
    /// What the end-of-stream flush must return.
    flush: &'static str,
}

/// Defaults for the common shape: streams nothing, announces nothing, flushes
/// nothing. Each row overrides only what it is actually about.
const BLANK: Case = Case {
    label: "",
    make: llama,
    feed: Feed::Chunks(&[]),
    text: "",
    call: None,
    args_contain: None,
    flush: "",
};

#[tokio::test]
async fn streaming_never_swallows_content() {
    let cases = [
        // --- definitively-not-a-tool JSON must surface as content mid-stream
        Case {
            label: "llama: no `name` key at all, split mid-token",
            feed: Feed::Realistic(NON_TOOL_JSON),
            text: NON_TOOL_JSON,
            ..BLANK
        },
        Case {
            label: "llama: undeclared name split across chunk boundaries",
            feed: Feed::Chunks(&[
                r#"{"name": "get_st"#,
                r#"ock_price", "para"#,
                r#"meters": {"ticker": "AAPL"}}"#,
            ]),
            text: UNDECLARED_TOOL_JSON,
            ..BLANK
        },
        Case {
            label: "json: plain answer that happens to be JSON",
            make: json,
            feed: Feed::Realistic(r#"{"result": 42, "status": "ok"}"#),
            text: r#"{"result": 42, "status": "ok"}"#,
            ..BLANK
        },
        Case {
            // Content-preserving, markers included: everything the model
            // emitted reaches the client, as the non-streaming path does too.
            label: "mistral: undeclared name inside [TOOL_CALLS] array",
            make: mistral,
            feed: Feed::Chunks(&[
                "[TOOL_CALLS] ",
                r#"[{"name": "frobni"#,
                r#"cate", "arguments"#,
                r#"": {"x": 1}}]"#,
            ]),
            text: r#"[TOOL_CALLS] [{"name": "frobnicate", "arguments": {"x": 1}}]"#,
            ..BLANK
        },
        Case {
            label: "qwen: complete JSON inside markers with no `name` key",
            make: qwen,
            feed: Feed::Chunks(&["<tool_call>\n", r#"{"foo": "#, "1}", "\n</tool_call>"]),
            text: "<tool_call>\n{\"foo\": 1}\n</tool_call>",
            ..BLANK
        },
        // --- legitimate tool calls keep streaming, and invent no content
        Case {
            label: "llama: a real declared call still streams name and args",
            feed: Feed::Chunks(&[
                r#"{"name": "get_we"#,
                r#"ather", "parameters"#, // codespell:ignore ather
                r#"": {"city": "Tokyo"}}"#,
            ]),
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Tokyo"}"#),
            ..BLANK
        },
        Case {
            label: "llama: nothing invented after a completed tool call",
            feed: Feed::Chunks(&[r#"{"name": "get_weather", "parameters": {"city": "Tokyo"}}"#]),
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Tokyo"}"#),
            ..BLANK
        },
        Case {
            // The buffered tail is tool syntax, not content: flushing it as
            // text would duplicate the announced call.
            label: "llama: announced call whose arguments are truncated",
            feed: Feed::Chunks(&[r#"{"name": "get_weather", "parameters": {"city": "Par"#]),
            call: Some("get_weather"),
            ..BLANK
        },
        // --- a call whose name and arguments arrive together must stream both
        //     in that single invocation: no later call ever comes
        Case {
            label: "json: whole declared call inside one chunk",
            make: json,
            feed: Feed::Chunks(&[r#"{"name": "get_weather", "arguments": {"city": "Tokyo"}}"#]),
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Tokyo"}"#),
            ..BLANK
        },
        Case {
            label: "mistral: declared call after an undeclared one streams its args",
            make: mistral,
            feed: Feed::Chunks(&[
                r#"[TOOL_CALLS] [{"name": "bogus", "arguments": {}}, {"name": "get_weather", "arguments": {"city": "Paris"}}"#,
                "]",
            ]),
            text: r#"[TOOL_CALLS] [{"name": "bogus", "arguments": {}}, "#,
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Paris"}"#),
            ..BLANK
        },
        Case {
            label: "cohere: complete action block streams name and arguments",
            make: cohere,
            feed: Feed::Chunks(&[
                r#"<|START_ACTION|>{"tool_name": "search", "parameters": {"query": "rust"}}<|END_ACTION|>"#,
                " done",
            ]),
            call: Some("search"),
            args_contain: Some(r#"{"query":"rust"}"#),
            ..BLANK
        },
        Case {
            // The helper bails out on the undeclared, still-incomplete value
            // and emits the whole chunk, so `normal_text_buffer` holds back
            // the trailing partial `</tool_call>`. With no tool call ever
            // announced that suffix is real text, not marker debris.
            label: "qwen: partial </tool_call> held in normal_text_buffer",
            make: qwen,
            feed: Feed::Chunks(&[r#"<tool_call>
{"name": "bogus"</tool"#]),
            text: "<tool_call>\n{\"name\": \"bogus\"",
            flush: "</tool",
            ..BLANK
        },
        Case {
            // Every other cohere emission path strips response markers; the
            // flush must too, rather than leaking control tokens as content.
            label: "cohere: response markers stripped from the flushed text",
            make: cohere,
            feed: Feed::Chunks(&["<|START_RESPONSE|>Hello<|START_AC"]),
            flush: "Hello<|START_AC",
            ..BLANK
        },
        // --- text still buffered at end of stream is recovered by the flush
        Case {
            label: "llama: stream ends mid-name (could still become get_weather)",
            feed: Feed::Chunks(&[r#"{"name": "get_wea"#]),
            flush: r#"{"name": "get_wea"#,
            ..BLANK
        },
        Case {
            label: "llama: partial <|python_tag|> held back at end of stream",
            feed: Feed::Chunks(&["Sure, let me check <|py"]),
            flush: "Sure, let me check <|py",
            ..BLANK
        },
        Case {
            label: "json: truncated prefix of the declared `search`",
            make: json,
            feed: Feed::Chunks(&[r#"{"name": "sea"#]),
            flush: r#"{"name": "sea"#,
            ..BLANK
        },
        Case {
            label: "mistral: truncated tool call after the marker",
            make: mistral,
            feed: Feed::Chunks(&[r#"[TOOL_CALLS] [{"unknown"#]),
            flush: r#"[TOOL_CALLS] [{"unknown"#,
            ..BLANK
        },
        Case {
            label: "qwen: truncated tool call after the marker",
            make: qwen,
            feed: Feed::Chunks(&["<tool_call>\n{\"a\": 1"]),
            flush: "<tool_call>\n{\"a\": 1",
            ..BLANK
        },
        Case {
            label: "cohere: START_ACTION seen, END_ACTION never arrived",
            make: cohere,
            feed: Feed::Chunks(&[
                r#"<|START_ACTION|>{"tool_name": "search", "parameters": {"query": "x"#,
            ]),
            flush: r#"{"tool_name": "search", "parameters": {"query": "x"#,
            ..BLANK
        },
        // --- a declared call trailing a non-tool value in the SAME chunk must
        //     still become tool-call deltas, never stranded text
        Case {
            label: "json: adjacent non-tool value then a declared call",
            make: json,
            feed: Feed::Chunks(&[
                r#"{"note": "checking"} {"name": "get_weather", "arguments": {"city": "Tokyo"}}"#,
            ]),
            text: r#"{"note": "checking"} "#,
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Tokyo"}"#),
            ..BLANK
        },
        Case {
            label: "json: adjacent undeclared call then a declared call",
            make: json,
            feed: Feed::Chunks(&[concat!(
                r#"{"name": "bogus_tool", "arguments": {}}"#,
                r#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#
            )]),
            text: r#"{"name": "bogus_tool", "arguments": {}}"#,
            call: Some("get_weather"),
            args_contain: Some(r#"{"city":"Paris"}"#),
            ..BLANK
        },
    ];

    for case in &cases {
        let mut parser = (case.make)();
        let (text, calls) = stream(parser.as_mut(), &case.feed).await;

        assert_eq!(text, case.text, "[{}] streamed content", case.label);
        assert_eq!(announced(&calls), case.call, "[{}] tool call", case.label);
        if let Some(fragment) = case.args_contain {
            let args = streamed_args(&calls);
            assert!(
                args.contains(fragment),
                "[{}] arguments must be streamed, got {args:?}",
                case.label
            );
        }
        assert_eq!(
            parser.take_unstreamed_normal_text(),
            case.flush,
            "[{}] end-of-stream flush",
            case.label
        );
        assert_eq!(
            parser.take_unstreamed_normal_text(),
            "",
            "[{}] flush must drain: a second take returns nothing",
            case.label
        );
    }
}

#[tokio::test]
async fn partial_declared_name_keeps_buffering() {
    let mut parser = LlamaParser::new();
    let tools = create_test_tools();

    // "get_we" is a prefix of the declared "get_weather": the parser must NOT
    // bail out to normal text while the name string is still open.
    let result = parser
        .parse_incremental(r#"{"name": "get_we"#, &tools)
        .await
        .unwrap();
    assert_eq!(result.normal_text, "");
    assert!(result.calls.is_empty());

    let result = parser
        .parse_incremental(r#"ather", "parameters": {"city": "Paris"}}"#, &tools) // codespell:ignore ather
        .await
        .unwrap();
    assert_eq!(
        announced(&result.calls),
        Some("get_weather"),
        "declared tool call must be announced once the name completes"
    );
}

#[tokio::test]
async fn reset_clears_the_streaming_buffers() {
    let tools = create_test_tools();

    // The shared buffer every helper-based parser holds.
    let mut llama = LlamaParser::new();
    llama
        .parse_incremental(r#"{"name": "get_wea"#, &tools)
        .await
        .unwrap();
    llama.reset();
    assert_eq!(
        llama.take_unstreamed_normal_text(),
        "",
        "reset must clear the streaming buffer"
    );

    // Qwen additionally parks a partial `</tool_call>` in `normal_text_buffer`,
    // which reset() must clear too or it bleeds into the next request.
    let held = r#"<tool_call>
{"name": "bogus"</tool"#;
    let mut qwen = QwenParser::new();
    qwen.parse_incremental(held, &tools).await.unwrap();
    let mut drained = QwenParser::new();
    drained.parse_incremental(held, &tools).await.unwrap();
    assert_eq!(
        drained.take_unstreamed_normal_text(),
        "</tool",
        "precondition: normal_text_buffer is holding the partial end token"
    );

    qwen.reset();
    assert_eq!(
        qwen.take_unstreamed_normal_text(),
        "",
        "reset must clear normal_text_buffer, not just the main buffer"
    );
}