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>";