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