Skip to content

[#21014][fix] DeepSeek DSML tool parsers: match closing tags structurally - #21018

Draft
lucifer1004 wants to merge 1 commit into
NVIDIA:mainfrom
lucifer1004:upstream/dsml-literal-close-tag
Draft

lucifer1004 wants to merge 1 commit into
NVIDIA:mainfrom
lucifer1004:upstream/dsml-literal-close-tag

Conversation

@lucifer1004

@lucifer1004 lucifer1004 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Fixes #21014.

The DeepSeek V3.2, V4 and V4.1 tool parsers end a parameter, an invoke or the whole calls block at the first matching DSML closing tag. A tool argument that contains such a tag as text (an agent writing notes about the tool-call format, or editing code that handles it) is cut there, and the rest of the call is dropped or leaks as content. Streaming and non-streaming parsing also fail in different ways.

A closing tag now ends its element only when the text after it, ignoring whitespace, is a valid continuation:

Closing tag Valid continuation
parameter the next parameter header, or the end of the invoke body
invoke the next invoke, the closing calls tag, <|end▁of▁sentence|>, a new calls block, or the end of the output
calls <|end▁of▁sentence|>, a new calls block, or the end of the output

If no parameter closing tag is confirmed, the first one is used. If no invoke closing tag is confirmed at the end of the output, the last one is used. In the normal case without literal tags, the result is the same as before.

The fix is in DeepSeekV32Parser. Every tag is now derived from class attributes (_CALLS_TAG, _INVOKE_TAG, _PARAMETER_TAG), so DeepSeekV4Parser and DeepSeekV41Parser set only their tag names and inherit everything else.

Other behavior changes that come with the structural matching:

  • Streaming emits a call once the text after its invoke closing tag confirms it, instead of as soon as the tag arrives. So if the closing tag is the last generated text and no closing calls tag follows, finish() now emits the call. Outside a calls block, finish() still returns the held-back text as content.
  • Non-streaming parses an invoke even when the closing calls tag is missing. Before, it returned no calls in that case, while streaming emitted them as soon as the invoke closed.
  • parse_partial (used on unfinished output in the Responses API) reports only calls whose closing tag is confirmed.
  • <|end▁of▁sentence|> is removed only from normal text and from after the calls block. Before, the non-streaming path stripped it everywhere, including from parameter values, while streaming kept it.
  • Internal attributes: parameter_regex is removed, and _INVOKE_HEADER_PREFIX / _INVOKE_BEGIN_TEMPLATE become the instance attributes _invoke_header_prefix / _invoke_begin_template. Nothing else in the repository uses them.

String parameter values stay verbatim (#21025), and the layout newlines around tool calls stay out of the content (#20873). This PR is rebased onto both and does not change either behavior.

Limitations

  • Some values cannot be told apart from the end of the call: a value that contains a closing tag followed by a valid continuation, for example a full </|DSML|parameter>\n</|DSML|invoke>\n</|DSML|calls> sequence followed by the end of the output. These values are still cut at that point, as before. In streaming, the text after the cut can surface as content.
  • In streaming, an invoke whose closing tag ends the buffer is held until the next delta shows what follows it, or until finish().

Relation to open PRs

#20990 (scan only new text while a tool call streams) changes parse_streaming_increment in the same file. It skips the search when no new invoke closing tag has arrived. With this PR, a closing tag can be found but not yet confirmed, and the delta that confirms it (for example, the next invoke header) contains no new closing tag. The shortcut must not move its search start past an unconfirmed closing tag. Whichever of the two merges second needs to reconcile this.

Test Coverage

New tests in tests/unittest/llmapi/apps/test_tool_parsers.py (already in l0_cpu.yml), parametrized over V3.2, V4 and V4.1:

  • TestDeepSeekDsmlLiteralTags, 13 cases × 3 formats. The cases include:

    • literal parameter, invoke and calls closing tags in a value, in the first or a later parameter;
    • parser source code as a value;
    • literal opening tags;
    • <|end▁of▁sentence|> in a value and after the block;
    • JSON body and no parameters;
    • a missing closing calls tag;
    • a compact form without whitespace.

    Each case checks:

    • detect_and_parse;
    • streaming per character, whole, and in 30 random chunkings, with finish(), which must give the same normal text and calls as the non-streaming result and leak no DSML or EOS text;
    • apply_tool_parser, streaming with finished=True on the last delta and non-streaming.
  • TestDeepSeekDsmlStreamingFinish:

    • calls are emitted before finish() once confirmed;
    • finish() flushes an invoke without a closing calls tag and is idempotent;
    • stray text after the last invoke;
    • an empty stream, and a plain-text stream ending in a partial tag;
    • parse_partial waits for confirmation.

Existing tests, including those added by #20873 and #21025, are unchanged. TestDeepSeekV32Parser::test_parse_streaming_increment_multiple_tools_streaming ends an invoke with a new calls block, so a new calls block is accepted as a valid continuation.

The full test_tool_parsers.py was run CPU-only on a TensorRT-LLM 1.4.0rc1 container, with this branch's tensorrt_llm/serve/tool_parser/ and tests mounted:

  • this branch: 1094 passed, 4 failed;
  • main's tool parsers and tests (7587cc8): 953 passed, the same 4 failed.

The 4 failures are TestQwen3CoderRequiredTools / TestQwen3CoderParameterRecovery cases with thinking enabled. They depend on main's reasoning parser, which is newer than the container's package.

pre-commit on the changed files: clean.

PR Checklist

  • PR description clearly explains what and why.
  • PR follows the TRT-LLM coding guidelines to the best of my knowledge.
  • Test cases are provided for new code paths.
  • No API changes beyond the parsers' internal attributes listed above.
  • No new dependencies, no CODEOWNERS changes.
  • Please check this after reviewing the above items as appropriate for this PR.

…ructurally

A DSML parameter, invoke or calls closing tag only ends its element when the
text after it is a valid continuation, so parameter values may contain the
literal tags. The V3.2 parser derives every tag from class attributes so the
V4 and V4.1 parsers inherit the fix. finish() also emits an invoke whose
closing tag ends the stream, parse_partial reports only confirmed calls, and
the non-streaming path keeps the end-of-sentence token inside parameter
values.

Signed-off-by: Zihua Wu <13583761+lucifer1004@users.noreply.github.com>
@lucifer1004
lucifer1004 force-pushed the upstream/dsml-literal-close-tag branch from 9a77243 to 7d29ebe Compare October 27, 2026 03:36

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: DeepSeek V4.1 / V3.2 tool parsers cut a parameter value at a literal DSML closing tag and drop or leak the rest of the call

1 participant