Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions parsers/v1/src/reasoning/base_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,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).
Comment thread
rmccorm4 marked this conversation as resolved.
Resolved
reasoning_parts.push(&text[cursor..]);
cursor = text.len();
}
Expand Down Expand Up @@ -364,7 +364,7 @@ impl ReasoningParser for BasicReasoningParser {
let ol_end = overlap(&current_text, &self.think_end_token);
let ol_tool = max_marker_overlap(&current_text, &self.tool_start_tokens);
let ol = ol_end.max(ol_tool);
if ol >= 2 {
if ol_end >= 2 || ol_tool > 0 {
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
let safe_end = current_text.len() - ol;
if safe_end > 0 {
accumulated_reasoning.push_str(&current_text[..safe_end]);
Expand Down
123 changes: 122 additions & 1 deletion parsers/v1/src/reasoning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +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_BLOCK_BEGIN: &str = "<|DSML|tool_calls>";
pub(crate) const DEEPSEEK_TOOL_INVOKE_BEGIN: &str = "<|DSML|invoke name=";

static REASONING_PARSER_MAP: OnceLock<HashMap<&'static str, ReasoningParserType>> = OnceLock::new();

Expand Down Expand Up @@ -235,7 +237,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("<think>".into(), "</think>".into(), false, true)
.with_tool_start_token(DEEPSEEK_TOOL_BLOCK_BEGIN)
.with_tool_start_token(DEEPSEEK_TOOL_INVOKE_BEGIN),
),
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
},
ReasoningParserType::NemotronDeci => ReasoningParserWrapper {
parser: Box::new(basic_parser),
Expand Down Expand Up @@ -371,6 +377,121 @@ 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!(
"<think>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_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</|DSML|parameter>\n</|DSML|invoke>"
);
let result =
parser.detect_and_parse_reasoning(&format!("<think>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("<think>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<not dsml");
assert_eq!(normal, "");
}

#[test] // MiniMax M2
fn test_minimax_m2_force_reasoning_and_tool_transition() {
let mut parser = ReasoningParserType::get_reasoning_parser_from_name("minimax_m2");
Expand Down
3 changes: 2 additions & 1 deletion parsers/v1/src/tool_calling/dsml/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
30 changes: 26 additions & 4 deletions parsers/v1/src/tool_calling/dsml/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub fn detect_tool_call_start_dsml(chunk: &str, config: &DsmlParserConfig) -> 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;

Expand All @@ -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<usize> {
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
Expand Down Expand Up @@ -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()"
);
}

Expand Down
5 changes: 3 additions & 2 deletions parsers/v1/src/tool_calling/parsers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -386,7 +387,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)
Comment thread
indrajit96 marked this conversation as resolved.
Resolved
}
ParserConfig::Glm47(glm47_config) => {
Some(find_tool_call_end_position_glm47(chunk, glm47_config))
Expand Down
Loading