diff --git a/parsers/v1/src/reasoning/mod.rs b/parsers/v1/src/reasoning/mod.rs index 6ef14bb75..888d9ef03 100644 --- a/parsers/v1/src/reasoning/mod.rs +++ b/parsers/v1/src/reasoning/mod.rs @@ -23,6 +23,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_BLOCK_BEGIN: &str = "<|DSML|tool_calls>"; +pub(crate) const DEEPSEEK_TOOL_INVOKE_BEGIN: &str = "<|DSML|invoke name="; static REASONING_PARSER_MAP: OnceLock> = OnceLock::new(); @@ -251,7 +253,11 @@ 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_BLOCK_BEGIN) + .with_tool_start_token(DEEPSEEK_TOOL_INVOKE_BEGIN), + ), }, ReasoningParserType::NemotronDeci => ReasoningParserWrapper { parser: Box::new(basic_parser), @@ -423,6 +429,141 @@ 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( + &format!( + "need a tool{DEEPSEEK_TOOL_BLOCK_BEGIN}\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_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"); + 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, ""); + } + + #[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_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"); + 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 +/// 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; @@ -59,6 +59,23 @@ pub fn find_tool_call_end_position_dsml(chunk: &str, config: &DsmlParserConfig) } } +/// 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_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()) { + return Some(pos + end_token.len()); + } + None +} + /// Parse DSML formatted tool calls from a message. /// /// Returns `(parsed_tool_calls, normal_text_content)`. `normal_text` is the @@ -1222,13 +1239,18 @@ 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_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(), - "Partial chunk without close fence must report end=len so caller buffers more" + "Public compatibility wrapper must keep returning chunk.len()" ); } diff --git a/parsers/v1/src/tool_calling/parsers.rs b/parsers/v1/src/tool_calling/parsers.rs index 3689da80f..8be3d786a 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, @@ -423,7 +424,7 @@ pub fn find_tool_call_end_position(chunk: &str, parser_str: Option<&str>) -> Opt Some(find_tool_call_end_position_xml(chunk, xml_config)) } ParserConfig::Dsml(dsml_config) => { - Some(find_tool_call_end_position_dsml(chunk, 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)) 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",