From 454e48bdb7972ca51ce114e4103dd46015a94dd4 Mon Sep 17 00:00:00 2001 From: Dobrica Pavlinusic Date: Tue, 18 Aug 2026 21:13:54 +0200 Subject: [PATCH 1/4] feat(server): parse XML tool emissions Add parser pattern 4d for ... emissions from DeepSeek models. Handles XML ... and inline JSON bodies, captures tool calls occurring in both content and reasoning streams, coerces parameters according to JSON Schema property definitions, and includes unit tests. --- server/src/server/sse_emitter.cpp | 58 +++++++++++++++++++++++++++++-- server/src/server/tool_parser.cpp | 56 +++++++++++++++++++++++++++++ server/test/test_server_unit.cpp | 54 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index c3d53bdd3..5e8315424 100644 --- a/server/src/server/sse_emitter.cpp +++ b/server/src/server/sse_emitter.cpp @@ -306,7 +306,11 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { } size_t idx = window_.find(THINK_CLOSE); - if (idx != std::string::npos) { + size_t tool_idx = std::string::npos; + bool tool_hit = has_request_tools(tools_) && + find_tool_syntax_start(window_, tools_, tool_idx); + + if (idx != std::string::npos && (tool_idx == std::string::npos || idx < tool_idx)) { std::string pre = window_.substr(0, idx); if (!pre.empty()) { reasoning_text_ += pre; @@ -341,6 +345,38 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { mode_ = StreamMode::CONTENT; continue; } + if (tool_hit) { + std::string pre = window_.substr(0, tool_idx); + if (!pre.empty()) { + reasoning_text_ += pre; + switch (format_) { + case ApiFormat::OPENAI_CHAT: + out.push_back(format_openai_delta({{"reasoning_content", pre}})); + break; + case ApiFormat::ANTHROPIC: { + if (active_kind_ != "thinking") { + out.push_back(sse_event("content_block_stop", + json({{"type", "content_block_stop"}, {"index", block_index_}}).dump())); + block_index_++; + active_kind_ = "thinking"; + json new_block = {{"type", "thinking"}, {"thinking", ""}}; + out.push_back(sse_event("content_block_start", + json({{"type", "content_block_start"}, {"index", block_index_}, + {"content_block", new_block}}).dump())); + } + out.push_back(sse_event("content_block_delta", + json({{"type", "content_block_delta"}, {"index", block_index_}, + {"delta", {{"type", "thinking_delta"}, {"thinking", pre}}}}).dump())); + break; + } + default: break; + } + } + tool_buffer_ = window_.substr(tool_idx); + window_.clear(); + mode_ = StreamMode::TOOL_BUFFER; + continue; + } // No close tag yet — emit safe prefix if window is large enough if (window_.size() > std::max(BASE_HOLDBACK, stop_holdback_)) { size_t cut = utf8_safe_len(window_, window_.size() - std::max(BASE_HOLDBACK, stop_holdback_)); @@ -576,8 +612,24 @@ std::vector SseEmitter::emit_finish(int completion_tokens, // Emit any cleaned text from the tool buffer if (!parsed.cleaned_text.empty()) { - accumulated_content_ += parsed.cleaned_text; - emit_content_delta(out, parsed.cleaned_text); + size_t think_close = parsed.cleaned_text.find(THINK_CLOSE); + if (think_close != std::string::npos) { + std::string reasoning = parsed.cleaned_text.substr(0, think_close); + std::string content = parsed.cleaned_text.substr(think_close + THINK_CLOSE_LEN); + if (!reasoning.empty()) { + reasoning_text_ += reasoning; + if (format_ == ApiFormat::OPENAI_CHAT) { + out.push_back(format_openai_delta({{"reasoning_content", reasoning}})); + } + } + if (!content.empty()) { + accumulated_content_ += content; + emit_content_delta(out, content); + } + } else { + accumulated_content_ += parsed.cleaned_text; + emit_content_delta(out, parsed.cleaned_text); + } } fr = "tool_calls"; diff --git a/server/src/server/tool_parser.cpp b/server/src/server/tool_parser.cpp index 01e2a31c4..b1542764a 100644 --- a/server/src/server/tool_parser.cpp +++ b/server/src/server/tool_parser.cpp @@ -55,6 +55,7 @@ static std::string generate_call_id() { static const char TOOL_OPEN[] = ""; static const char FUNCTION_CALL_OPEN[] = ""; +static const char FUNCTION_CALLS_OPEN[] = ""; static const char FUNCTION_OPEN[] = "...V... + { + static const std::regex re_block(R"(([\s\S]*?))"); + static const std::regex re_invoke(R"(([\s\S]*?))"); + static const std::regex re_param(R"(<(param|parameter)\s+name\s*=\s*["']?([A-Za-z_][\w.\-]*)["']?\s*>([\s\S]*?))"); + + auto fbegin = std::sregex_iterator(text.begin(), text.end(), re_block); + auto fend = std::sregex_iterator(); + for (auto fit = fbegin; fit != fend; ++fit) { + size_t bstart = fit->position(); + size_t bend = bstart + fit->length(); + if (overlaps(removals, bstart)) continue; + + std::string block_content = (*fit)[1].str(); + auto begin = std::sregex_iterator(block_content.begin(), block_content.end(), re_invoke); + auto end = std::sregex_iterator(); + std::vector> block_calls; + + for (auto it = begin; it != end; ++it) { + std::string fn_name = (*it)[1].str(); + if (!tool_allowed(tools, fn_name)) continue; + std::string body = trim_ws((*it)[2].str()); + json args = json::object(); + if (!body.empty() && body.front() == '{') { + json raw_args = json::parse(body, nullptr, false); + if (raw_args.is_discarded() || !raw_args.is_object()) continue; + json props = find_tool_properties(tools, fn_name); + for (auto & [k, v] : raw_args.items()) { + if (v.is_string()) { + args[k] = convert_param_value(v.get(), k, props); + } else { + args[k] = v; + } + } + } else { + auto pbegin = std::sregex_iterator(body.begin(), body.end(), re_param); + auto pend = std::sregex_iterator(); + for (auto pit = pbegin; pit != pend; ++pit) { + std::string k = (*pit)[2].str(); + std::string v = trim_ws((*pit)[3].str()); + args[k] = convert_param_value(v, k, find_tool_properties(tools, fn_name)); + } + } + block_calls.push_back({fn_name, std::move(args)}); + } + + if (!block_calls.empty()) { + for (auto & bc : block_calls) { + add_call(bc.first, bc.second, bstart, bend); + } + } + } + } // Pattern 5: call:?{relaxed-JSON args} diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 8373859dc..c1904561c 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -5704,3 +5704,57 @@ TEST_CASE(ServerUnitFixture, test_qwen35_embedded_mtp_target_layer_count) { "laguna", 65, 1, target_layers, error)); TEST_ASSERT(target_layers == 65); } + +TEST_CASE(ServerUnitFixture, test_parse_function_calls_invoke_xml) { + const std::string text = + "Reading configuration:\n" + "\n" + "\n" + " server.go\n" + " 10\n" + " 50\n" + "\n" + ""; + + auto result = parse_tool_calls(text, read_tools()); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "read"); + auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["path"] == "server.go"); + TEST_ASSERT(args["offset"] == 10); + TEST_ASSERT(args["limit"] == 50); + } + TEST_ASSERT(result.cleaned_text == "Reading configuration:"); +} + +TEST_CASE(ServerUnitFixture, test_parse_function_calls_invoke_json) { + const std::string text = + "\n" + "\n" + " {\"path\": \"app.py\", \"offset\": \"5\"}\n" + "\n" + ""; + + auto result = parse_tool_calls(text, read_tools()); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + TEST_ASSERT(result.tool_calls[0].name == "read"); + auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["path"] == "app.py"); + TEST_ASSERT(args["offset"] == 5); + } + TEST_ASSERT(result.cleaned_text.empty()); +} + +TEST_CASE(ServerUnitFixture, test_emitter_function_calls_inside_reasoning) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_tools(), false); + auto c1 = em.emit_token("Analyzing build files.\n"); + auto c2 = em.emit_token("\n \n CMakeLists.txt\n \n\n"); + auto fin = em.emit_finish(2); + + std::string all = concat(c1) + concat(c2) + concat(fin); + TEST_ASSERT(em.tool_calls().size() == 1); + TEST_ASSERT(em.reasoning_text().find("Analyzing build files.") != std::string::npos); + TEST_ASSERT(all.find("\"finish_reason\":\"tool_calls\"") != std::string::npos); +} From e576db6ad1829cba7894dc000d2e15dd7552a39e Mon Sep 17 00:00:00 2001 From: Dobrica Pavlinusic Date: Tue, 18 Aug 2026 21:36:40 +0200 Subject: [PATCH 2/4] fix(server): address review feedback on function_calls streaming and parsing Address review feedback: - Restrict reasoning mode tool interception to - Record content boundary accounting when is in buffered tool text - Emit Anthropic thinking_delta for reasoning buffered around tool calls - Support Anthropic input_schema in find_tool_properties - Preserve literal "null" strings when property schema specifies string type - Strictly validate XML parameter body completeness in Pattern 4d - Add corresponding unit tests --- server/src/server/sse_emitter.cpp | 42 +++++++++++++++++++++++++++- server/src/server/sse_emitter.h | 1 + server/src/server/tool_parser.cpp | 17 ++++++++++-- server/test/test_server_unit.cpp | 46 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index 5e8315424..580c6df04 100644 --- a/server/src/server/sse_emitter.cpp +++ b/server/src/server/sse_emitter.cpp @@ -13,6 +13,7 @@ namespace dflash::common { static const char THINK_OPEN[] = ""; static const char THINK_CLOSE[] = ""; +static const char FUNCTION_CALLS_OPEN[] = ""; static constexpr size_t THINK_OPEN_LEN = 7; static constexpr size_t THINK_CLOSE_LEN = 8; @@ -308,7 +309,7 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { size_t idx = window_.find(THINK_CLOSE); size_t tool_idx = std::string::npos; bool tool_hit = has_request_tools(tools_) && - find_tool_syntax_start(window_, tools_, tool_idx); + (tool_idx = window_.find(FUNCTION_CALLS_OPEN)) != std::string::npos; if (idx != std::string::npos && (tool_idx == std::string::npos || idx < tool_idx)) { std::string pre = window_.substr(0, idx); @@ -373,6 +374,7 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { } } tool_buffer_ = window_.substr(tool_idx); + tool_from_reasoning_ = true; window_.clear(); mode_ = StreamMode::TOOL_BUFFER; continue; @@ -446,6 +448,7 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { // Tool-call syntax. Keep the full tag/function text buffered // until finish so the parser can validate it. tool_buffer_ = window_.substr(h.pos); + tool_from_reasoning_ = false; window_.clear(); mode_ = StreamMode::TOOL_BUFFER; } @@ -455,6 +458,7 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { if (accumulated_content_.find_first_not_of(" \t\n\r") == std::string::npos && starts_with_potential_bare_json_tool(window_, tools_)) { tool_buffer_ = window_; + tool_from_reasoning_ = false; tool_buffer_fallback_to_content_ = true; window_.clear(); mode_ = StreamMode::TOOL_BUFFER; @@ -614,18 +618,54 @@ std::vector SseEmitter::emit_finish(int completion_tokens, if (!parsed.cleaned_text.empty()) { size_t think_close = parsed.cleaned_text.find(THINK_CLOSE); if (think_close != std::string::npos) { + if (first_content_token_index_ == -1) { + first_content_token_index_ = emit_token_count_; + } std::string reasoning = parsed.cleaned_text.substr(0, think_close); std::string content = parsed.cleaned_text.substr(think_close + THINK_CLOSE_LEN); if (!reasoning.empty()) { reasoning_text_ += reasoning; if (format_ == ApiFormat::OPENAI_CHAT) { out.push_back(format_openai_delta({{"reasoning_content", reasoning}})); + } else if (format_ == ApiFormat::ANTHROPIC) { + if (active_kind_ != "thinking") { + out.push_back(sse_event("content_block_stop", + json({{"type", "content_block_stop"}, {"index", block_index_}}).dump())); + block_index_++; + active_kind_ = "thinking"; + json new_block = {{"type", "thinking"}, {"thinking", ""}}; + out.push_back(sse_event("content_block_start", + json({{"type", "content_block_start"}, {"index", block_index_}, + {"content_block", new_block}}).dump())); + } + out.push_back(sse_event("content_block_delta", + json({{"type", "content_block_delta"}, {"index", block_index_}, + {"delta", {{"type", "thinking_delta"}, {"thinking", reasoning}}}}).dump())); } } if (!content.empty()) { accumulated_content_ += content; emit_content_delta(out, content); } + } else if (tool_from_reasoning_) { + reasoning_text_ += parsed.cleaned_text; + if (format_ == ApiFormat::OPENAI_CHAT) { + out.push_back(format_openai_delta({{"reasoning_content", parsed.cleaned_text}})); + } else if (format_ == ApiFormat::ANTHROPIC) { + if (active_kind_ != "thinking") { + out.push_back(sse_event("content_block_stop", + json({{"type", "content_block_stop"}, {"index", block_index_}}).dump())); + block_index_++; + active_kind_ = "thinking"; + json new_block = {{"type", "thinking"}, {"thinking", ""}}; + out.push_back(sse_event("content_block_start", + json({{"type", "content_block_start"}, {"index", block_index_}, + {"content_block", new_block}}).dump())); + } + out.push_back(sse_event("content_block_delta", + json({{"type", "content_block_delta"}, {"index", block_index_}, + {"delta", {{"type", "thinking_delta"}, {"thinking", parsed.cleaned_text}}}}).dump())); + } } else { accumulated_content_ += parsed.cleaned_text; emit_content_delta(out, parsed.cleaned_text); diff --git a/server/src/server/sse_emitter.h b/server/src/server/sse_emitter.h index 06b4d892e..caff29dbb 100644 --- a/server/src/server/sse_emitter.h +++ b/server/src/server/sse_emitter.h @@ -153,6 +153,7 @@ class SseEmitter { ToolMemory * tool_memory_; StreamMode mode_; + bool tool_from_reasoning_ = false; std::string window_; // holdback buffer std::string tool_buffer_; // accumulated tool text bool tool_buffer_fallback_to_content_ = false; diff --git a/server/src/server/tool_parser.cpp b/server/src/server/tool_parser.cpp index b1542764a..8507002dc 100644 --- a/server/src/server/tool_parser.cpp +++ b/server/src/server/tool_parser.cpp @@ -176,6 +176,12 @@ static json find_tool_properties(const json & tools, const std::string & name) { return params["properties"]; } } + if (fn.contains("input_schema") && fn["input_schema"].is_object()) { + const auto & params = fn["input_schema"]; + if (params.contains("properties") && params["properties"].is_object()) { + return params["properties"]; + } + } } return json::object(); } @@ -183,8 +189,7 @@ static json find_tool_properties(const json & tools, const std::string & name) { // Convert a string value to its JSON-schema-typed equivalent. static json convert_param_value(const std::string & val, const std::string & key, const json & props) { - if (val == "null") return nullptr; - if (!props.contains(key)) return val; + if (!props.contains(key)) return val == "null" ? nullptr : json(val); const auto & cfg = props[key]; std::string ptype = "string"; @@ -206,6 +211,7 @@ static json convert_param_value(const std::string & val, const std::string & key // string types if (ptype == "string" || ptype == "str" || ptype == "enum") return val; + if (val == "null") return nullptr; // integer types if (ptype.substr(0, 3) == "int" || ptype == "integer") { @@ -1212,13 +1218,20 @@ ToolParseResult parse_tool_calls(const std::string & text, const json & tools) { } } } else { + size_t cursor = 0; + bool valid_body = true; auto pbegin = std::sregex_iterator(body.begin(), body.end(), re_param); auto pend = std::sregex_iterator(); for (auto pit = pbegin; pit != pend; ++pit) { + size_t ppos = pit->position(); + if (!trim_ws(body.substr(cursor, ppos - cursor)).empty()) { valid_body = false; break; } std::string k = (*pit)[2].str(); + if (args.contains(k)) { valid_body = false; break; } std::string v = trim_ws((*pit)[3].str()); args[k] = convert_param_value(v, k, find_tool_properties(tools, fn_name)); + cursor = ppos + pit->length(); } + if (!valid_body || (!args.empty() && !trim_ws(body.substr(cursor)).empty())) continue; } block_calls.push_back({fn_name, std::move(args)}); } diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index c1904561c..c1dcf1356 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -5758,3 +5758,49 @@ TEST_CASE(ServerUnitFixture, test_emitter_function_calls_inside_reasoning) { TEST_ASSERT(em.reasoning_text().find("Analyzing build files.") != std::string::npos); TEST_ASSERT(all.find("\"finish_reason\":\"tool_calls\"") != std::string::npos); } + +TEST_CASE(ServerUnitFixture, test_parse_function_calls_anthropic_input_schema) { + json anthropic_tools = json::array({ + { + {"name", "read"}, + {"description", "Read a file"}, + {"input_schema", { + {"type", "object"}, + {"properties", { + {"path", {{"type", "string"}}}, + {"offset", {{"type", "integer"}}} + }} + }} + } + }); + + const std::string text = + "\n" + "\n" + " main.cpp\n" + " 42\n" + "\n" + ""; + + auto result = parse_tool_calls(text, anthropic_tools); + TEST_ASSERT(result.tool_calls.size() == 1); + if (!result.tool_calls.empty()) { + auto args = json::parse(result.tool_calls[0].arguments); + TEST_ASSERT(args["path"] == "main.cpp"); + TEST_ASSERT(args["offset"] == 42); + } +} + +TEST_CASE(ServerUnitFixture, test_emitter_function_calls_unclosed_think_flushes_reasoning) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_tools(), false); + auto c1 = em.emit_token("Analyzing build files without closing tag.\n"); + auto c2 = em.emit_token("\n \n CMakeLists.txt\n \n"); + auto fin = em.emit_finish(2); + + std::string all = concat(c1) + concat(c2) + concat(fin); + TEST_ASSERT(em.tool_calls().size() == 1); + TEST_ASSERT(em.reasoning_text().find("Analyzing build files without closing tag.") != std::string::npos); + TEST_ASSERT(em.accumulated_text().find("Analyzing build files") == std::string::npos); + TEST_ASSERT(all.find("\"finish_reason\":\"tool_calls\"") != std::string::npos); +} + From c781af7fad8495cb5afd5c300c245e02b03703e9 Mon Sep 17 00:00:00 2001 From: Dobrica Pavlinusic Date: Tue, 18 Aug 2026 21:47:19 +0200 Subject: [PATCH 3/4] fix(server): track first_content_token_index on buffered think close Record first_content_token_index when is observed in TOOL_BUFFER to ensure accurate content_tokens counts when content follows a tool call. --- server/src/server/sse_emitter.cpp | 12 +++++++++--- server/test/test_server_unit.cpp | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index 580c6df04..85667f1c8 100644 --- a/server/src/server/sse_emitter.cpp +++ b/server/src/server/sse_emitter.cpp @@ -285,6 +285,12 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { // State machine loop — processes the window while (true) { if (mode_ == StreamMode::TOOL_BUFFER) { + if (tool_from_reasoning_ && first_content_token_index_ < 0) { + if (window_.find(THINK_CLOSE) != std::string::npos || + (tool_buffer_ + window_).find(THINK_CLOSE) != std::string::npos) { + first_content_token_index_ = emit_token_count_ - 1; + } + } tool_buffer_ += window_; window_.clear(); break; @@ -618,11 +624,11 @@ std::vector SseEmitter::emit_finish(int completion_tokens, if (!parsed.cleaned_text.empty()) { size_t think_close = parsed.cleaned_text.find(THINK_CLOSE); if (think_close != std::string::npos) { - if (first_content_token_index_ == -1) { - first_content_token_index_ = emit_token_count_; - } std::string reasoning = parsed.cleaned_text.substr(0, think_close); std::string content = parsed.cleaned_text.substr(think_close + THINK_CLOSE_LEN); + if (first_content_token_index_ == -1) { + first_content_token_index_ = content.empty() ? emit_token_count_ : std::max(0, emit_token_count_ - 1); + } if (!reasoning.empty()) { reasoning_text_ += reasoning; if (format_ == ApiFormat::OPENAI_CHAT) { diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index c1dcf1356..3a0f7cd9e 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -5804,3 +5804,18 @@ TEST_CASE(ServerUnitFixture, test_emitter_function_calls_unclosed_think_flushes_ TEST_ASSERT(all.find("\"finish_reason\":\"tool_calls\"") != std::string::npos); } +TEST_CASE(ServerUnitFixture, test_emitter_function_calls_content_tokens_accounting) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_tools(), false); + em.emit_token("Analyzing build configuration.\n"); + em.emit_token("\n \n CMakeLists.txt\n \n\n"); + em.emit_token("\n"); + em.emit_token("Here is the build summary."); + em.emit_finish(4); + + TEST_ASSERT(em.tool_calls().size() == 1); + TEST_ASSERT(em.first_content_token_index() == 2); + TEST_ASSERT(em.emit_token_count() == 4); + TEST_ASSERT(em.emit_token_count() - em.first_content_token_index() == 2); +} + + From ab274117017719c6374c3258ab98a4e5bf09d1cb Mon Sep 17 00:00:00 2001 From: Dobrica Pavlinusic Date: Tue, 18 Aug 2026 22:06:58 +0200 Subject: [PATCH 4/4] fix(server): match think close outside function_calls and align first_content_token_index Search for only outside the completed element to avoid false closes on parameters containing literal text, and align first_content_token_index with the first real content token. --- server/src/server/sse_emitter.cpp | 19 ++++++++++++++++--- server/test/test_server_unit.cpp | 25 +++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index 85667f1c8..32d545b22 100644 --- a/server/src/server/sse_emitter.cpp +++ b/server/src/server/sse_emitter.cpp @@ -286,9 +286,22 @@ std::vector SseEmitter::emit_token(const std::string & raw_piece) { while (true) { if (mode_ == StreamMode::TOOL_BUFFER) { if (tool_from_reasoning_ && first_content_token_index_ < 0) { - if (window_.find(THINK_CLOSE) != std::string::npos || - (tool_buffer_ + window_).find(THINK_CLOSE) != std::string::npos) { - first_content_token_index_ = emit_token_count_ - 1; + const std::string full = tool_buffer_ + window_; + const size_t fc_close = full.find(""); + if (fc_close != std::string::npos) { + const size_t search_start = fc_close + std::strlen(""); + const size_t think_close = full.find(THINK_CLOSE, search_start); + if (think_close != std::string::npos) { + const size_t after_think = think_close + THINK_CLOSE_LEN; + if (after_think < full.size() && + full.find_first_not_of(" \t\r\n", after_think) != std::string::npos) { + // The current token already carries content after + first_content_token_index_ = emit_token_count_ - 1; + } else { + // First real content token starts on the next token + first_content_token_index_ = emit_token_count_; + } + } } } tool_buffer_ += window_; diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 3a0f7cd9e..5529234e4 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -5806,16 +5806,37 @@ TEST_CASE(ServerUnitFixture, test_emitter_function_calls_unclosed_think_flushes_ TEST_CASE(ServerUnitFixture, test_emitter_function_calls_content_tokens_accounting) { auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_tools(), false); + // Token 0: reasoning em.emit_token("Analyzing build configuration.\n"); + // Token 1: function_calls em.emit_token("\n \n CMakeLists.txt\n \n\n"); + // Token 2: close think em.emit_token("\n"); + // Token 3: content em.emit_token("Here is the build summary."); em.emit_finish(4); TEST_ASSERT(em.tool_calls().size() == 1); - TEST_ASSERT(em.first_content_token_index() == 2); + TEST_ASSERT(em.first_content_token_index() == 3); TEST_ASSERT(em.emit_token_count() == 4); - TEST_ASSERT(em.emit_token_count() - em.first_content_token_index() == 2); + TEST_ASSERT(em.emit_token_count() - em.first_content_token_index() == 1); +} + +TEST_CASE(ServerUnitFixture, test_emitter_function_calls_param_with_literal_think_close) { + auto em = make_emitter(ApiFormat::OPENAI_CHAT, read_tools(), false); + // Token 0: reasoning + em.emit_token("Searching for tag.\n"); + // Token 1: parameter with literal inside + em.emit_token("\n \n test_.cpp\n \n\n"); + // Token 2: real close think + trailing content in same token + em.emit_token(" Found file."); + em.emit_finish(3); + + TEST_ASSERT(em.tool_calls().size() == 1); + TEST_ASSERT(em.first_content_token_index() == 2); + TEST_ASSERT(em.emit_token_count() == 3); + TEST_ASSERT(em.emit_token_count() - em.first_content_token_index() == 1); } +