1 //! Recovery for tool calls a model emits as literal text instead of the
2 //! endpoint's structured `tool_calls` field.
3 //!
4 //! Several open-weight families siGit Code can drive are fine-tuned on chat
5 //! templates that render a tool call inside assistant content. A serving stack
6 //! is supposed to parse that back into the OpenAI-shaped `tool_calls` field
7 //! before it reaches us. When it doesn't, the raw tag arrives as ordinary
8 //! content.
9 //!
10 //! Nothing then executes it. Worse, `consume_stream` forwards content to the
11 //! UI as it arrives, so the tag is rendered verbatim in the editor and the
12 //! turn ends with no tool calls, which reads to the agent loop as "the model
13 //! chose to answer in prose". Whatever it was actually trying to do is
14 //! dropped.
15 //!
16 //! This lives here rather than only in the gateway because siGit Code talks to
17 //! more than one kind of endpoint: siGit Code Cloud, an arbitrary
18 //! OpenAI-compatible base URL from `providers.toml` or `OPENAI_BASE_URL`, and
19 //! on-device models. A fix in any single upstream leaves the others exposed.
20 //!
21 //! Only well-formed shapes are recovered. A block that doesn't match is never
22 //! guessed at: reissuing a `run_command` is cheap, but guessing wrong at a
23 //! half-parsed `edit_file` would write the wrong change to a file. It isn't
24 //! shown either. A tool-call block that fails to parse, or that is still open
25 //! when the reply ends, is reported as [`ScanEvent::Malformed`] and kept out of
26 //! both the UI and the conversation history. Left in the history, the model
27 //! reads back a call it "made" with no result attached, and later turns start
28 //! inventing `<function_results>` blocks of their own. The backend tells the
29 //! model the call didn't run and asks for it again instead.
30 //!
31 //! A marker the model merely mentions in prose, say while explaining this very
32 //! scanner, is not a call. So a block that fails to parse is only treated as
33 //! malformed when what follows the marker looks like the start of a call (see
34 //! [`looks_like_xml_call`] and [`looks_like_k3_call`]); otherwise it stays
35 //! text, as it always did.
36
37 use std::collections::HashMap;
38
39 use crate::backend::ToolSpec;
40
41 const XML_OPEN_TAG: &str = "<tool_call>";
42 const XML_CLOSE_TAG: &str = "</tool_call>";
43
44 const K3_TOOLS_OPEN: &str = "<|open|>tools<|sep|>";
45 const K3_TOOLS_CLOSE: &str = "<|close|>tools<|sep|>";
46 const K3_CALL_OPEN: &str = "<|open|>call";
47 const K3_CALL_CLOSE: &str = "<|close|>call<|sep|>";
48 const K3_ARGUMENT_OPEN: &str = "<|open|>argument";
49 const K3_ARGUMENT_CLOSE: &str = "<|close|>argument<|sep|>";
50 const K3_RESPONSE_OPEN: &str = "<|open|>response<|sep|>";
51 const K3_RESPONSE_CLOSE: &str = "<|close|>response<|sep|>";
52 const K3_THINK_OPEN: &str = "<|open|>think<|sep|>";
53 const K3_THINK_CLOSE: &str = "<|close|>think<|sep|>";
54 const K3_SEP: &str = "<|sep|>";
55
56 const DSML_TOOL_CALLS_OPEN: &str = "<|DSML|tool_calls>";
57 const DSML_TOOL_CALLS_CLOSE: &str = "<|DSML|/tool_calls>";
58 const DSML_INVOKE_OPEN: &str = "<|DSML|invoke";
59 const DSML_INVOKE_CLOSE: &str = "<|DSML|/invoke>";
60 const DSML_PARAMETER_OPEN: &str = "<|DSML|parameter";
61 const DSML_PARAMETER_CLOSE: &str = "<|DSML|/parameter>";
62
63 const START_MARKERS: &[&str] = &[
64 XML_OPEN_TAG,
65 K3_TOOLS_OPEN,
66 K3_RESPONSE_OPEN,
67 K3_THINK_OPEN,
68 DSML_TOOL_CALLS_OPEN,
69 ];
70
71 /// One tool call recovered from inline text.
72 #[derive(Debug, Clone, PartialEq, Eq)]
73 pub struct Recovered {
74 pub name: String,
75 /// Arguments as a JSON-encoded string, matching `ToolCall::arguments`.
76 pub arguments: String,
77 }
78
79 /// What [`extract`] found in a complete text blob.
80 #[derive(Debug, Default, PartialEq, Eq)]
81 pub struct Extracted {
82 /// The text with every tool-call block removed, recovered or not.
83 pub text: String,
84 /// Calls recovered from well-formed blocks, in order.
85 pub calls: Vec<Recovered>,
86 /// Tool-call blocks that were dropped because they couldn't be parsed.
87 pub malformed: usize,
88 }
89
90 /// Scan a complete text blob for inline tool-call blocks. Recovered blocks
91 /// become calls; blocks that don't parse cleanly, including an unterminated
92 /// trailing one, are dropped and counted in [`Extracted::malformed`].
93 pub fn extract(text: &str, tools: &[ToolSpec]) -> Extracted {
94 let mut scanner = StreamScanner::new(tools);
95 let mut extracted = Extracted {
96 text: String::with_capacity(text.len()),
97 ..Extracted::default()
98 };
99
100 for event in scanner.push(text).into_iter().chain(scanner.finish()) {
101 match event {
102 ScanEvent::Text(text) => extracted.text.push_str(&text),
103 ScanEvent::ToolCall(call) => extracted.calls.push(call),
104 ScanEvent::Malformed(_) => extracted.malformed += 1,
105 }
106 }
107
108 extracted
109 }
110
111 /// Parse one legacy XML block's inner text: an offered tool name, then zero or
112 /// more `<arg_key>K</arg_key><arg_value>V</arg_value>` pairs. The malformed
113 /// opening marker observed from GLM is accepted only for the first argument;
114 /// later arguments stay strict so text that merely resembles a call cannot be
115 /// recovered as one.
116 fn parse_xml_block(inner: &str, tools: &[ToolSpec]) -> Option<Recovered> {
117 let key_idx = inner.find("<arg_key>");
118 let malformed_key_idx = inner.find(XML_OPEN_TAG);
119 let check_status_name = key_idx.and_then(|idx| parse_check_status_preamble(&inner[..idx]));
120 let (name, mut rest, malformed_first_key) =
121 if let (Some(idx), Some(name)) = (key_idx, check_status_name) {
122 (name, &inner[idx..], false)
123 } else {
124 match (key_idx, malformed_key_idx) {
125 (Some(key_idx), Some(malformed_key_idx)) if malformed_key_idx < key_idx => (
126 inner[..malformed_key_idx].trim(),
127 &inner[malformed_key_idx..],
128 true,
129 ),
130 (Some(idx), _) => (inner[..idx].trim(), &inner[idx..], false),
131 (None, Some(idx)) => (inner[..idx].trim(), &inner[idx..], true),
132 (None, None) => (inner.trim(), "", false),
133 }
134 };
135 if name.is_empty() || name.contains(['<', '>']) || !offered_tool(tools, name) {
136 return None;
137 }
138
139 let mut args = serde_json::Map::new();
140 let mut first_key = true;
141 while !rest.is_empty() {
142 // The GLM alias is permitted exactly once, at the first key. A later
143 // `<tool_call>` must reject the complete block rather than letting a
144 // partial call escape recovery.
145 if !first_key && rest.starts_with(XML_OPEN_TAG) {
146 return None;
147 }
148 let key_open = if first_key && malformed_first_key {
149 XML_OPEN_TAG
150 } else {
151 "<arg_key>"
152 };
153 let (key, after_key) = split_once(rest.strip_prefix(key_open)?, "</arg_key>")?;
154 let (value, after_value) =
155 split_once(after_key.strip_prefix("<arg_value>")?, "</arg_value>")?;
156 let key = key.trim();
157 if key.is_empty() {
158 return None;
159 }
160 args.insert(
161 key.to_string(),
162 coerce(value, declared_type(tools, name, key).as_deref()),
163 );
164 rest = after_value;
165 first_key = false;
166 }
167
168 Some(Recovered {
169 name: name.to_string(),
170 arguments: serde_json::Value::Object(args).to_string(),
171 })
172 }
173
174 /// Some GLM-family responses prefix the real arguments with generation-control
175 /// metadata shaped like `tool_name CheckStatus=value</arg_value>`. It is not a
176 /// tool argument: accept that exact bounded preamble and discard it, while
177 /// leaving every other malformed prefix untouched.
178 fn parse_check_status_preamble(prefix: &str) -> Option<&str> {
179 let (name, status) = prefix.trim().split_once(" CheckStatus=")?;
180 let status = status.strip_suffix("</arg_value>")?;
181 if name.is_empty()
182 || status.is_empty()
183 || !status
184 .chars()
185 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
186 {
187 return None;
188 }
189 Some(name)
190 }
191
192 fn parse_k3_tools_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
193 let mut calls = Vec::new();
194 let mut rest = inner;
195
196 while !rest.trim().is_empty() {
197 rest = rest.trim_start();
198 let header = rest.strip_prefix(K3_CALL_OPEN)?;
199 let (attr_text, after_header) = split_once(header, K3_SEP)?;
200 let attrs = parse_k3_attributes(attr_text)?;
201 let name = attrs.get("tool")?;
202 if name.is_empty() || !offered_tool(tools, name) {
203 return None;
204 }
205
206 let (call_inner, after_call) = split_once(after_header, K3_CALL_CLOSE)?;
207 let arguments = parse_k3_arguments(call_inner, tools, name)?;
208 calls.push(Recovered {
209 name: name.to_string(),
210 arguments: serde_json::Value::Object(arguments).to_string(),
211 });
212 rest = after_call;
213 }
214
215 Some(calls)
216 }
217
218 fn parse_k3_arguments(
219 mut rest: &str,
220 tools: &[ToolSpec],
221 tool_name: &str,
222 ) -> Option<serde_json::Map<String, serde_json::Value>> {
223 let mut args = serde_json::Map::new();
224
225 while !rest.trim().is_empty() {
226 rest = rest.trim_start();
227 let header = rest.strip_prefix(K3_ARGUMENT_OPEN)?;
228 let (attr_text, after_header) = split_once(header, K3_SEP)?;
229 let attrs = parse_k3_attributes(attr_text)?;
230 let key = attrs.get("key")?.trim();
231 if key.is_empty() {
232 return None;
233 }
234 // K3 normally emits `type` for every argument. If an endpoint omits
235 // it, retain the legacy recovery path's schema-based typing instead of
236 // turning e.g. an integer `task_id` into a JSON string.
237 let schema_type = declared_type(tools, tool_name, key);
238 let value_type = attrs
239 .get("type")
240 .map(String::as_str)
241 .or(schema_type.as_deref())
242 .unwrap_or("string");
243
244 let (raw_value, after_argument) = split_once(after_header, K3_ARGUMENT_CLOSE)?;
245 let value = if value_type == "string" {
246 serde_json::Value::String(raw_value.to_string())
247 } else {
248 serde_json::from_str(raw_value).ok()?
249 };
250 args.insert(key.to_string(), value);
251 rest = after_argument;
252 }
253
254 Some(args)
255 }
256
257 fn parse_k3_attributes(mut text: &str) -> Option<HashMap<String, String>> {
258 let mut attrs = HashMap::new();
259 text = text.trim();
260
261 while !text.is_empty() {
262 let eq_idx = text.find('=')?;
263 let key = text[..eq_idx].trim();
264 if key.is_empty()
265 || !key
266 .chars()
267 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
268 {
269 return None;
270 }
271
272 let mut value_part = text[eq_idx + 1..].trim_start();
273 value_part = value_part.strip_prefix('"')?;
274 let end_idx = value_part.find('"')?;
275 let raw_value = &value_part[..end_idx];
276 attrs.insert(key.to_string(), decode_k3_attribute(raw_value));
277
278 text = value_part[end_idx + 1..].trim_start();
279 }
280
281 Some(attrs)
282 }
283
284 fn decode_k3_attribute(value: &str) -> String {
285 // K3's encoder escapes only `&` and `"` in attributes. These replacements
286 // deliberately mirror that format rather than implementing general HTML
287 // entity decoding (which could alter literal argument names).
288 value.replace("&quot;", "\"").replace("&amp;", "&")
289 }
290
291 /// Parse a DeepSeek DSML `<|DSML|tool_calls>` envelope's inner text: zero or
292 /// more `<|DSML|invoke name="$TOOL">...</|DSML|invoke>` blocks, each
293 /// holding `<|DSML|parameter name="$KEY" string="true|false">...` entries.
294 fn parse_dsml_block(inner: &str, tools: &[ToolSpec]) -> Option<Vec<Recovered>> {
295 let mut calls = Vec::new();
296 let mut rest = inner;
297
298 while !rest.trim().is_empty() {
299 rest = rest.trim_start();
300 let header = rest.strip_prefix(DSML_INVOKE_OPEN)?;
301 let (attr_text, after_header) = split_once(header, ">")?;
302 let attrs = parse_k3_attributes(attr_text)?;
303 let name = attrs.get("name")?;
304 if name.is_empty() || !offered_tool(tools, name) {
305 return None;
306 }
307
308 let (call_inner, after_call) = split_once(after_header, DSML_INVOKE_CLOSE)?;
309 let arguments = parse_dsml_parameters(call_inner, tools, name)?;
310 calls.push(Recovered {
311 name: name.to_string(),
312 arguments: serde_json::Value::Object(arguments).to_string(),
313 });
314 rest = after_call;
315 }
316
317 Some(calls)
318 }
319
320 fn parse_dsml_parameters(
321 mut rest: &str,
322 tools: &[ToolSpec],
323 tool_name: &str,
324 ) -> Option<serde_json::Map<String, serde_json::Value>> {
325 let mut args = serde_json::Map::new();
326
327 while !rest.trim().is_empty() {
328 rest = rest.trim_start();
329 let header = rest.strip_prefix(DSML_PARAMETER_OPEN)?;
330 let (attr_text, after_header) = split_once(header, ">")?;
331 let attrs = parse_k3_attributes(attr_text)?;
332 let key = attrs.get("name")?.trim();
333 if key.is_empty() {
334 return None;
335 }
336 // DSML marks each parameter's encoding with `string="true|false"`. If
337 // an endpoint omits it, fall back to the schema-based typing the
338 // other recovery paths use instead of mistyping the value.
339 let is_string = match attrs.get("string").map(String::as_str) {
340 Some("true") => true,
341 Some("false") => false,
342 _ => {
343 declared_type(tools, tool_name, key)
344 .as_deref()
345 .unwrap_or("string")
346 == "string"
347 }
348 };
349
350 let (raw_value, after_param) = split_once(after_header, DSML_PARAMETER_CLOSE)?;
351 let value = if is_string {
352 serde_json::Value::String(raw_value.to_string())
353 } else {
354 serde_json::from_str(raw_value).ok()?
355 };
356 args.insert(key.to_string(), value);
357 rest = after_param;
358 }
359
360 Some(args)
361 }
362
363 fn split_once<'a>(s: &'a str, delim: &str) -> Option<(&'a str, &'a str)> {
364 let idx = s.find(delim)?;
365 Some((&s[..idx], &s[idx + delim.len()..]))
366 }
367
368 fn offered_tool(tools: &[ToolSpec], tool_name: &str) -> bool {
369 tools.iter().any(|spec| spec.name == tool_name)
370 }
371
372 /// The JSON Schema `type` declared for `tool_name`'s `key` parameter, e.g.
373 /// `"integer"` for `command_output`'s `task_id`. [`ToolSpec`] carries the
374 /// schema as an unparsed string, so this parses it per lookup; the argument
375 /// counts involved are tiny and this only runs on the recovery path.
376 fn declared_type(tools: &[ToolSpec], tool_name: &str, key: &str) -> Option<String> {
377 let spec = tools.iter().find(|spec| spec.name == tool_name)?;
378 let schema: serde_json::Value = serde_json::from_str(&spec.parameters_schema).ok()?;
379 Some(
380 schema
381 .get("properties")?
382 .get(key)?
383 .get("type")?
384 .as_str()?
385 .to_string(),
386 )
387 }
388
389 /// Convert a raw legacy text value to JSON per its declared schema type.
390 /// Falls back to a plain string for `"string"`, an unknown type, or a value
391 /// that doesn't parse as what it claims to be.
392 fn coerce(raw: &str, declared: Option<&str>) -> serde_json::Value {
393 match declared {
394 Some("integer") => raw
395 .trim()
396 .parse::<i64>()
397 .map(serde_json::Value::from)
398 .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
399 Some("number") => raw
400 .trim()
401 .parse::<f64>()
402 .ok()
403 .and_then(serde_json::Number::from_f64)
404 .map(serde_json::Value::Number)
405 .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
406 Some("boolean") => match raw.trim() {
407 "true" => serde_json::Value::Bool(true),
408 "false" => serde_json::Value::Bool(false),
409 _ => serde_json::Value::String(raw.to_string()),
410 },
411 _ => serde_json::Value::String(raw.to_string()),
412 }
413 }
414
415 /// One event from incrementally scanning a stream of content deltas.
416 #[derive(Debug, Clone, PartialEq, Eq)]
417 pub enum ScanEvent {
418 /// Plain text, safe to forward to the UI.
419 Text(String),
420 /// A tool call recovered from a completed block.
421 ToolCall(Recovered),
422 /// A tool-call block that couldn't be parsed, or never closed. Carries the
423 /// raw block for logging; it must not be shown or executed.
424 Malformed(String),
425 }
426
427 /// Incremental scanner for a live delta stream.
428 ///
429 /// Holds back only the text that could still turn into a known inline protocol
430 /// marker, so ordinary answers still reach the UI token by token. Buffering
431 /// starts once an opening marker appears, and lasts only until the matching
432 /// close arrives.
433 pub struct StreamScanner<'a> {
434 tools: &'a [ToolSpec],
435 pending: String,
436 }
437
438 impl<'a> StreamScanner<'a> {
439 pub fn new(tools: &'a [ToolSpec]) -> Self {
440 Self {
441 tools,
442 pending: String::new(),
443 }
444 }
445
446 /// Feed the next content delta. Returns events to emit now, in order; may
447 /// be empty if the chunk was absorbed into a tag that hasn't closed yet.
448 pub fn push(&mut self, chunk: &str) -> Vec<ScanEvent> {
449 self.pending.push_str(chunk);
450 self.drain()
451 }
452
453 /// Call once the stream has ended to flush whatever is still held back.
454 /// A tool-call block that opened but never closed is [`ScanEvent::Malformed`]:
455 /// it can't be executed safely, and it isn't prose either. Anything else,
456 /// such as a partial marker prefix or an unclosed Kimi response wrapper, is
457 /// handed back as text.
458 pub fn finish(&mut self) -> Option<ScanEvent> {
459 if self.pending.is_empty() {
460 return None;
461 }
462 let rest = std::mem::take(&mut self.pending);
463 Some(
464 if rest
465 .strip_prefix(K3_TOOLS_OPEN)
466 .is_some_and(looks_like_k3_call)
467 || rest
468 .strip_prefix(XML_OPEN_TAG)
469 .is_some_and(looks_like_xml_call)
470 || rest
471 .strip_prefix(DSML_TOOL_CALLS_OPEN)
472 .is_some_and(looks_like_dsml_call)
473 {
474 ScanEvent::Malformed(rest)
475 } else {
476 ScanEvent::Text(rest)
477 },
478 )
479 }
480
481 fn drain(&mut self) -> Vec<ScanEvent> {
482 let mut events = Vec::new();
483 loop {
484 let Some((open_idx, marker)) = find_next_marker(&self.pending) else {
485 let keep = max_partial_prefix_len(&self.pending, START_MARKERS);
486 let flush_len = self.pending.len() - keep;
487 if flush_len > 0 {
488 events.push(ScanEvent::Text(
489 self.pending.drain(..flush_len).collect::<String>(),
490 ));
491 }
492 break;
493 };
494
495 if open_idx > 0 {
496 events.push(ScanEvent::Text(
497 self.pending.drain(..open_idx).collect::<String>(),
498 ));
499 }
500
501 match marker {
502 XML_OPEN_TAG => {
503 let Some(event) = self.drain_xml() else {
504 break;
505 };
506 events.push(event);
507 }
508 K3_TOOLS_OPEN => match self.drain_k3_tools() {
509 Some(mut recovered) => events.append(&mut recovered),
510 None => break,
511 },
512 DSML_TOOL_CALLS_OPEN => match self.drain_dsml() {
513 Some(mut recovered) => events.append(&mut recovered),
514 None => break,
515 },
516 K3_RESPONSE_OPEN => {
517 let Some(mut unwrapped) =
518 self.drain_wrapped_text(K3_RESPONSE_OPEN, K3_RESPONSE_CLOSE, true)
519 else {
520 break;
521 };
522 events.append(&mut unwrapped);
523 }
524 K3_THINK_OPEN => {
525 let Some(mut unwrapped) =
526 self.drain_wrapped_text(K3_THINK_OPEN, K3_THINK_CLOSE, false)
527 else {
528 break;
529 };
530 events.append(&mut unwrapped);
531 }
532 _ => unreachable!("unknown inline marker"),
533 }
534 }
535 events
536 }
537
538 fn drain_xml(&mut self) -> Option<ScanEvent> {
539 let close_rel = self.pending[XML_OPEN_TAG.len()..].find(XML_CLOSE_TAG)?;
540 let close_idx = XML_OPEN_TAG.len() + close_rel;
541 let block: String = self
542 .pending
543 .drain(..close_idx + XML_CLOSE_TAG.len())
544 .collect();
545 let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
546 Some(match parse_xml_block(inner, self.tools) {
547 Some(call) => ScanEvent::ToolCall(call),
548 None if looks_like_xml_call(inner) => ScanEvent::Malformed(block),
549 None => ScanEvent::Text(block),
550 })
551 }
552
553 fn drain_k3_tools(&mut self) -> Option<Vec<ScanEvent>> {
554 let close_rel = self.pending[K3_TOOLS_OPEN.len()..].find(K3_TOOLS_CLOSE)?;
555 let close_idx = K3_TOOLS_OPEN.len() + close_rel;
556 let block: String = self
557 .pending
558 .drain(..close_idx + K3_TOOLS_CLOSE.len())
559 .collect();
560 let inner = &block[K3_TOOLS_OPEN.len()..block.len() - K3_TOOLS_CLOSE.len()];
561
562 Some(match parse_k3_tools_block(inner, self.tools) {
563 Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
564 None if looks_like_k3_call(inner) => vec![ScanEvent::Malformed(block)],
565 None => vec![ScanEvent::Text(block)],
566 })
567 }
568
569 fn drain_dsml(&mut self) -> Option<Vec<ScanEvent>> {
570 let close_rel = self.pending[DSML_TOOL_CALLS_OPEN.len()..].find(DSML_TOOL_CALLS_CLOSE)?;
571 let close_idx = DSML_TOOL_CALLS_OPEN.len() + close_rel;
572 let block: String = self
573 .pending
574 .drain(..close_idx + DSML_TOOL_CALLS_CLOSE.len())
575 .collect();
576 let inner = &block[DSML_TOOL_CALLS_OPEN.len()..block.len() - DSML_TOOL_CALLS_CLOSE.len()];
577
578 Some(match parse_dsml_block(inner, self.tools) {
579 Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
580 None if looks_like_dsml_call(inner) => vec![ScanEvent::Malformed(block)],
581 None => vec![ScanEvent::Text(block)],
582 })
583 }
584
585 fn drain_wrapped_text(
586 &mut self,
587 open: &str,
588 close: &str,
589 emit_inner: bool,
590 ) -> Option<Vec<ScanEvent>> {
591 let after_open = &self.pending[open.len()..];
592 let close_rel = match after_open.find(close) {
593 Some(close_rel) => close_rel,
594 None => {
595 // A malformed response/think wrapper must not swallow a later
596 // valid tool block forever. Preserve the malformed prefix as
597 // text, leave the next marker pending, and resume scanning.
598 let (next_rel, _) = find_next_marker(after_open)?;
599 return Some(vec![ScanEvent::Text(
600 self.pending
601 .drain(..open.len() + next_rel)
602 .collect::<String>(),
603 )]);
604 }
605 };
606 let close_idx = open.len() + close_rel;
607 let block: String = self.pending.drain(..close_idx + close.len()).collect();
608
609 if emit_inner {
610 let inner = &block[open.len()..block.len() - close.len()];
611 Some(scan_complete_text(inner, self.tools))
612 } else {
613 Some(Vec::new())
614 }
615 }
616 }
617
618 /// Scan text from a wrapper whose closing marker has already arrived. This
619 /// preserves event order when a response body itself contains an inline block.
620 /// Whether the text after a `<tool_call>` tag reads as the start of a call:
621 /// a tool name straight after the tag or on the next line, a JSON object or
622 /// another tag after any whitespace, or nothing at all (a reply cut off right
623 /// after the tag). Prose that mentions the tag, like "`<tool_call>` wraps..."
624 /// or "<tool_call> and </tool_call> tags", fails every one of those.
625 fn looks_like_xml_call(after_tag: &str) -> bool {
626 let after_newlines = after_tag.trim_start_matches(['\n', '\r']);
627 if after_newlines
628 .chars()
629 .next()
630 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
631 {
632 return true;
633 }
634 let rest = after_newlines.trim_start();
635 rest.is_empty() || rest.starts_with(['{', '<'])
636 }
637
638 /// Whether the text after a Kimi `<|open|>tools<|sep|>` marker reads as the
639 /// start of a call: another `<|open|>call` marker after any whitespace, or
640 /// nothing at all. A quoted marker, like the constant in this file, fails.
641 fn looks_like_k3_call(after_marker: &str) -> bool {
642 let rest = after_marker.trim_start();
643 rest.is_empty() || rest.starts_with(K3_CALL_OPEN)
644 }
645
646 /// Whether the text after a DeepSeek `<|DSML|tool_calls>` marker reads as
647 /// the start of a call: a `<|DSML|invoke` marker after any whitespace, or
648 /// nothing at all. A quoted marker, like the constant in this file, fails.
649 fn looks_like_dsml_call(after_marker: &str) -> bool {
650 let rest = after_marker.trim_start();
651 rest.is_empty() || rest.starts_with(DSML_INVOKE_OPEN)
652 }
653
654 fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
655 let mut scanner = StreamScanner::new(tools);
656 let mut events = scanner.push(text);
657 events.extend(scanner.finish());
658 events
659 }
660
661 fn find_next_marker<'a>(text: &str) -> Option<(usize, &'a str)> {
662 START_MARKERS
663 .iter()
664 .filter_map(|marker| text.find(marker).map(|idx| (idx, *marker)))
665 .min_by_key(|(idx, _)| *idx)
666 }
667
668 /// Length of the longest suffix of `buf` that is a prefix of any known opening
669 /// marker. Lets a split opening tag be caught without delaying text that can't
670 /// possibly be part of one.
671 fn max_partial_prefix_len(buf: &str, needles: &[&str]) -> usize {
672 needles
673 .iter()
674 .map(|needle| partial_prefix_len(buf, needle))
675 .max()
676 .unwrap_or(0)
677 }
678
679 fn partial_prefix_len(buf: &str, needle: &str) -> usize {
680 let max = buf.len().min(needle.len() - 1);
681 for len in (1..=max).rev() {
682 let start = buf.len() - len;
683 if buf.is_char_boundary(start) && needle.starts_with(&buf[start..]) {
684 return len;
685 }
686 }
687 0
688 }
689
690 #[cfg(test)]
691 mod tests {
692 use super::*;
693
694 fn spec(name: &str, schema: serde_json::Value) -> ToolSpec {
695 ToolSpec {
696 name: name.to_string(),
697 description: String::new(),
698 parameters_schema: schema.to_string(),
699 }
700 }
701
702 fn command_output_spec() -> ToolSpec {
703 spec(
704 "command_output",
705 serde_json::json!({
706 "type": "object",
707 "properties": { "task_id": { "type": "integer" } }
708 }),
709 )
710 }
711
712 fn run_command_spec() -> ToolSpec {
713 spec(
714 "run_command",
715 serde_json::json!({
716 "type": "object",
717 "properties": {
718 "command": { "type": "string" },
719 "cwd": { "type": "string" },
720 "run_in_background": { "type": "boolean" }
721 }
722 }),
723 )
724 }
725
726 #[test]
727 fn an_integer_argument_is_recovered_as_a_json_number() {
728 let tools = vec![command_output_spec()];
729 let Extracted { text, calls, .. } = extract(
730 "Checking.<tool_call>command_output<arg_key>task_id</arg_key><arg_value>2</arg_value></tool_call>",
731 &tools,
732 );
733 assert_eq!(text, "Checking.");
734 assert_eq!(calls.len(), 1);
735 assert_eq!(calls[0].name, "command_output");
736 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
737 assert_eq!(args["task_id"], 2);
738 assert!(args["task_id"].is_number());
739 }
740
741 #[test]
742 fn recovers_multiple_string_arguments_in_order() {
743 let tools = vec![run_command_spec()];
744 let Extracted { text, calls, .. } = extract(
745 "<tool_call>run_command<arg_key>command</arg_key><arg_value>cargo test --locked 2>&1 | tail -20</arg_value><arg_key>cwd</arg_key><arg_value>/Users/x/sigit</arg_value><arg_key>run_in_background</arg_key><arg_value>true</arg_value></tool_call>",
746 &tools,
747 );
748 assert_eq!(text, "");
749 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
750 assert_eq!(args["command"], "cargo test --locked 2>&1 | tail -20");
751 assert_eq!(args["cwd"], "/Users/x/sigit");
752 assert_eq!(args["run_in_background"], true);
753 }
754
755 #[test]
756 fn recovers_glm_call_with_a_malformed_first_argument_marker() {
757 let tools = vec![run_command_spec()];
758 let Extracted { text, calls, .. } = extract(
759 "<tool_call>run_command<tool_call>command</arg_key><arg_value>pwd</arg_value><arg_key>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>",
760 &tools,
761 );
762
763 assert_eq!(text, "");
764 assert_eq!(calls.len(), 1);
765 assert_eq!(calls[0].name, "run_command");
766 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
767 assert_eq!(args["command"], "pwd");
768 assert_eq!(args["cwd"], "/tmp");
769 }
770
771 #[test]
772 fn recovers_glm_call_with_check_status_before_real_arguments() {
773 let tools = vec![command_output_spec()];
774 let Extracted { text, calls, .. } = extract(
775 "polling <tool_call>command_output CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>",
776 &tools,
777 );
778
779 assert_eq!(text, "polling ");
780 assert_eq!(calls.len(), 1);
781 assert_eq!(calls[0].name, "command_output");
782 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
783 assert_eq!(args["task_id"], 1);
784 assert!(args["task_id"].is_number());
785 assert!(args.get("CheckStatus").is_none());
786 }
787
788 #[test]
789 fn malformed_check_status_preamble_is_dropped() {
790 let text = "<tool_call>command_output CheckStatus=keep going</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
791 let Extracted {
792 text: out,
793 calls,
794 malformed,
795 } = extract(text, &[command_output_spec()]);
796 assert_eq!(out, "");
797 assert_eq!(malformed, 1);
798 assert!(calls.is_empty());
799 }
800
801 #[test]
802 fn check_status_after_a_real_argument_is_dropped() {
803 let text = "<tool_call>command_output<arg_key>task_id</arg_key><arg_value>1</arg_value> CheckStatus=true_or_poll_again_with_different_params</arg_value></tool_call>";
804 let Extracted {
805 text: out,
806 calls,
807 malformed,
808 } = extract(text, &[command_output_spec()]);
809 assert_eq!(out, "");
810 assert_eq!(malformed, 1);
811 assert!(calls.is_empty());
812 }
813
814 #[test]
815 fn check_status_for_an_unknown_tool_is_dropped() {
816 let text = "<tool_call>not_offered CheckStatus=true_or_poll_again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>";
817 let Extracted {
818 text: out,
819 calls,
820 malformed,
821 } = extract(text, &[command_output_spec()]);
822 assert_eq!(out, "");
823 assert_eq!(malformed, 1);
824 assert!(calls.is_empty());
825 }
826
827 #[test]
828 fn unknown_glm_tool_is_dropped() {
829 let text = "<tool_call>not_offered<tool_call>command</arg_key><arg_value>pwd</arg_value></tool_call>";
830 let Extracted {
831 text: out,
832 calls,
833 malformed,
834 } = extract(text, &[run_command_spec()]);
835 assert_eq!(out, "");
836 assert_eq!(malformed, 1);
837 assert!(calls.is_empty());
838 }
839
840 #[test]
841 fn malformed_glm_marker_is_accepted_only_for_the_first_argument() {
842 let text = "<tool_call>run_command<arg_key>command</arg_key><arg_value>pwd</arg_value><tool_call>cwd</arg_key><arg_value>/tmp</arg_value></tool_call>";
843 let Extracted {
844 text: out,
845 calls,
846 malformed,
847 } = extract(text, &[run_command_spec()]);
848 assert_eq!(out, "");
849 assert_eq!(malformed, 1);
850 assert!(calls.is_empty());
851 }
852
853 #[test]
854 fn a_malformed_block_is_dropped_rather_than_guessed_at() {
855 let text = "<tool_call>edit_file<arg_value>new_text</arg_key><arg_value>def x; end</arg_value></tool_call>";
856 let Extracted {
857 text: out,
858 calls,
859 malformed,
860 } = extract(text, &[run_command_spec()]);
861 assert_eq!(out, "");
862 assert_eq!(malformed, 1);
863 assert!(calls.is_empty());
864 }
865
866 #[test]
867 fn an_unterminated_tag_is_dropped() {
868 let text = "working on it <tool_call>run_command<arg_key>command</arg_key>";
869 let Extracted {
870 text: out,
871 calls,
872 malformed,
873 } = extract(text, &[run_command_spec()]);
874 assert_eq!(out, "working on it ");
875 assert_eq!(malformed, 1);
876 assert!(calls.is_empty());
877 }
878
879 #[test]
880 fn ordinary_text_passes_through_untouched() {
881 let Extracted {
882 text: out, calls, ..
883 } = extract("if x < y then a<b, nothing to recover", &[]);
884 assert_eq!(out, "if x < y then a<b, nothing to recover");
885 assert!(calls.is_empty());
886 }
887
888 #[test]
889 fn kimi_k3_run_command_block_is_recovered_and_removed() {
890 let tools = vec![run_command_spec()];
891 let Extracted { text, calls, .. } = extract(
892 "Working <|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>sleep 180; echo waited<|close|>argument<|sep|><|open|>argument key=\"cwd\" type=\"string\"<|sep|>/Users/setoelkahfi/Repositories/onde-ed<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
893 &tools,
894 );
895 assert_eq!(text, "Working ");
896 assert_eq!(calls.len(), 1);
897 assert_eq!(calls[0].name, "run_command");
898 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
899 assert_eq!(args["command"], "sleep 180; echo waited");
900 assert_eq!(args["cwd"], "/Users/setoelkahfi/Repositories/onde-ed");
901 }
902
903 #[test]
904 fn kimi_k3_non_string_arguments_are_json_decoded() {
905 let tools = vec![command_output_spec()];
906 let Extracted { calls, .. } = extract(
907 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
908 &tools,
909 );
910 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
911 assert_eq!(args["task_id"], 2);
912 assert!(args["task_id"].is_number());
913 }
914
915 #[test]
916 fn kimi_k3_missing_argument_type_uses_the_tool_schema() {
917 let tools = vec![command_output_spec()];
918 let Extracted { calls, .. } = extract(
919 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
920 &tools,
921 );
922 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
923 assert_eq!(args["task_id"], 2);
924 assert!(args["task_id"].is_number());
925 }
926
927 #[test]
928 fn kimi_k3_multiple_calls_are_recovered_in_order() {
929 let tools = vec![command_output_spec()];
930 let Extracted { calls, .. } = extract(
931 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>1<|close|>argument<|sep|><|close|>call<|sep|><|open|>call tool=\"command_output\" index=\"2\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
932 &tools,
933 );
934 let first: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
935 let second: serde_json::Value = serde_json::from_str(&calls[1].arguments).unwrap();
936 assert_eq!(calls.len(), 2);
937 assert_eq!(first["task_id"], 1);
938 assert_eq!(second["task_id"], 2);
939 }
940
941 #[test]
942 fn kimi_k3_response_is_unwrapped_and_think_is_hidden() {
943 let Extracted { text, calls, .. } = extract(
944 "a<|open|>think<|sep|>private<|close|>think<|sep|>b<|open|>response<|sep|>visible<|close|>response<|sep|>c",
945 &[],
946 );
947 assert_eq!(text, "abvisiblec");
948 assert!(calls.is_empty());
949 }
950
951 #[test]
952 fn kimi_k3_response_content_is_scanned_for_nested_tool_calls() {
953 let tools = vec![command_output_spec()];
954 let Extracted { text, calls, .. } = extract(
955 "<|open|>response<|sep|>before <|open|>think<|sep|>private<|close|>think<|sep|><|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after<|close|>response<|sep|>",
956 &tools,
957 );
958 assert_eq!(text, "before after");
959 assert_eq!(calls.len(), 1);
960 assert_eq!(calls[0].name, "command_output");
961 }
962
963 #[test]
964 fn kimi_k3_attribute_entities_are_decoded() {
965 let attrs = parse_k3_attributes(r#"tool="run&quot;command" key="a&amp;b""#).unwrap();
966 assert_eq!(attrs["tool"], "run\"command");
967 assert_eq!(attrs["key"], "a&b");
968 assert_eq!(decode_k3_attribute("line&#10;break"), "line&#10;break");
969 }
970
971 #[test]
972 fn unterminated_kimi_k3_tools_block_is_dropped() {
973 let text = "<|open|>tools<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|><|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi<|close|>argument<|sep|>";
974 let Extracted {
975 text: out,
976 calls,
977 malformed,
978 } = extract(text, &[run_command_spec()]);
979 assert_eq!(out, "");
980 assert_eq!(malformed, 1);
981 assert!(calls.is_empty());
982 }
983
984 #[test]
985 fn unknown_kimi_k3_tool_block_is_dropped() {
986 let text = "<|open|>tools<|sep|><|open|>call tool=\"delete_everything\" index=\"1\"<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
987 let Extracted {
988 text: out,
989 calls,
990 malformed,
991 } = extract(text, &[run_command_spec()]);
992 assert_eq!(out, "");
993 assert_eq!(malformed, 1);
994 assert!(calls.is_empty());
995 }
996
997 #[test]
998 fn invalid_kimi_k3_non_string_argument_is_dropped() {
999 let text = "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>not-json<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>";
1000 let Extracted {
1001 text: out,
1002 calls,
1003 malformed,
1004 } = extract(text, &[command_output_spec()]);
1005 assert_eq!(out, "");
1006 assert_eq!(malformed, 1);
1007 assert!(calls.is_empty());
1008 }
1009
1010 #[test]
1011 fn scanner_recovers_a_legacy_tag_split_across_chunk_boundaries() {
1012 let tools = vec![command_output_spec()];
1013 let mut scanner = StreamScanner::new(&tools);
1014 let mut text = String::new();
1015 let mut calls = Vec::new();
1016
1017 for chunk in [
1018 "polling ",
1019 "<tool_c",
1020 "all>command_output<arg_",
1021 "key>task_id</arg_key><arg_value>2</arg_v",
1022 "alue></tool_call>",
1023 " done",
1024 ] {
1025 for event in scanner.push(chunk) {
1026 match event {
1027 ScanEvent::Text(t) => text.push_str(&t),
1028 ScanEvent::ToolCall(c) => calls.push(c),
1029 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1030 }
1031 }
1032 }
1033 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1034 text.push_str(&rest);
1035 }
1036
1037 assert_eq!(text, "polling done", "no tag fragment may reach the UI");
1038 assert_eq!(calls.len(), 1);
1039 assert_eq!(calls[0].name, "command_output");
1040 }
1041
1042 #[test]
1043 fn scanner_recovers_glm_call_with_malformed_marker_split_across_chunks() {
1044 let tools = vec![run_command_spec()];
1045 let mut scanner = StreamScanner::new(&tools);
1046 let mut text = String::new();
1047 let mut calls = Vec::new();
1048
1049 for chunk in [
1050 "before ",
1051 "<tool_call>run_command<tool_",
1052 "call>command</arg_key><arg_value>pwd</arg_value></tool_call>",
1053 " after",
1054 ] {
1055 for event in scanner.push(chunk) {
1056 match event {
1057 ScanEvent::Text(value) => text.push_str(&value),
1058 ScanEvent::ToolCall(call) => calls.push(call),
1059 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1060 }
1061 }
1062 }
1063 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1064 text.push_str(&rest);
1065 }
1066
1067 assert_eq!(text, "before after");
1068 assert_eq!(calls.len(), 1);
1069 assert_eq!(calls[0].name, "run_command");
1070 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1071 assert_eq!(args["command"], "pwd");
1072 }
1073
1074 #[test]
1075 fn scanner_recovers_check_status_call_split_across_chunks() {
1076 let tools = vec![command_output_spec()];
1077 let mut scanner = StreamScanner::new(&tools);
1078 let mut text = String::new();
1079 let mut calls = Vec::new();
1080
1081 for chunk in [
1082 "before <tool_call>command_output CheckStatus=true_or_poll_",
1083 "again_with_different_params</arg_",
1084 "value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_",
1085 "call> after",
1086 ] {
1087 for event in scanner.push(chunk) {
1088 match event {
1089 ScanEvent::Text(value) => text.push_str(&value),
1090 ScanEvent::ToolCall(call) => calls.push(call),
1091 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1092 }
1093 }
1094 }
1095 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1096 text.push_str(&rest);
1097 }
1098
1099 assert_eq!(text, "before after");
1100 assert_eq!(calls.len(), 1);
1101 assert_eq!(calls[0].name, "command_output");
1102 }
1103
1104 #[test]
1105 fn scanner_recovers_kimi_k3_tools_split_across_chunk_boundaries() {
1106 let tools = vec![run_command_spec()];
1107 let mut scanner = StreamScanner::new(&tools);
1108 let mut text = String::new();
1109 let mut calls = Vec::new();
1110
1111 for chunk in [
1112 "pre ",
1113 "<|open|>too",
1114 "ls<|sep|><|open|>call tool=\"run_command\" index=\"1\"<|sep|>",
1115 "<|open|>argument key=\"command\" type=\"string\"<|sep|>echo hi",
1116 "<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1117 " post",
1118 ] {
1119 for event in scanner.push(chunk) {
1120 match event {
1121 ScanEvent::Text(t) => text.push_str(&t),
1122 ScanEvent::ToolCall(c) => calls.push(c),
1123 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1124 }
1125 }
1126 }
1127 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1128 text.push_str(&rest);
1129 }
1130
1131 assert_eq!(text, "pre post");
1132 assert_eq!(calls.len(), 1);
1133 assert_eq!(calls[0].name, "run_command");
1134 }
1135
1136 #[test]
1137 fn scanner_recovers_tools_after_an_unclosed_k3_wrapper() {
1138 let tools = vec![command_output_spec()];
1139 let mut scanner = StreamScanner::new(&tools);
1140 let mut text = String::new();
1141 let mut calls = Vec::new();
1142
1143 for chunk in [
1144 "before <|open|>response<|sep|>broken ",
1145 "<|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|> after",
1146 ] {
1147 for event in scanner.push(chunk) {
1148 match event {
1149 ScanEvent::Text(chunk) => text.push_str(&chunk),
1150 ScanEvent::ToolCall(call) => calls.push(call),
1151 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1152 }
1153 }
1154 }
1155 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1156 text.push_str(&rest);
1157 }
1158
1159 assert_eq!(text, "before <|open|>response<|sep|>broken after");
1160 assert_eq!(calls.len(), 1);
1161 assert_eq!(calls[0].name, "command_output");
1162 }
1163
1164 #[test]
1165 fn scanner_recovers_tools_after_an_unclosed_k3_think_wrapper() {
1166 let tools = vec![command_output_spec()];
1167 let Extracted { text, calls, .. } = extract(
1168 "<|open|>think<|sep|>broken <|open|>tools<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|><|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>",
1169 &tools,
1170 );
1171 assert_eq!(text, "<|open|>think<|sep|>broken ");
1172 assert_eq!(calls.len(), 1);
1173 assert_eq!(calls[0].name, "command_output");
1174 }
1175
1176 #[test]
1177 fn scanner_forwards_ordinary_text_without_holding_it_back() {
1178 let mut scanner = StreamScanner::new(&[]);
1179 let events = scanner.push("a normal streamed answer");
1180 assert_eq!(
1181 events,
1182 vec![ScanEvent::Text("a normal streamed answer".to_string())]
1183 );
1184 assert!(scanner.finish().is_none());
1185 }
1186
1187 #[test]
1188 fn scanner_releases_a_near_miss_prefix_once_it_cannot_match() {
1189 let mut scanner = StreamScanner::new(&[]);
1190 assert_eq!(
1191 scanner.push("see <tool"),
1192 vec![ScanEvent::Text("see ".to_string())]
1193 );
1194 assert_eq!(
1195 scanner.push("box for details"),
1196 vec![ScanEvent::Text("<toolbox for details".to_string())]
1197 );
1198 assert!(scanner.finish().is_none());
1199 }
1200
1201 #[test]
1202 fn scanner_reports_an_unclosed_tag_at_stream_end_as_malformed() {
1203 let tools = vec![run_command_spec()];
1204 let mut scanner = StreamScanner::new(&tools);
1205 let events = scanner.push("partial <tool_call>run_command<arg_key>cmd");
1206 assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1207 assert_eq!(
1208 scanner.finish(),
1209 Some(ScanEvent::Malformed(
1210 "<tool_call>run_command<arg_key>cmd".to_string()
1211 ))
1212 );
1213 }
1214
1215 #[test]
1216 fn scanner_hands_back_a_partial_marker_prefix_at_stream_end_as_text() {
1217 let mut scanner = StreamScanner::new(&[]);
1218 assert_eq!(
1219 scanner.push("see <tool_"),
1220 vec![ScanEvent::Text("see ".to_string())]
1221 );
1222 assert_eq!(
1223 scanner.finish(),
1224 Some(ScanEvent::Text("<tool_".to_string()))
1225 );
1226
1227 let mut scanner = StreamScanner::new(&[]);
1228 assert_eq!(
1229 scanner.push("see <|open|>to"),
1230 vec![ScanEvent::Text("see ".to_string())]
1231 );
1232 assert_eq!(
1233 scanner.finish(),
1234 Some(ScanEvent::Text("<|open|>to".to_string()))
1235 );
1236 }
1237
1238 #[test]
1239 fn a_tag_mentioned_in_prose_stays_text() {
1240 let tools = vec![run_command_spec()];
1241 for text in [
1242 "Wrap it in `<tool_call>` and `</tool_call>` tags.",
1243 "The scanner holds <tool_call> and </tool_call> blocks back.",
1244 "The model opens with `<tool_call>` and never closes it.",
1245 ] {
1246 assert_eq!(
1247 extract(text, &tools),
1248 Extracted {
1249 text: text.to_string(),
1250 ..Extracted::default()
1251 },
1252 "{text}"
1253 );
1254 }
1255 }
1256
1257 #[test]
1258 fn a_kimi_marker_mentioned_in_prose_stays_text() {
1259 let tools = vec![run_command_spec()];
1260 for text in [
1261 "The marker is `<|open|>tools<|sep|>` and it never closes here.",
1262 "Quote `<|open|>tools<|sep|>` then `<|close|>tools<|sep|>` in docs.",
1263 ] {
1264 assert_eq!(
1265 extract(text, &tools),
1266 Extracted {
1267 text: text.to_string(),
1268 ..Extracted::default()
1269 },
1270 "{text}"
1271 );
1272 }
1273 }
1274
1275 #[test]
1276 fn a_json_shaped_block_is_dropped() {
1277 let text = "<tool_call>\n{\"name\": \"run_command\", \"arguments\": {}}\n</tool_call>";
1278 let Extracted {
1279 text: out,
1280 calls,
1281 malformed,
1282 } = extract(text, &[run_command_spec()]);
1283 assert_eq!(out, "");
1284 assert_eq!(malformed, 1);
1285 assert!(calls.is_empty());
1286 }
1287
1288 /// Issue #105: a GLM-family reply that opened a call, then wrote an
1289 /// invented invoke/result transcript instead of closing it. None of that
1290 /// may reach the editor, and it must not count as a recovered call.
1291 #[test]
1292 fn a_hallucinated_invoke_transcript_is_dropped() {
1293 let tools = vec![command_output_spec()];
1294 let reply = "Checking.<tool_call>command_output\n\n<invokeID>reply_A</invokeID>\n\
1295 <parameter_name>task_id</parameter_name>\n<parameter>6</parameter>\n\
1296 </invoke>\n\n<function_results>PyPI and NuGet are queued.\n\
1297 <tool_call>command_output<arg_key>task_id</arg_key><arg_value>5</task_id>\n\
1298 </invoke>\n</function_results>";
1299 let Extracted {
1300 text,
1301 calls,
1302 malformed,
1303 } = extract(reply, &tools);
1304 assert_eq!(text, "Checking.");
1305 assert!(calls.is_empty());
1306 assert_eq!(malformed, 1);
1307 }
1308
1309 /// Issue #97: the well-formed blocks from that thread are recovered as
1310 /// real calls, multi-line argument values included.
1311 #[test]
1312 fn well_formed_blocks_from_issue_97_are_recovered() {
1313 let tools = vec![
1314 run_command_spec(),
1315 spec(
1316 "edit_file",
1317 serde_json::json!({
1318 "type": "object",
1319 "properties": {
1320 "path": { "type": "string" },
1321 "old_text": { "type": "string" },
1322 "new_text": { "type": "string" }
1323 }
1324 }),
1325 ),
1326 ];
1327 let reply = "Now wire `body_path` into the step:<tool_call>edit_file<arg_key>new_text</arg_key><arg_value> - name: Release\n with:\n body_path: release_notes.md</arg_value><arg_key>old_text</arg_key><arg_value> - name: Release\n with:</arg_value><arg_key>path</arg_key><arg_value>/repo/.github/workflows/release-github.yml</arg_value></tool_call>";
1328 let Extracted {
1329 text,
1330 calls,
1331 malformed,
1332 } = extract(reply, &tools);
1333 assert_eq!(text, "Now wire `body_path` into the step:");
1334 assert_eq!(malformed, 0);
1335 assert_eq!(calls.len(), 1);
1336 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1337 assert_eq!(
1338 args["new_text"],
1339 " - name: Release\n with:\n body_path: release_notes.md"
1340 );
1341 assert_eq!(args["path"], "/repo/.github/workflows/release-github.yml");
1342
1343 let Extracted { calls, .. } = extract(
1344 "<tool_call>run_command<arg_key>command</arg_key><arg_value>sleep 90</arg_value><arg_key>cwd</arg_key><arg_value>/repo</arg_value></tool_call>",
1345 &tools,
1346 );
1347 assert_eq!(calls.len(), 1);
1348 assert_eq!(calls[0].name, "run_command");
1349 }
1350
1351 #[test]
1352 fn dsml_multiple_invokes_are_recovered_in_order() {
1353 let tools = vec![command_output_spec()];
1354 let Extracted { text, calls, .. } = extract(
1355 "Working <|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">1<|DSML|/parameter><|DSML|/invoke><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">2<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1356 &tools,
1357 );
1358 assert_eq!(text, "Working ");
1359 assert_eq!(calls.len(), 2);
1360 let first: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1361 let second: serde_json::Value = serde_json::from_str(&calls[1].arguments).unwrap();
1362 assert_eq!(first["task_id"], 1);
1363 assert_eq!(second["task_id"], 2);
1364 }
1365
1366 #[test]
1367 fn dsml_string_true_is_kept_verbatim_and_string_false_is_json_typed() {
1368 let tools = vec![run_command_spec()];
1369 let Extracted { calls, .. } = extract(
1370 "<|DSML|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|parameter name=\"command\" string=\"true\">echo {\"a\":1}<|DSML|/parameter><|DSML|parameter name=\"run_in_background\" string=\"false\">true<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1371 &tools,
1372 );
1373 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1374 assert_eq!(args["command"], "echo {\"a\":1}");
1375 assert_eq!(args["run_in_background"], true);
1376 }
1377
1378 #[test]
1379 fn dsml_missing_string_attribute_uses_the_tool_schema() {
1380 let tools = vec![command_output_spec()];
1381 let Extracted { calls, .. } = extract(
1382 "<|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\">2<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1383 &tools,
1384 );
1385 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1386 assert_eq!(args["task_id"], 2);
1387 assert!(args["task_id"].is_number());
1388 }
1389
1390 #[test]
1391 fn unterminated_dsml_tool_calls_block_is_dropped() {
1392 let text = "<|DSML|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|parameter name=\"command\" string=\"true\">echo hi<|DSML|/parameter>";
1393 let Extracted {
1394 text: out,
1395 calls,
1396 malformed,
1397 } = extract(text, &[run_command_spec()]);
1398 assert_eq!(out, "");
1399 assert_eq!(malformed, 1);
1400 assert!(calls.is_empty());
1401 }
1402
1403 #[test]
1404 fn unknown_dsml_tool_is_dropped() {
1405 let text = "<|DSML|tool_calls><|DSML|invoke name=\"delete_everything\"><|DSML|/invoke><|DSML|/tool_calls>";
1406 let Extracted {
1407 text: out,
1408 calls,
1409 malformed,
1410 } = extract(text, &[run_command_spec()]);
1411 assert_eq!(out, "");
1412 assert_eq!(malformed, 1);
1413 assert!(calls.is_empty());
1414 }
1415
1416 #[test]
1417 fn invalid_dsml_json_typed_parameter_is_dropped() {
1418 let text = "<|DSML|tool_calls><|DSML|invoke name=\"command_output\"><|DSML|parameter name=\"task_id\" string=\"false\">not-json<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>";
1419 let Extracted {
1420 text: out,
1421 calls,
1422 malformed,
1423 } = extract(text, &[command_output_spec()]);
1424 assert_eq!(out, "");
1425 assert_eq!(malformed, 1);
1426 assert!(calls.is_empty());
1427 }
1428
1429 #[test]
1430 fn a_dsml_marker_mentioned_in_prose_stays_text() {
1431 let tools = vec![run_command_spec()];
1432 for text in [
1433 "The marker is `<|DSML|tool_calls>` and it never closes here.",
1434 "Quote `<|DSML|tool_calls>` then `<|DSML|/tool_calls>` in docs.",
1435 ] {
1436 assert_eq!(
1437 extract(text, &tools),
1438 Extracted {
1439 text: text.to_string(),
1440 ..Extracted::default()
1441 },
1442 "{text}"
1443 );
1444 }
1445 }
1446
1447 #[test]
1448 fn scanner_recovers_dsml_tool_calls_split_across_chunk_boundaries_including_inside_the_marker()
1449 {
1450 let tools = vec![run_command_spec()];
1451 let mut scanner = StreamScanner::new(&tools);
1452 let mut text = String::new();
1453 let mut calls = Vec::new();
1454
1455 for chunk in [
1456 "pre ",
1457 "<|DSML",
1458 "|tool_calls><|DSML|invoke name=\"run_command\"><|DSML|paramet",
1459 "er name=\"command\" string=\"true\">echo hi<|DSML|/parameter><|DSML|/invoke><|DSML|/tool_calls>",
1460 " post",
1461 ] {
1462 for event in scanner.push(chunk) {
1463 match event {
1464 ScanEvent::Text(t) => text.push_str(&t),
1465 ScanEvent::ToolCall(c) => calls.push(c),
1466 ScanEvent::Malformed(block) => panic!("unexpected malformed block: {block}"),
1467 }
1468 }
1469 }
1470 if let Some(ScanEvent::Text(rest)) = scanner.finish() {
1471 text.push_str(&rest);
1472 }
1473
1474 assert_eq!(text, "pre post");
1475 assert_eq!(calls.len(), 1);
1476 assert_eq!(calls[0].name, "run_command");
1477 let args: serde_json::Value = serde_json::from_str(&calls[0].arguments).unwrap();
1478 assert_eq!(args["command"], "echo hi");
1479 }
1480
1481 #[test]
1482 fn scanner_reports_an_unclosed_dsml_tag_at_stream_end_as_malformed() {
1483 let tools = vec![run_command_spec()];
1484 let mut scanner = StreamScanner::new(&tools);
1485 let events =
1486 scanner.push("partial <|DSML|tool_calls><|DSML|invoke name=\"run_command\">");
1487 assert_eq!(events, vec![ScanEvent::Text("partial ".to_string())]);
1488 assert_eq!(
1489 scanner.finish(),
1490 Some(ScanEvent::Malformed(
1491 "<|DSML|tool_calls><|DSML|invoke name=\"run_command\">".to_string()
1492 ))
1493 );
1494 }
1495 }