Keep prose that mentions the Kimi tools marker visible
An unclosed or unparseable <|open|>tools<|sep|> block was always treated as a failed call, so a reply quoting the marker lost the rest of its text and set off a retry. Like the <tool_call> case, it now counts as a call only when an <|open|>call marker (or nothing) follows.
Seto Elkahfi committed
Sep 25, 2026 at 20:08 UTC
7e6724550760fb7bec2038853b9cb0af1587aaab
1 file changed
+36
-7
src/inline_tool_calls.rs
+36
-7
@@ -28,11 +28,11 @@
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.
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
@@ -376,7 +376,9 @@ impl<'a> StreamScanner<'a> {
376
}
377
let rest = std::mem::take(&mut self.pending);
378
Some(
379
- if rest.starts_with(K3_TOOLS_OPEN)
379
+ if rest
380
+ .strip_prefix(K3_TOOLS_OPEN)
381
+ .is_some_and(looks_like_k3_call)
382
|| rest
383
.strip_prefix(XML_OPEN_TAG)
384
.is_some_and(looks_like_xml_call)
@@ -467,7 +469,8 @@ impl<'a> StreamScanner<'a> {
469
470
Some(match parse_k3_tools_block(inner, self.tools) {
471
Some(calls) => calls.into_iter().map(ScanEvent::ToolCall).collect(),
470
- None => vec![ScanEvent::Malformed(block)],
472
+ None if looks_like_k3_call(inner) => vec![ScanEvent::Malformed(block)],
473
+ None => vec![ScanEvent::Text(block)],
474
})
475
}
476
@@ -524,6 +527,14 @@ fn looks_like_xml_call(after_tag: &str) -> bool {
527
rest.is_empty() || rest.starts_with(['{', '<'])
528
}
529
530
+/// Whether the text after a Kimi `<|open|>tools<|sep|>` marker reads as the
531
+/// start of a call: another `<|open|>call` marker after any whitespace, or
532
+/// nothing at all. A quoted marker, like the constant in this file, fails.
533
+fn looks_like_k3_call(after_marker: &str) -> bool {
534
+ let rest = after_marker.trim_start();
535
+ rest.is_empty() || rest.starts_with(K3_CALL_OPEN)
536
+}
537
+
538
fn scan_complete_text(text: &str, tools: &[ToolSpec]) -> Vec<ScanEvent> {
539
let mut scanner = StreamScanner::new(tools);
540
let mut events = scanner.push(text);
@@ -1127,6 +1138,24 @@ mod tests {
1138
}
1139
}
1140
1141
+ #[test]
1142
+ fn a_kimi_marker_mentioned_in_prose_stays_text() {
1143
+ let tools = vec![run_command_spec()];
1144
+ for text in [
1145
+ "The marker is `<|open|>tools<|sep|>` and it never closes here.",
1146
+ "Quote `<|open|>tools<|sep|>` then `<|close|>tools<|sep|>` in docs.",
1147
+ ] {
1148
+ assert_eq!(
1149
+ extract(text, &tools),
1150
+ Extracted {
1151
+ text: text.to_string(),
1152
+ ..Extracted::default()
1153
+ },
1154
+ "{text}"
1155
+ );
1156
+ }
1157
+ }
1158
+
1159
#[test]
1160
fn a_json_shaped_block_is_dropped() {
1161
let text = "<tool_call>\n{\"name\": \"run_command\", \"arguments\": {}}\n</tool_call>";