Repository navigation
Recover mixed DeepSeek DSML tool calls #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e03cef2
dd04f46
c5d4f3d
c07a515
0e1f94c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ use crate::parse::call_object::{AliasPolicy, read_calls}; | |
| use crate::parse::json_values::{ | ||
| extract_first_json_value_with_end, extract_json_values, find_json_end, | ||
| }; | ||
| use crate::repair::args::from_call_object; | ||
| use crate::repair::json::{recover_object, strip_code_fence}; | ||
| use crate::types::{CallSource, ParseOptions, ParsedToolCall}; | ||
|
|
||
|
|
@@ -94,6 +95,19 @@ static NAMED_INVOKE_OPEN_RE: LazyLock<Option<Regex>> = LazyLock::new(|| { | |
| .ok() | ||
| }); | ||
|
|
||
| /// The malformed name-parameter spelling emitted after JSON arguments by | ||
| /// `DeepSeek` when its DSML stream leaks into a text `<tool_call>` block. | ||
| static DSML_NAME_PARAMETER_RE: LazyLock<Option<Regex>> = LazyLock::new(|| { | ||
| Regex::new( | ||
| r#"(?is)^\s*(?:</[||]{1,2}\s*DSML\s*[||]{1,2}\s*parameter\s*>\s*)*<[||]{1,2}\s*DSML\s*[||]{1,2}\s*parameter\s+name\s*=\s*"name"\s*:\s*"([^"]+)"[^>]*(?:>|})"#, | ||
| ) | ||
| .ok() | ||
| }); | ||
|
|
||
| /// `DeepSeek`'s group terminator, used to leave following assistant prose intact. | ||
| static DSML_CALLS_CLOSE_RE: LazyLock<Option<Regex>> = | ||
| LazyLock::new(|| Regex::new(r"(?is)</[||]{1,2}\s*DSML\s*[||]{1,2}\s*calls\s*>").ok()); | ||
|
|
||
| /// First match of `re` in `haystack`, as `(start, end)`. | ||
| fn find_re(re: &LazyLock<Option<Regex>>, haystack: &str) -> Option<(usize, usize)> { | ||
| re.as_ref() | ||
|
|
@@ -340,6 +354,14 @@ impl Tagged { | |
| }) | ||
| .or_else(|| extract_first_json_value_with_end(after)); | ||
| if let Some((value, consumed)) = recovered { | ||
| let (dsml_calls, dsml_end) = recover_dsml_calls(after); | ||
| if !dsml_calls.is_empty() { | ||
| return Probe::Found(Block { | ||
| start: opener.start, | ||
| end: body_start + dsml_end, | ||
| decoded: Decoded::Calls(dsml_calls), | ||
| }); | ||
| } | ||
| let calls = read_calls( | ||
| &value, | ||
| AliasPolicy::Marked, | ||
|
|
@@ -382,6 +404,55 @@ impl Tagged { | |
| } | ||
| } | ||
|
|
||
| /// Recovers JSON argument objects followed by `DeepSeek`'s name parameter. | ||
| /// JSON boundaries are found before scanning markup, so tag-like text inside | ||
| /// string arguments cannot be mistaken for DSML framing. | ||
| fn recover_dsml_calls(after: &str) -> (Vec<ParsedToolCall>, usize) { | ||
| let Some(re) = DSML_NAME_PARAMETER_RE.as_ref() else { | ||
| return (Vec::new(), 0); | ||
| }; | ||
| let mut calls = Vec::new(); | ||
| let mut cursor = 0; | ||
| while cursor < after.len() { | ||
| let Some((arguments, json_end)) = extract_first_json_value_with_end(&after[cursor..]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a recovered call is followed by Useful? React with 👍 / 👎. |
||
| else { | ||
| break; | ||
| }; | ||
| let json_end = cursor + json_end; | ||
| let metadata = &after[json_end..]; | ||
| let inline_name = arguments.get("name").and_then(serde_json::Value::as_str); | ||
| let name_match = re.captures(metadata); | ||
| let name = inline_name.or_else(|| { | ||
| name_match | ||
| .as_ref() | ||
| .and_then(|m| m.get(1).map(|v| v.as_str())) | ||
| }); | ||
| let Some(name) = name.map(str::trim) else { | ||
| break; | ||
| }; | ||
| if name.is_empty() { | ||
| break; | ||
| } | ||
| let name = name.to_owned(); | ||
| let args = from_call_object(&arguments); | ||
| calls.push(ParsedToolCall::new(name, args, CallSource::TaggedJson)); | ||
| // An inline name identifies this JSON value, but does not frame a | ||
| // later value in trailing text. Continue only when DSML explicitly | ||
| // connects the values with a name parameter. | ||
| let Some(name_match) = name_match else { | ||
| cursor = json_end; | ||
| break; | ||
| }; | ||
| cursor = json_end + name_match.get(0).map_or(0, |m| m.end()); | ||
| } | ||
| let end = if calls.is_empty() { | ||
| 0 | ||
| } else { | ||
| find_re(&DSML_CALLS_CLOSE_RE, &after[cursor..]).map_or(cursor, |(_, end)| cursor + end) | ||
| }; | ||
| (calls, end) | ||
| } | ||
|
|
||
| /// Whether a tag-family marker is a closer (`</tool_call>`, `<|/tool_call|>`). | ||
| /// Skips the same whitespace class `TAG_RE`'s `\s` does (not just space and | ||
| /// tab), so a marker like `<\n/tool_call>` — which the regex matches as one | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.