From 2ad42384167a10a195e146fdabff8aa74533ecca Mon Sep 17 00:00:00 2001 From: Claudio Maradonna Date: Tue, 1 Sep 2026 00:06:23 +0200 Subject: [PATCH] fix(ds4): separate prefilled reasoning from content (#11802) DS4 appends the opening thinking marker to tokenizer-templated prompts, so generated text begins directly with reasoning bytes. Starting DsmlParser in TEXT therefore puts the reasoning and closing marker in visible content. Start the parser in THINK for structured chat requests with thinking enabled in both Predict and PredictStream. Keep the default TEXT state for raw prompts and reasoning-off requests, and add incremental regression coverage. Assisted-by: Codex:gpt-5 Signed-off-by: Claudio Maradonna --- backend/cpp/ds4/dsml_parser.cpp | 3 +- backend/cpp/ds4/dsml_parser.h | 6 +- backend/cpp/ds4/dsml_parser_test.cpp | 133 +++++++++++++++++++++++++++ backend/cpp/ds4/grpc-server.cpp | 16 +++- 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 backend/cpp/ds4/dsml_parser_test.cpp diff --git a/backend/cpp/ds4/dsml_parser.cpp b/backend/cpp/ds4/dsml_parser.cpp index 6fb88a9fc..e360c580c 100644 --- a/backend/cpp/ds4/dsml_parser.cpp +++ b/backend/cpp/ds4/dsml_parser.cpp @@ -92,7 +92,8 @@ std::string json_escape(const std::string &in) { } // namespace -DsmlParser::DsmlParser() = default; +DsmlParser::DsmlParser(bool starts_in_thinking) + : state_(starts_in_thinking ? State::THINK : State::TEXT) {} bool DsmlParser::IsInDsmlStructural() const { switch (state_) { diff --git a/backend/cpp/ds4/dsml_parser.h b/backend/cpp/ds4/dsml_parser.h index c09833673..14b977af6 100644 --- a/backend/cpp/ds4/dsml_parser.h +++ b/backend/cpp/ds4/dsml_parser.h @@ -17,7 +17,9 @@ struct ParserEvent { // Streaming parser. Stateless across instances; one per Predict call. class DsmlParser { public: - DsmlParser(); + // The chat prompt may already contain the opening thinking marker, so the + // generated text can begin directly with reasoning bytes. + explicit DsmlParser(bool starts_in_thinking = false); // Feed a chunk of raw model-emitted text. Appends classified events to // `out`. May buffer the tail of `chunk` internally if it looks like a @@ -43,7 +45,7 @@ public: private: enum class State { TEXT, THINK, TOOL_CALLS, INVOKE, PARAM_VALUE }; - State state_ = State::TEXT; + State state_; std::string buf_; std::string current_tool_name_; int tool_index_ = -1; diff --git a/backend/cpp/ds4/dsml_parser_test.cpp b/backend/cpp/ds4/dsml_parser_test.cpp new file mode 100644 index 000000000..325c36252 --- /dev/null +++ b/backend/cpp/ds4/dsml_parser_test.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// Standalone regression tests for the DSML streaming parser. +// +// The repository's backend/cpp/run-unit-tests.sh harness compiles each +// *_test.cpp as a single translation unit, so include the implementation here. + +#include "dsml_parser.cpp" + +#include +#include +#include +#include + +namespace { + +struct ParsedText { + std::string content; + std::string reasoning; +}; + +int failures = 0; + +void check_equal(const std::string &got, const std::string &want, + const char *name) { + if (got == want) return; + std::fprintf(stderr, "FAIL %s: got \"%s\", want \"%s\"\n", + name, got.c_str(), want.c_str()); + failures++; +} + +void collect_text(const std::vector &events, + ParsedText *parsed) { + for (const auto &event : events) { + if (event.type == ds4cpp::ParserEvent::CONTENT) { + parsed->content += event.text; + } else if (event.type == ds4cpp::ParserEvent::REASONING) { + parsed->reasoning += event.text; + } + } +} + +ParsedText parse_chunks(ds4cpp::DsmlParser *parser, + const std::vector &chunks) { + ParsedText parsed; + for (const auto &chunk : chunks) { + std::vector events; + parser->Feed(chunk, events); + collect_text(events, &parsed); + } + std::vector events; + parser->Flush(events); + collect_text(events, &parsed); + return parsed; +} + +template +void test_reasoning_opened_by_prompt() { + if constexpr (!std::is_constructible_v) { + std::fprintf(stderr, + "FAIL reasoning_opened_by_prompt: parser cannot start in thinking state\n"); + failures++; + } else { + Parser parser(true); + ParsedText parsed = parse_chunks( + &parser, + {"We need to calculate factorial recursively.Here is the answer."}); + check_equal(parsed.reasoning, + "We need to calculate factorial recursively.", + "reasoning_opened_by_prompt:reasoning"); + check_equal(parsed.content, "Here is the answer.", + "reasoning_opened_by_prompt:content"); + } +} + +template +Parser text_parser() { + if constexpr (std::is_constructible_v) { + return Parser(false); + } else { + return Parser(); + } +} + +void test_reasoning_disabled() { + auto parser = text_parser(); + ParsedText parsed = parse_chunks(&parser, {"Here is the answer."}); + check_equal(parsed.reasoning, "", "reasoning_disabled:reasoning"); + check_equal(parsed.content, "Here is the answer.", + "reasoning_disabled:content"); +} + +void test_explicit_think_tag() { + auto parser = text_parser(); + ParsedText parsed = parse_chunks( + &parser, {"reasoninganswer"}); + check_equal(parsed.reasoning, "reasoning", "explicit_think_tag:reasoning"); + check_equal(parsed.content, "answer", "explicit_think_tag:content"); +} + +template +void test_split_think_close_marker() { + if constexpr (!std::is_constructible_v) { + std::fprintf(stderr, + "FAIL split_think_close_marker: parser cannot start in thinking state\n"); + failures++; + } else { + Parser parser(true); + ParsedText parsed = parse_chunks( + &parser, + {"We need ", "to calculate ", "factorial", "", + "Here is ", "the answer."}); + check_equal(parsed.reasoning, "We need to calculate factorial", + "split_think_close_marker:reasoning"); + check_equal(parsed.content, "Here is the answer.", + "split_think_close_marker:content"); + } +} + +} // namespace + +int main() { + test_reasoning_opened_by_prompt(); + test_reasoning_disabled(); + test_explicit_think_tag(); + test_split_think_close_marker(); + + if (failures == 0) { + std::fprintf(stderr, "all dsml_parser checks passed\n"); + return 0; + } + std::fprintf(stderr, "%d check(s) failed\n", failures); + return 1; +} diff --git a/backend/cpp/ds4/grpc-server.cpp b/backend/cpp/ds4/grpc-server.cpp index 924da5476..2118fd1cb 100644 --- a/backend/cpp/ds4/grpc-server.cpp +++ b/backend/cpp/ds4/grpc-server.cpp @@ -771,7 +771,12 @@ public: build_prompt(g_engine, request, &prompt); int n_predict = request->tokens() > 0 ? request->tokens() : 256; - CollectCtx collect = {g_engine, "", {}, reply, 0, {}, "", ""}; + const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request)); + const bool starts_in_thinking = think_enabled && + request->usetokenizertemplate() && request->messages_size() > 0; + CollectCtx collect = { + g_engine, "", ds4cpp::DsmlParser(starts_in_thinking), + reply, 0, {}, "", ""}; std::string cache_key = render_prompt_text(request); size_t cache_hit = maybe_load_cache(cache_key); (void)cache_hit; // future: skip prompt prefix if hit covers full prompt @@ -789,7 +794,6 @@ public: if (rc == 0) { const int eos = ds4_token_eos(g_engine); const int draft_max = ds4_engine_mtp_draft_tokens(g_engine); - const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request)); int produced = 0; while (produced < n_predict) { SampleParams sp = compute_sample_params(request, collect.parser, think_enabled); @@ -871,7 +875,12 @@ public: build_prompt(g_engine, request, &prompt); int n_predict = request->tokens() > 0 ? request->tokens() : 256; - StreamCtx s = {g_engine, writer, {}, 0, false, {}}; + const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request)); + const bool starts_in_thinking = think_enabled && + request->usetokenizertemplate() && request->messages_size() > 0; + StreamCtx s = { + g_engine, writer, ds4cpp::DsmlParser(starts_in_thinking), + 0, false, {}}; std::string cache_key = render_prompt_text(request); size_t cache_hit = maybe_load_cache(cache_key); (void)cache_hit; @@ -884,7 +893,6 @@ public: if (rc == 0) { const int eos = ds4_token_eos(g_engine); const int draft_max = ds4_engine_mtp_draft_tokens(g_engine); - const bool think_enabled = ds4_think_mode_enabled(parse_think_mode(request)); int produced = 0; while (produced < n_predict && !s.aborted) { SampleParams sp = compute_sample_params(request, s.parser, think_enabled);