Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 114 additions & 3 deletions server/src/server/sse_emitter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace dflash::common {

static const char THINK_OPEN[] = "<think>";
static const char THINK_CLOSE[] = "</think>";
static const char FUNCTION_CALLS_OPEN[] = "<function_calls>";
static constexpr size_t THINK_OPEN_LEN = 7;
static constexpr size_t THINK_CLOSE_LEN = 8;

Expand Down Expand Up @@ -284,6 +285,25 @@ std::vector<std::string> 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("</function_calls>");
if (fc_close != std::string::npos) {
const size_t search_start = fc_close + std::strlen("</function_calls>");
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 </think>
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;
Expand All @@ -306,7 +326,11 @@ std::vector<std::string> 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_) &&
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(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;
Expand Down Expand Up @@ -341,6 +365,39 @@ std::vector<std::string> 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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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_));
Expand Down Expand Up @@ -410,6 +467,7 @@ std::vector<std::string> 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;
}
Expand All @@ -419,6 +477,7 @@ std::vector<std::string> 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;
Expand Down Expand Up @@ -576,8 +635,60 @@ std::vector<std::string> 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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
emit_content_delta(out, parsed.cleaned_text);
}
}

fr = "tool_calls";
Expand Down
1 change: 1 addition & 0 deletions server/src/server/sse_emitter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 71 additions & 2 deletions server/src/server/tool_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ static std::string generate_call_id() {

static const char TOOL_OPEN[] = "<tool_call>";
static const char FUNCTION_CALL_OPEN[] = "<function_call>";
static const char FUNCTION_CALLS_OPEN[] = "<function_calls>";
static const char FUNCTION_OPEN[] = "<function=";
static const char BARE_FUNCTION_OPEN[] = "<function>";
static const char FUNCTION_SPACE_OPEN[] = "<function ";
Expand Down Expand Up @@ -106,6 +107,7 @@ bool find_tool_syntax_start(const std::string & text, const json & tools,
while (idx != std::string::npos) {
if (text.compare(idx, sizeof(TOOL_OPEN) - 1, TOOL_OPEN) == 0 ||
text.compare(idx, sizeof(FUNCTION_CALL_OPEN) - 1, FUNCTION_CALL_OPEN) == 0 ||
text.compare(idx, sizeof(FUNCTION_CALLS_OPEN) - 1, FUNCTION_CALLS_OPEN) == 0 ||
text.compare(idx, sizeof(FUNCTION_OPEN) - 1, FUNCTION_OPEN) == 0 ||
text.compare(idx, sizeof(BARE_FUNCTION_OPEN) - 1, BARE_FUNCTION_OPEN) == 0 ||
text.compare(idx, sizeof(FUNCTION_SPACE_OPEN) - 1,
Expand Down Expand Up @@ -138,6 +140,7 @@ bool find_tool_syntax_start(const std::string & text, const json & tools,
size_t tool_syntax_holdback(const json & tools) {
// Longest fixed opener is `<parameter name=` (16 bytes).
size_t holdback = std::max({sizeof(ATTRIBUTE_PARAMETER_OPEN) - 2,
sizeof(FUNCTION_CALLS_OPEN) - 2,
sizeof(FUNCTION_CALL_OPEN) - 2,
sizeof(BARE_FUNCTION_OPEN) - 2});
if (!tools.is_array()) return holdback;
Expand Down Expand Up @@ -173,15 +176,20 @@ 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();
}

// 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";
Expand All @@ -203,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") {
Expand Down Expand Up @@ -1174,6 +1183,66 @@ ToolParseResult parse_tool_calls(const std::string & text, const json & tools) {
}
}

// Pattern 4d: <function_calls><invoke name="NAME">...<param name="K">V</param>...</invoke></function_calls>
{
static const std::regex re_block(R"(<function_calls>([\s\S]*?)</function_calls>)");
static const std::regex re_invoke(R"(<invoke\s+(?:name|tool)\s*=\s*["']?([A-Za-z_][\w.\-]*)["']?\s*>([\s\S]*?)</invoke>)");
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<std::pair<std::string, json>> 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() == '{') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A malformed/single-source JSON body inside is silently ignored (no call, no error), so in a multi-invoke <function_calls> block one bad JSON invoke is dropped while valid siblings still execute; an empty '{}' body also produces a zero-argument call. Parse defensively and collect per-invoke errors or skip only the malformed invoke while reporting the rest.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/server/tool_parser.cpp, line 1203:

<comment>A malformed/single-source JSON body inside <invoke> is silently ignored (no call, no error), so in a multi-invoke <function_calls> block one bad JSON invoke is dropped while valid siblings still execute; an empty '{}' body also produces a zero-argument call. Parse defensively and collect per-invoke errors or skip only the malformed invoke while reporting the rest.</comment>

<file context>
@@ -1174,6 +1177,59 @@ ToolParseResult parse_tool_calls(const std::string & text, const json & tools) {
+                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;
</file context>

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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
for (auto & [k, v] : raw_args.items()) {
if (v.is_string()) {
args[k] = convert_param_value(v.get<std::string>(), k, props);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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)});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an invoke body contains a truncated parameter or extra non-parameter text, Pattern 4d still emits a tool call with partial arguments and removes the entire block. Validate that parameter matches cover the complete body and reject duplicate keys before queuing the call, as the existing strict XML parser does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/server/tool_parser.cpp, line 1223:

<comment>When an invoke body contains a truncated parameter or extra non-parameter text, Pattern 4d still emits a tool call with partial arguments and removes the entire block. Validate that parameter matches cover the complete body and reject duplicate keys before queuing the call, as the existing strict XML parser does.</comment>

<file context>
@@ -1174,6 +1177,59 @@ ToolParseResult parse_tool_calls(const std::string & text, const json & tools) {
+                        args[k] = convert_param_value(v, k, find_tool_properties(tools, fn_name));
+                    }
+                }
+                block_calls.push_back({fn_name, std::move(args)});
+            }
+
</file context>

}

if (!block_calls.empty()) {
for (auto & bc : block_calls) {
add_call(bc.first, bc.second, bstart, bend);
}
}
}
}


// Pattern 5: call:<ns>?<verb>{relaxed-JSON args}
Expand Down
Loading