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[] = "";
static const char FUNCTION_SPACE_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]*?)\1>)");
+
+ 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);
+}
+
+
+