diff --git a/server/src/server/sse_emitter.cpp b/server/src/server/sse_emitter.cpp index c3d53bdd3..32d545b22 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; @@ -284,6 +285,25 @@ 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) { + 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_; window_.clear(); break; @@ -306,7 +326,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_) && + (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); if (!pre.empty()) { reasoning_text_ += pre; @@ -341,6 +365,39 @@ 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); + tool_from_reasoning_ = true; + 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_)); @@ -410,6 +467,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; } @@ -419,6 +477,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; @@ -576,8 +635,60 @@ 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 (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) { + 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); + } } fr = "tool_calls"; 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 01e2a31c4..8507002dc 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 { + 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)}); + } + + 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..5529234e4 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -5704,3 +5704,139 @@ 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); +} + +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); +} + +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() == 3); + TEST_ASSERT(em.emit_token_count() == 4); + 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); +} + + +