Keep prose that mentions <tool_call> visible

A <tool_call> block that failed to parse was always dropped, as was an unclosed one at the end of a reply. That also swallowed prose that only mentions the tag, so a reply explaining the scanner could lose everything after the first backticked `<tool_call>`. Such a block now counts as a failed call only when the text after the tag looks like one: a tool name, a JSON object, another tag, or nothing. Anything else stays text, as it did before. Also log the malformed count on the non-streamed path, warn when the retry fails too, and test a partial Kimi marker at stream end.

Seto Elkahfi committed Sep 25, 2026 at 18:10 UTC cdc4961f3f8a9847a20509546746173e7a688a1d
2 files changed +83 -3
src/backend.rs
+10 -1
@@ -560,7 +560,13 @@ impl OpenAiBackend {
560 // running on into the previous sentence.
561 let _ = sink.send("\n\n".to_string());
562 }
563 - let (retry, _) = self.request(tools, allow_tool_calls, sink).await?;
563 + let (retry, retry_malformed) = self.request(tools, allow_tool_calls, sink).await?;
564 + if retry_malformed > 0 && retry.tool_calls.is_empty() {
565 + log::warn!(
566 + "the retry also wrote {retry_malformed} unparseable inline tool call(s); \
567 + ending the turn without them"
568 + );
569 + }
570 Ok(TurnResult {
571 text: join_reply_text(&result.text, &retry.text),
572 tool_calls: retry.tool_calls,
@@ -649,6 +655,9 @@ impl OpenAiBackend {
655
656 let extracted = crate::inline_tool_calls::extract(&text, tools);
657 let malformed = extracted.malformed;
658 + if malformed > 0 {
659 + log::warn!("dropped {malformed} unparseable inline tool call(s) from the reply");
660 + }
661
662 if !allow_tool_calls {
663 let recovered = extracted.calls;
src/inline_tool_calls.rs
+73 -2
@@ -27,6 +27,12 @@
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 `<tool_call>` tag the model merely mentions in prose, say while explaining
32 +//! this very scanner, is not a call. So a legacy block that fails to parse is
33 +//! only treated as malformed when what follows the tag looks like the start of
34 +//! a call (see [`looks_like_xml_call`]); otherwise it stays text, as it always
35 +//! did.
36
37 use std::collections::HashMap;
38
@@ -370,7 +376,11 @@ impl<'a> StreamScanner<'a> {
376 }
377 let rest = std::mem::take(&mut self.pending);
378 Some(
373 - if rest.starts_with(XML_OPEN_TAG) || rest.starts_with(K3_TOOLS_OPEN) {
379 + if rest.starts_with(K3_TOOLS_OPEN)
380 + || rest
381 + .strip_prefix(XML_OPEN_TAG)
382 + .is_some_and(looks_like_xml_call)
383 + {
384 ScanEvent::Malformed(rest)
385 } else {
386 ScanEvent::Text(rest)
@@ -441,7 +451,8 @@ impl<'a> StreamScanner<'a> {
451 let inner = &block[XML_OPEN_TAG.len()..block.len() - XML_CLOSE_TAG.len()];
452 Some(match parse_xml_block(inner, self.tools) {
453 Some(call) => ScanEvent::ToolCall(call),
444 - None => ScanEvent::Malformed(block),
454 + None if looks_like_xml_call(inner) => ScanEvent::Malformed(block),
455 + None => ScanEvent::Text(block),
456 })
457 }
458
@@ -495,6 +506,24 @@ impl<'a> StreamScanner<'a> {
506
507 /// Scan text from a wrapper whose closing marker has already arrived. This
508 /// preserves event order when a response body itself contains an inline block.
509 +/// Whether the text after a `<tool_call>` tag reads as the start of a call:
510 +/// a tool name straight after the tag or on the next line, a JSON object or
511 +/// another tag after any whitespace, or nothing at all (a reply cut off right
512 +/// after the tag). Prose that mentions the tag, like "`<tool_call>` wraps..."
513 +/// or "<tool_call> and </tool_call> tags", fails every one of those.
514 +fn looks_like_xml_call(after_tag: &str) -> bool {
515 + let after_newlines = after_tag.trim_start_matches(['\n', '\r']);
516 + if after_newlines
517 + .chars()
518 + .next()
519 + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
520 + {
521 + return true;
522 + }
523 + let rest = after_newlines.trim_start();
524 + rest.is_empty() || rest.starts_with(['{', '<'])
525 +}
526 +
527 fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
528 let mut scanner = StreamScanner::new(tools);
529 let mut events = scanner.push(text);
@@ -1067,6 +1096,48 @@ mod tests {
1096 scanner.finish(),
1097 Some(ScanEvent::Text("<tool_".to_string()))
1098 );
1099 +
1100 + let mut scanner = StreamScanner::new(&[]);
1101 + assert_eq!(
1102 + scanner.push("see <|open|>to"),
1103 + vec![ScanEvent::Text("see ".to_string())]
1104 + );
1105 + assert_eq!(
1106 + scanner.finish(),
1107 + Some(ScanEvent::Text("<|open|>to".to_string()))
1108 + );
1109 + }
1110 +
1111 + #[test]
1112 + fn a_tag_mentioned_in_prose_stays_text() {
1113 + let tools = vec![run_command_spec()];
1114 + for text in [
1115 + "Wrap it in `<tool_call>` and `</tool_call>` tags.",
1116 + "The scanner holds <tool_call> and </tool_call> blocks back.",
1117 + "The model opens with `<tool_call>` and never closes it.",
1118 + ] {
1119 + assert_eq!(
1120 + extract(text, &tools),
1121 + Extracted {
1122 + text: text.to_string(),
1123 + ..Extracted::default()
1124 + },
1125 + "{text}"
1126 + );
1127 + }
1128 + }
1129 +
1130 + #[test]
1131 + fn a_json_shaped_block_is_dropped() {
1132 + let text = "<tool_call>\n{\"name\": \"run_command\", \"arguments\": {}}\n</tool_call>";
1133 + let Extracted {
1134 + text: out,
1135 + calls,
1136 + malformed,
1137 + } = extract(text, &[run_command_spec()]);
1138 + assert_eq!(out, "");
1139 + assert_eq!(malformed, 1);
1140 + assert!(calls.is_empty());
1141 }
1142
1143 /// Issue #105: a GLM-family reply that opened a call, then wrote an