From 6c720caa913b729ff9f075d487b04801c185b090 Mon Sep 17 00:00:00 2001 From: slivanovich Date: Sun, 26 Jul 2026 20:30:31 +0300 Subject: [PATCH 1/4] fix: tools are leaking in the reasoning content (deepseek) Signed-off-by: slivanovich --- parsers/v1/src/reasoning/base_parser.rs | 15 ++++- parsers/v1/src/reasoning/mod.rs | 68 +++++++++++++++++++++- parsers/v1/src/tool_calling/dsml/parser.rs | 25 ++++---- parsers/v1/src/tool_calling/parsers.rs | 4 +- 4 files changed, 94 insertions(+), 18 deletions(-) diff --git a/parsers/v1/src/reasoning/base_parser.rs b/parsers/v1/src/reasoning/base_parser.rs index 32cb6385a..8fc0e2e39 100644 --- a/parsers/v1/src/reasoning/base_parser.rs +++ b/parsers/v1/src/reasoning/base_parser.rs @@ -229,8 +229,17 @@ impl ReasoningParser for BasicReasoningParser { exited_on_tool_start = true; } (None, None) => { - // No end token — rest is reasoning (truncated) - reasoning_parts.push(&text[cursor..]); + // Emit only reasoning content that cannot still become a + // tool-call start marker once more bytes arrive. + let reasoning_tail = &text[cursor..]; + let tool_overlap = + max_marker_overlap(reasoning_tail, &self.tool_start_tokens); + if tool_overlap > 0 { + let safe_end = reasoning_tail.len() - tool_overlap; + reasoning_parts.push(&reasoning_tail[..safe_end]); + } else { + reasoning_parts.push(reasoning_tail); + } cursor = text.len(); } } @@ -364,7 +373,7 @@ impl ReasoningParser for BasicReasoningParser { let ol_end = overlap(¤t_text, &self.think_end_token); let ol_tool = max_marker_overlap(¤t_text, &self.tool_start_tokens); let ol = ol_end.max(ol_tool); - if ol >= 2 { + if ol_end >= 2 || ol_tool > 0 { let safe_end = current_text.len() - ol; if safe_end > 0 { accumulated_reasoning.push_str(¤t_text[..safe_end]); diff --git a/parsers/v1/src/reasoning/mod.rs b/parsers/v1/src/reasoning/mod.rs index b4f17fc58..c857274db 100644 --- a/parsers/v1/src/reasoning/mod.rs +++ b/parsers/v1/src/reasoning/mod.rs @@ -20,6 +20,7 @@ pub use minimax_append_think_parser::MiniMaxAppendThinkParser; /// registration and its test fixtures so both stay in sync. Mirrors /// `KimiK2ParserConfig::default().section_start` in `crate::tool_calling::config`. pub(crate) const KIMI_K2_TOOL_SECTION_BEGIN: &str = "<|tool_calls_section_begin|>"; +pub(crate) const DEEPSEEK_TOOL_SECTION_BEGIN: &str = "<|DSML|"; static REASONING_PARSER_MAP: OnceLock> = OnceLock::new(); @@ -235,7 +236,10 @@ impl ReasoningParserType { // distinct variant so V4-specific divergence has somewhere to land. // See `ReasoningParserType::DeepSeekV4` docstring for rationale. ReasoningParserType::DeepSeekV4 => ReasoningParserWrapper { - parser: Box::new(basic_parser), + parser: Box::new( + BasicReasoningParser::new("".into(), "".into(), false, true) + .with_tool_start_token(DEEPSEEK_TOOL_SECTION_BEGIN), + ), }, ReasoningParserType::NemotronDeci => ReasoningParserWrapper { parser: Box::new(basic_parser), @@ -371,6 +375,68 @@ mod tests { } } + #[test] + fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_dsml_tool_start() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + let result = parser.detect_and_parse_reasoning( + "need a tool<|DSML|tool_calls>\n<|DSML|invoke name=\"bash\">", + &[], + ); + + assert_eq!(result.reasoning_text, "need a tool"); + assert_eq!( + result.normal_text, + "<|DSML|tool_calls>\n<|DSML|invoke name=\"bash\">" + ); + } + + #[test] + fn test_deepseek_v4_detect_and_parse_drops_dangling_dsml_tool_prefix() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + let result = parser.detect_and_parse_reasoning("need a tool<", &[]); + + assert_eq!(result.reasoning_text, "need a tool"); + assert_eq!(result.normal_text, ""); + } + + #[test] + fn test_deepseek_v4_streaming_exits_reasoning_on_split_dsml_tool_start() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + parser.set_in_reasoning(true); + + let chunks = ["need a tool", "<", "|DSML|tool_calls>\nX"]; + let mut reasoning = String::new(); + let mut normal = String::new(); + + for chunk in chunks { + let result = parser.parse_reasoning_streaming_incremental(chunk, &[]); + reasoning.push_str(&result.reasoning_text); + normal.push_str(&result.normal_text); + } + + assert_eq!(reasoning, "need a tool"); + assert_eq!(normal, "<|DSML|tool_calls>\nX"); + } + + #[test] + fn test_deepseek_v4_streaming_does_not_drop_non_dsml_lone_angle() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + parser.set_in_reasoning(true); + + let chunks = ["literal", "<", "not dsml"]; + let mut reasoning = String::new(); + let mut normal = String::new(); + + for chunk in chunks { + let result = parser.parse_reasoning_streaming_incremental(chunk, &[]); + reasoning.push_str(&result.reasoning_text); + normal.push_str(&result.normal_text); + } + + assert_eq!(reasoning, "literal bo false } -/// Find the end position of a DSML tool call block -pub fn find_tool_call_end_position_dsml(chunk: &str, config: &DsmlParserConfig) -> usize { +/// Find the end position of a DSML tool call block. +/// +/// Returns `None` when a DSML block has started but the matching block end has +/// not arrived yet. This tells the streaming jail to keep accumulating instead +/// of parsing a partial block on every delta. +pub fn find_tool_call_end_position_dsml(chunk: &str, config: &DsmlParserConfig) -> Option { let end_token = &config.block_end; if let Some(pos) = chunk.find(end_token.as_str()) { - pos + end_token.len() - } else { - chunk.len() + return Some(pos + end_token.len()); } + None } /// Parse DSML formatted tool calls from a message. @@ -490,7 +493,7 @@ mod tests { fn test_find_tool_call_end_position() { let config = get_test_config(); let text = "<|DSML|function_calls><|DSML|invoke name=\"test\">more"; - let pos = find_tool_call_end_position_dsml(text, &config); + let pos = find_tool_call_end_position_dsml(text, &config).unwrap(); assert_eq!(&text[pos..], "more"); } @@ -499,7 +502,7 @@ mod tests { fn test_find_tool_call_end_position_v4() { let config = get_v4_test_config(); let text = "<|DSML|tool_calls><|DSML|invoke name=\"test\">more"; - let pos = find_tool_call_end_position_dsml(text, &config); + let pos = find_tool_call_end_position_dsml(text, &config).unwrap(); assert_eq!(&text[pos..], "more"); } @@ -1222,13 +1225,13 @@ mod tests { assert!(detect_tool_call_start_dsml("<|DSML|tool_call", &config)); // And on an empty buffer that ends with the very first char of the fence. assert!(detect_tool_call_start_dsml("<", &config)); - // End-position lookup must return chunk.len() when the end fence - // hasn't arrived yet — caller is expected to keep buffering. + // End-position lookup must return None when the end fence hasn't + // arrived yet — caller is expected to keep buffering. let partial = "<|DSML|tool_calls>\n<|DSML|invoke name=\"a\">\n"; assert_eq!( find_tool_call_end_position_dsml(partial, &config), - partial.len(), - "Partial chunk without close fence must report end=len so caller buffers more" + None, + "Partial chunk without close fence must report None so caller buffers more" ); } diff --git a/parsers/v1/src/tool_calling/parsers.rs b/parsers/v1/src/tool_calling/parsers.rs index 8a7c702a5..36dbc90da 100644 --- a/parsers/v1/src/tool_calling/parsers.rs +++ b/parsers/v1/src/tool_calling/parsers.rs @@ -385,9 +385,7 @@ pub fn find_tool_call_end_position(chunk: &str, parser_str: Option<&str>) -> Opt ParserConfig::Xml(xml_config) => { Some(find_tool_call_end_position_xml(chunk, xml_config)) } - ParserConfig::Dsml(dsml_config) => { - Some(find_tool_call_end_position_dsml(chunk, dsml_config)) - } + ParserConfig::Dsml(dsml_config) => find_tool_call_end_position_dsml(chunk, dsml_config), ParserConfig::Glm47(glm47_config) => { Some(find_tool_call_end_position_glm47(chunk, glm47_config)) } From 2d88d4fb59aae3c56a74f4a307a787e5293c4e4d Mon Sep 17 00:00:00 2001 From: slivanovich Date: Mon, 27 Jul 2026 13:19:03 +0300 Subject: [PATCH 2/4] fix: tool start tokens, public api, e2e reasoning parsing Signed-off-by: slivanovich --- parsers/v1/src/reasoning/base_parser.rs | 13 +---- parsers/v1/src/reasoning/mod.rs | 65 ++++++++++++++++++++-- parsers/v1/src/tool_calling/dsml/mod.rs | 3 +- parsers/v1/src/tool_calling/dsml/parser.rs | 27 +++++++-- parsers/v1/src/tool_calling/parsers.rs | 7 ++- 5 files changed, 92 insertions(+), 23 deletions(-) diff --git a/parsers/v1/src/reasoning/base_parser.rs b/parsers/v1/src/reasoning/base_parser.rs index 8fc0e2e39..53f1c2433 100644 --- a/parsers/v1/src/reasoning/base_parser.rs +++ b/parsers/v1/src/reasoning/base_parser.rs @@ -229,17 +229,8 @@ impl ReasoningParser for BasicReasoningParser { exited_on_tool_start = true; } (None, None) => { - // Emit only reasoning content that cannot still become a - // tool-call start marker once more bytes arrive. - let reasoning_tail = &text[cursor..]; - let tool_overlap = - max_marker_overlap(reasoning_tail, &self.tool_start_tokens); - if tool_overlap > 0 { - let safe_end = reasoning_tail.len() - tool_overlap; - reasoning_parts.push(&reasoning_tail[..safe_end]); - } else { - reasoning_parts.push(reasoning_tail); - } + // No end token — rest is reasoning (truncated). + reasoning_parts.push(&text[cursor..]); cursor = text.len(); } } diff --git a/parsers/v1/src/reasoning/mod.rs b/parsers/v1/src/reasoning/mod.rs index c857274db..50c1e154a 100644 --- a/parsers/v1/src/reasoning/mod.rs +++ b/parsers/v1/src/reasoning/mod.rs @@ -20,7 +20,8 @@ pub use minimax_append_think_parser::MiniMaxAppendThinkParser; /// registration and its test fixtures so both stay in sync. Mirrors /// `KimiK2ParserConfig::default().section_start` in `crate::tool_calling::config`. pub(crate) const KIMI_K2_TOOL_SECTION_BEGIN: &str = "<|tool_calls_section_begin|>"; -pub(crate) const DEEPSEEK_TOOL_SECTION_BEGIN: &str = "<|DSML|"; +pub(crate) const DEEPSEEK_TOOL_BLOCK_BEGIN: &str = "<|DSML|tool_calls>"; +pub(crate) const DEEPSEEK_TOOL_INVOKE_BEGIN: &str = "<|DSML|invoke name="; static REASONING_PARSER_MAP: OnceLock> = OnceLock::new(); @@ -238,7 +239,8 @@ impl ReasoningParserType { ReasoningParserType::DeepSeekV4 => ReasoningParserWrapper { parser: Box::new( BasicReasoningParser::new("".into(), "".into(), false, true) - .with_tool_start_token(DEEPSEEK_TOOL_SECTION_BEGIN), + .with_tool_start_token(DEEPSEEK_TOOL_BLOCK_BEGIN) + .with_tool_start_token(DEEPSEEK_TOOL_INVOKE_BEGIN), ), }, ReasoningParserType::NemotronDeci => ReasoningParserWrapper { @@ -379,7 +381,9 @@ mod tests { fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_dsml_tool_start() { let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); let result = parser.detect_and_parse_reasoning( - "need a tool<|DSML|tool_calls>\n<|DSML|invoke name=\"bash\">", + &format!( + "need a tool{DEEPSEEK_TOOL_BLOCK_BEGIN}\n<|DSML|invoke name=\"bash\">" + ), &[], ); @@ -391,11 +395,24 @@ mod tests { } #[test] - fn test_deepseek_v4_detect_and_parse_drops_dangling_dsml_tool_prefix() { + fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_bare_dsml_invoke() { let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); - let result = parser.detect_and_parse_reasoning("need a tool<", &[]); + let tool_call = format!( + "{DEEPSEEK_TOOL_INVOKE_BEGIN}\"bash\">\n<|DSML|parameter name=\"cmd\" string=\"true\">pwd\n" + ); + let result = + parser.detect_and_parse_reasoning(&format!("need a tool{tool_call}"), &[]); assert_eq!(result.reasoning_text, "need a tool"); + assert_eq!(result.normal_text, tool_call); + } + + #[test] + fn test_deepseek_v4_detect_and_parse_keeps_partial_dsml_tool_prefix_at_eof() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + let result = parser.detect_and_parse_reasoning("need a tool<|DSML|tool_", &[]); + + assert_eq!(result.reasoning_text, "need a tool<|DSML|tool_"); assert_eq!(result.normal_text, ""); } @@ -418,6 +435,44 @@ mod tests { assert_eq!(normal, "<|DSML|tool_calls>\nX"); } + #[test] + fn test_deepseek_v4_streaming_exits_reasoning_on_split_dsml_invoke() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + parser.set_in_reasoning(true); + + let chunks = ["need a tool", "<|DSML|inv", "oke name=\"bash\">\nX"]; + let mut reasoning = String::new(); + let mut normal = String::new(); + + for chunk in chunks { + let result = parser.parse_reasoning_streaming_incremental(chunk, &[]); + reasoning.push_str(&result.reasoning_text); + normal.push_str(&result.normal_text); + } + + assert_eq!(reasoning, "need a tool"); + assert_eq!(normal, "<|DSML|invoke name=\"bash\">\nX"); + } + + #[test] + fn test_deepseek_v4_streaming_keeps_non_tool_dsml_prefix_in_reasoning() { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + parser.set_in_reasoning(true); + + let chunks = ["literal", "<|DSML|", "not_a_tool"]; + let mut reasoning = String::new(); + let mut normal = String::new(); + + for chunk in chunks { + let result = parser.parse_reasoning_streaming_incremental(chunk, &[]); + reasoning.push_str(&result.reasoning_text); + normal.push_str(&result.normal_text); + } + + assert_eq!(reasoning, "literal<|DSML|not_a_tool"); + assert_eq!(normal, ""); + } + #[test] fn test_deepseek_v4_streaming_does_not_drop_non_dsml_lone_angle() { let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); diff --git a/parsers/v1/src/tool_calling/dsml/mod.rs b/parsers/v1/src/tool_calling/dsml/mod.rs index 56a023753..021f00d2c 100644 --- a/parsers/v1/src/tool_calling/dsml/mod.rs +++ b/parsers/v1/src/tool_calling/dsml/mod.rs @@ -5,5 +5,6 @@ mod parser; pub use super::response; pub use parser::{ - detect_tool_call_start_dsml, find_tool_call_end_position_dsml, try_tool_call_parse_dsml, + detect_tool_call_start_dsml, find_complete_tool_call_end_position_dsml, + find_tool_call_end_position_dsml, try_tool_call_parse_dsml, }; diff --git a/parsers/v1/src/tool_calling/dsml/parser.rs b/parsers/v1/src/tool_calling/dsml/parser.rs index d35e624a9..a3be56494 100644 --- a/parsers/v1/src/tool_calling/dsml/parser.rs +++ b/parsers/v1/src/tool_calling/dsml/parser.rs @@ -49,11 +49,25 @@ pub fn detect_tool_call_start_dsml(chunk: &str, config: &DsmlParserConfig) -> bo } /// Find the end position of a DSML tool call block. +pub fn find_tool_call_end_position_dsml(chunk: &str, config: &DsmlParserConfig) -> usize { + let end_token = &config.block_end; + + if let Some(pos) = chunk.find(end_token.as_str()) { + pos + end_token.len() + } else { + chunk.len() + } +} + +/// Find the end position of a complete DSML tool call block. /// /// Returns `None` when a DSML block has started but the matching block end has /// not arrived yet. This tells the streaming jail to keep accumulating instead /// of parsing a partial block on every delta. -pub fn find_tool_call_end_position_dsml(chunk: &str, config: &DsmlParserConfig) -> Option { +pub fn find_complete_tool_call_end_position_dsml( + chunk: &str, + config: &DsmlParserConfig, +) -> Option { let end_token = &config.block_end; if let Some(pos) = chunk.find(end_token.as_str()) { @@ -493,7 +507,7 @@ mod tests { fn test_find_tool_call_end_position() { let config = get_test_config(); let text = "<|DSML|function_calls><|DSML|invoke name=\"test\">more"; - let pos = find_tool_call_end_position_dsml(text, &config).unwrap(); + let pos = find_tool_call_end_position_dsml(text, &config); assert_eq!(&text[pos..], "more"); } @@ -502,7 +516,7 @@ mod tests { fn test_find_tool_call_end_position_v4() { let config = get_v4_test_config(); let text = "<|DSML|tool_calls><|DSML|invoke name=\"test\">more"; - let pos = find_tool_call_end_position_dsml(text, &config).unwrap(); + let pos = find_tool_call_end_position_dsml(text, &config); assert_eq!(&text[pos..], "more"); } @@ -1229,10 +1243,15 @@ mod tests { // arrived yet — caller is expected to keep buffering. let partial = "<|DSML|tool_calls>\n<|DSML|invoke name=\"a\">\n"; assert_eq!( - find_tool_call_end_position_dsml(partial, &config), + find_complete_tool_call_end_position_dsml(partial, &config), None, "Partial chunk without close fence must report None so caller buffers more" ); + assert_eq!( + find_tool_call_end_position_dsml(partial, &config), + partial.len(), + "Public compatibility wrapper must keep returning chunk.len()" + ); } /// `TOOLCALLING.batch.8` — paired reasoning + tool in same response. DSv4 emits diff --git a/parsers/v1/src/tool_calling/parsers.rs b/parsers/v1/src/tool_calling/parsers.rs index 36dbc90da..e1ed236c9 100644 --- a/parsers/v1/src/tool_calling/parsers.rs +++ b/parsers/v1/src/tool_calling/parsers.rs @@ -4,7 +4,8 @@ use super::ToolDefinition; use super::config::{ParserConfig, ToolCallConfig}; use super::dsml::{ - detect_tool_call_start_dsml, find_tool_call_end_position_dsml, try_tool_call_parse_dsml, + detect_tool_call_start_dsml, find_complete_tool_call_end_position_dsml, + try_tool_call_parse_dsml, }; use super::gemma4::{ detect_tool_call_start_gemma4, find_tool_call_end_position_gemma4, try_tool_call_parse_gemma4, @@ -385,7 +386,9 @@ pub fn find_tool_call_end_position(chunk: &str, parser_str: Option<&str>) -> Opt ParserConfig::Xml(xml_config) => { Some(find_tool_call_end_position_xml(chunk, xml_config)) } - ParserConfig::Dsml(dsml_config) => find_tool_call_end_position_dsml(chunk, dsml_config), + ParserConfig::Dsml(dsml_config) => { + find_complete_tool_call_end_position_dsml(chunk, dsml_config) + } ParserConfig::Glm47(glm47_config) => { Some(find_tool_call_end_position_glm47(chunk, glm47_config)) } From f53cd81ecf09220025cc360c9be8bca4987dfded Mon Sep 17 00:00:00 2001 From: Ryan McCormick Date: Tue, 28 Jul 2026 23:55:02 -0700 Subject: [PATCH 3/4] Apply suggestion from @rmccorm4 Signed-off-by: Ryan McCormick --- parsers/v1/src/reasoning/base_parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsers/v1/src/reasoning/base_parser.rs b/parsers/v1/src/reasoning/base_parser.rs index 92cd80845..f3ca01e56 100644 --- a/parsers/v1/src/reasoning/base_parser.rs +++ b/parsers/v1/src/reasoning/base_parser.rs @@ -260,7 +260,7 @@ impl ReasoningParser for BasicReasoningParser { exited_on_tool_start = true; } (None, None) => { - // No end token — rest is reasoning (truncated). + // No end token — rest is reasoning (truncated) reasoning_parts.push(&text[cursor..]); cursor = text.len(); } From d3f8d6456c58c4f9a251dd58643891f50daf7de8 Mon Sep 17 00:00:00 2001 From: slivanovich Date: Thu, 30 Jul 2026 01:59:37 +0300 Subject: [PATCH 4/4] add: some tests (also for v3.2) Signed-off-by: slivanovich --- parsers/v1/src/reasoning/mod.rs | 20 ++++++++++++++++++++ parsers/v1/tests/jail.rs | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/parsers/v1/src/reasoning/mod.rs b/parsers/v1/src/reasoning/mod.rs index 1a4d9affe..888d9ef03 100644 --- a/parsers/v1/src/reasoning/mod.rs +++ b/parsers/v1/src/reasoning/mod.rs @@ -446,6 +446,26 @@ mod tests { ); } + #[test] + fn test_deepseek_v4_detect_and_parse_exits_reasoning_without_visible_opener_on_dsml_tool_start() + { + let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); + parser.set_in_reasoning(true); + + let result = parser.detect_and_parse_reasoning( + &format!( + "What is the weather now?{DEEPSEEK_TOOL_BLOCK_BEGIN}\n<|DSML|invoke name=\"get_weather\">" + ), + &[], + ); + + assert_eq!(result.reasoning_text, "What is the weather now?"); + assert_eq!( + result.normal_text, + "<|DSML|tool_calls>\n<|DSML|invoke name=\"get_weather\">" + ); + } + #[test] fn test_deepseek_v4_detect_and_parse_exits_reasoning_on_bare_dsml_invoke() { let mut parser = ReasoningParserType::get_reasoning_parser_from_name("deepseek_v4"); diff --git a/parsers/v1/tests/jail.rs b/parsers/v1/tests/jail.rs index 073ac161d..eb3b90d50 100644 --- a/parsers/v1/tests/jail.rs +++ b/parsers/v1/tests/jail.rs @@ -4223,6 +4223,25 @@ fahrenheit ], &[""][..], ), + ( + "deepseek_v3_2", + "DeepSeek V3.2", + vec![ + "I will check that. <|DSML|inv", + "oke name=\"get_weather\">\n<|DSML|parameter name=\"location\" string=\"true\">NYC\n\n", + ], + &["<|DSML|invoke", ""][..], + ), + ( + "deepseek_v3_2", + "DeepSeek V3.2 delayed outer close", + vec![ + "I will check that. <|DSML|invoke name=\"get_weather\">\n<|DSML|parameter name=\"location\" string=\"true\">NYC\n", + "", + "\n", + ], + &["<|DSML|invoke", ""][..], + ), ( "deepseek_v4", "DeepSeek V4",