985 lines
47 KiB
C++
985 lines
47 KiB
C++
#include "chat-peg-parser.h"
|
||
|
||
#include "chat-auto-parser.h"
|
||
#include "ggml.h"
|
||
|
||
#include <nlohmann/json.hpp>
|
||
|
||
using json = nlohmann::ordered_json;
|
||
|
||
static std::string_view trim_trailing_space(std::string_view sv, int max = -1) {
|
||
int count = 0;
|
||
while (!sv.empty() && std::isspace(static_cast<unsigned char>(sv.back()))) {
|
||
if (max != -1 && count >= max) {
|
||
break;
|
||
}
|
||
sv.remove_suffix(1);
|
||
count++;
|
||
}
|
||
return sv;
|
||
}
|
||
|
||
static std::string_view trim_leading_space(std::string_view sv, int max = -1) {
|
||
int count = 0;
|
||
while (!sv.empty() && std::isspace(static_cast<unsigned char>(sv.front()))) {
|
||
if (max != -1 && count >= max) {
|
||
break;
|
||
}
|
||
sv.remove_prefix(1);
|
||
count++;
|
||
}
|
||
return sv;
|
||
}
|
||
|
||
static std::string_view trim(std::string_view sv) {
|
||
return trim_trailing_space(trim_leading_space(sv, 1));
|
||
}
|
||
|
||
// Convert Python-style single-quoted strings to JSON double-quoted strings
|
||
// Only converts outer string delimiters, properly handling escape sequences:
|
||
// - {'key': 'value'} -> {"key": "value"}
|
||
// - {'code': 'print(\'hello\')'} -> {"code": "print('hello')"}
|
||
// - {'msg': 'He said "hi"'} -> {"msg": "He said \"hi\""}
|
||
static std::string normalize_quotes_to_json(const std::string & input) {
|
||
std::string result;
|
||
result.reserve(input.size() + 16); // May need extra space for escaping
|
||
|
||
bool in_single_quoted = false;
|
||
bool in_double_quoted = false;
|
||
|
||
for (size_t i = 0; i < input.size(); ++i) {
|
||
char c = input[i];
|
||
|
||
// Handle escape sequences
|
||
if (c == '\\' && i + 1 < input.size()) {
|
||
char next = input[i + 1];
|
||
|
||
if (in_single_quoted) {
|
||
// Inside a single-quoted string being converted to double quotes
|
||
if (next == '\'') {
|
||
// \' -> ' (escaped single quote becomes unescaped in double-quoted string)
|
||
result += '\'';
|
||
++i;
|
||
continue;
|
||
}
|
||
if (next == '"') {
|
||
// \" stays as \" (already escaped, works in double-quoted string)
|
||
result += "\\\"";
|
||
++i;
|
||
continue;
|
||
}
|
||
// Other escapes (\n, \\, etc.): pass through both characters
|
||
result += c;
|
||
result += next;
|
||
++i;
|
||
continue;
|
||
}
|
||
|
||
if (in_double_quoted) {
|
||
// Inside a double-quoted string - pass through escape sequences as-is
|
||
result += c;
|
||
result += next;
|
||
++i;
|
||
continue;
|
||
}
|
||
|
||
// Outside any string - just pass through the backslash
|
||
result += c;
|
||
continue;
|
||
}
|
||
|
||
// Handle quote characters
|
||
if (c == '"') {
|
||
if (in_single_quoted) {
|
||
// Unescaped double quote inside single-quoted string -> must escape for JSON
|
||
result += "\\\"";
|
||
} else {
|
||
// Double quote as string delimiter or outside strings
|
||
in_double_quoted = !in_double_quoted;
|
||
result += c;
|
||
}
|
||
} else if (c == '\'') {
|
||
if (in_double_quoted) {
|
||
// Single quote inside double-quoted string -> pass through
|
||
result += c;
|
||
} else if (in_single_quoted) {
|
||
// Closing single quote -> convert to double quote
|
||
in_single_quoted = false;
|
||
result += '"';
|
||
} else {
|
||
// Opening single quote -> convert to double quote
|
||
in_single_quoted = true;
|
||
result += '"';
|
||
}
|
||
} else {
|
||
result += c;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
void common_chat_peg_mapper::from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result) {
|
||
arena.visit(result, [this](const common_peg_ast_node & node) { map(node); });
|
||
}
|
||
|
||
void common_chat_peg_mapper::map(const common_peg_ast_node & node) {
|
||
bool is_reasoning = node.tag == common_chat_peg_builder::REASONING;
|
||
bool is_content = node.tag == common_chat_peg_builder::CONTENT;
|
||
|
||
if (is_reasoning) { // GPT OSS can have more than 1 reasoning block, so concatenate here
|
||
result.reasoning_content += std::string(trim_trailing_space(node.text));
|
||
}
|
||
|
||
if (is_content) {
|
||
// Concatenate content from multiple content nodes (e.g., when reasoning markers
|
||
// are preserved before content markers in reasoning_format=NONE mode)
|
||
result.content += std::string(trim_trailing_space(node.text));
|
||
}
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_builder::tag_with_safe_content(const std::string & tag_name,
|
||
const std::string & marker,
|
||
const common_peg_parser & p) {
|
||
if (marker.empty()) {
|
||
return zero_or_more(choice({ p, rule(tag_name, content(any())) }));
|
||
}
|
||
auto content_chunk = rule(tag_name, content(negate(literal(marker)) + any() + until(marker)));
|
||
return zero_or_more(choice({ p, content_chunk }));
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::build_reasoning_block(const content_structure & cs,
|
||
common_reasoning_format reasoning_format,
|
||
bool thinking_forced_open) {
|
||
// If reasoning is explicitly disabled, return empty
|
||
if (reasoning_format == COMMON_REASONING_FORMAT_NONE) {
|
||
return eps();
|
||
}
|
||
|
||
// Get reasoning markers - use from content_structure or fallback for DEEPSEEK format
|
||
std::string reason_start = cs.reasoning_start;
|
||
std::string reason_end = cs.reasoning_end;
|
||
|
||
// If DEEPSEEK format is specified but markers weren't detected, use fallback markers
|
||
if ((reasoning_format == COMMON_REASONING_FORMAT_DEEPSEEK ||
|
||
reasoning_format == COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY) &&
|
||
(reason_start.empty() || reason_end.empty())) {
|
||
// Try standard DeepSeek markers
|
||
if (reason_start.empty()) {
|
||
reason_start = "<think>";
|
||
}
|
||
if (reason_end.empty()) {
|
||
reason_end = "</think>";
|
||
}
|
||
}
|
||
|
||
// If still no markers, return empty
|
||
// But allow empty start marker if thinking is forced open (implicit start)
|
||
if ((reason_start.empty() && !thinking_forced_open) || reason_end.empty()) {
|
||
return eps();
|
||
}
|
||
|
||
if (thinking_forced_open) {
|
||
// Mandatory reasoning: parse from current position to end marker
|
||
auto parser = reasoning(until(reason_end)) + literal(reason_end);
|
||
return rule("reasoning", reasoning_block(parser));
|
||
}
|
||
// Optional reasoning: may or may not appear
|
||
// Also try <|START_THINKING|> style markers if standard markers don't match
|
||
auto standard_reasoning =
|
||
reasoning_block(literal(reason_start) + reasoning(until(reason_end)) + literal(reason_end));
|
||
|
||
// For templates that use <|START_THINKING|> style markers
|
||
if (reason_start == "<think>" && reason_end == "</think>") {
|
||
auto alt_reasoning = reasoning_block(literal("<|START_THINKING|>") + reasoning(until("<|END_THINKING|>")) +
|
||
literal("<|END_THINKING|>"));
|
||
return optional(rule("reasoning", choice({ standard_reasoning, alt_reasoning })));
|
||
}
|
||
|
||
return optional(rule("reasoning", standard_reasoning));
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::build_content_block(const content_structure & cs,
|
||
common_reasoning_format reasoning_format,
|
||
const std::string & tool_section_start) {
|
||
GGML_UNUSED(tool_section_start); // leaving for now just in case
|
||
std::string content_start = cs.content_start;
|
||
std::string content_end = cs.content_end;
|
||
|
||
// Add fallback content markers for DEEPSEEK format if not detected
|
||
// Some templates use <response> tags for content when reasoning is enabled
|
||
if ((reasoning_format == COMMON_REASONING_FORMAT_DEEPSEEK ||
|
||
reasoning_format == COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY) &&
|
||
(content_start.empty() || content_end.empty())) {
|
||
content_start = "<response>";
|
||
content_end = "</response>";
|
||
}
|
||
|
||
// Handle content markers with both start and end
|
||
if (cs.content_mode != content_structure::CONTENT_PLAIN && !cs.content_start.empty() && !cs.content_end.empty()) {
|
||
// Content is wrapped in markers
|
||
if (reasoning_format == COMMON_REASONING_FORMAT_NONE) {
|
||
// When reasoning_format=NONE, preserve any content before the content start marker
|
||
// (this may include reasoning/thinking markers that the model generates).
|
||
// This applies even if reasoning markers weren't detected by the analyzer.
|
||
auto with_markers = content(until(cs.content_start)) + literal(cs.content_start) +
|
||
content(until(cs.content_end)) + literal(cs.content_end);
|
||
// Fallback: content wrapped in end marker only (start marker might be in prompt)
|
||
auto implicit_markers = content(until(cs.content_end)) + literal(cs.content_end);
|
||
auto without_markers = content(rest());
|
||
return choice({ with_markers, implicit_markers, without_markers });
|
||
} // When reasoning is parsed separately, content starts directly after reasoning block
|
||
auto with_markers = literal(cs.content_start) + content(until(cs.content_end)) + literal(cs.content_end);
|
||
auto implicit_markers = content(until(cs.content_end)) + literal(cs.content_end);
|
||
auto without_markers = content(rest());
|
||
return choice({ with_markers, implicit_markers, without_markers });
|
||
}
|
||
|
||
// Handle content with only start marker (no end marker)
|
||
// This is for formats like recipient-based (Functionary v3.2) where content is prefixed with
|
||
// a marker but has no explicit closing marker - content ends at end of message or before tool calls
|
||
if (cs.content_mode != content_structure::CONTENT_PLAIN && !cs.content_start.empty() && cs.content_end.empty()) {
|
||
if (reasoning_format == COMMON_REASONING_FORMAT_NONE) {
|
||
// Preserve any content before the start marker, then consume the marker and capture rest
|
||
auto with_start_marker = content(until(cs.content_start)) + literal(cs.content_start) + content(rest());
|
||
auto without_markers = content(rest());
|
||
return choice({ with_start_marker, without_markers });
|
||
} // Content starts directly after reasoning block
|
||
auto with_start_marker = literal(cs.content_start) + content(rest());
|
||
auto without_markers = content(rest());
|
||
return choice({ with_start_marker, without_markers });
|
||
}
|
||
|
||
// For DEEPSEEK format, try fallback content markers even if not detected
|
||
if (!content_start.empty() && !content_end.empty()) {
|
||
auto with_markers = literal(content_start) + content(until(content_end)) + literal(content_end);
|
||
auto without_markers = content(rest());
|
||
return choice({ with_markers, without_markers });
|
||
}
|
||
|
||
// Plain content - capture rest
|
||
return content(rest());
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::build_tool_section(const tool_call_structure & ts,
|
||
const nlohmann::json & tools,
|
||
bool parallel_tool_calls,
|
||
bool force_tool_calls) {
|
||
if (!ts.supports_tools || !tools.is_array() || tools.empty()) {
|
||
return eps();
|
||
}
|
||
|
||
// Build tool choices based on function format
|
||
auto tool_choices = choice();
|
||
|
||
for (const auto & tool_def : tools) {
|
||
if (!tool_def.contains("function")) {
|
||
continue;
|
||
}
|
||
const auto & function = tool_def.at("function");
|
||
std::string name = function.at("name");
|
||
nlohmann::json params = function.contains("parameters") ? function.at("parameters") : nlohmann::json::object();
|
||
|
||
tool_choices |= rule("tool-" + name, build_function(ts, name, params));
|
||
}
|
||
|
||
// Build the section with or without markers
|
||
auto build_section = [&]() -> common_peg_parser {
|
||
// Markdown code block format (Cohere Command-R Plus):
|
||
// Action:\n```json\n[{...}]\n```
|
||
if (ts.function_format == tool_call_structure::FUNC_MARKDOWN_CODE_BLOCK) {
|
||
// Build the opening: "Action:\n```json"
|
||
std::string code_fence_open = "```";
|
||
if (!ts.code_block_language.empty()) {
|
||
code_fence_open += ts.code_block_language;
|
||
}
|
||
|
||
auto opening = literal(ts.code_block_marker) + literal("\n") + literal(code_fence_open) + literal("\n");
|
||
auto closing = literal("\n") + literal(ts.tool_section_end); // "\n```"
|
||
|
||
// Build the JSON array of tool calls
|
||
// Don't use trigger_rule here since we're nested inside a sequence
|
||
auto tools_array = literal("[") + space();
|
||
if (parallel_tool_calls) {
|
||
tools_array = tools_array + tool_choices;
|
||
tools_array = tools_array + zero_or_more(space() + literal(",") + space() + tool_choices);
|
||
} else {
|
||
tools_array = tools_array + optional(tool_choices);
|
||
}
|
||
tools_array = tools_array + space() + literal("]");
|
||
|
||
// Full section: Action:\n```json\n[{...}]\n```
|
||
return trigger_rule("tool-call", opening + tools_array + closing);
|
||
}
|
||
|
||
// Recipient-based format (Functionary v3.2): >>>function_name\n{arguments}
|
||
// Uses tool_section_start as delimiter, but no array wrapper or section markers
|
||
if (ts.function_format == tool_call_structure::FUNC_RECIPIENT_BASED) {
|
||
auto tool_call = trigger_rule("tool-call", tool_choices);
|
||
if (parallel_tool_calls) {
|
||
// Multiple tool calls: each starts with >>>
|
||
return one_or_more(tool_call + space());
|
||
}
|
||
return tool_call;
|
||
}
|
||
|
||
if (!ts.tool_section_start.empty() && !ts.tool_section_end.empty()) {
|
||
// Check if this format has SEPARATE section markers and per-call markers.
|
||
// This happens when:
|
||
// - Section markers wrap the ENTIRE section (e.g., <tool_calls_begin>...<tool_calls_end>)
|
||
// - Function prefix contains its own per-call marker (e.g., <tool_call_begin>...)
|
||
// Example: DeepSeek R1 with section and call markers, Kimi-K2 with prefixed-indexed format
|
||
// We detect this by checking if function_prefix contains a per-call START marker
|
||
// (indicated by words like "call_begin", "call_start", or similar patterns)
|
||
bool has_separate_section_and_call_markers = false;
|
||
|
||
// FUNC_PREFIXED_INDEXED and FUNC_BRACKET_TAG always have separate section and per-call markers
|
||
if (ts.function_format == tool_call_structure::FUNC_PREFIXED_INDEXED ||
|
||
ts.function_format == tool_call_structure::FUNC_BRACKET_TAG) {
|
||
has_separate_section_and_call_markers = true;
|
||
} else if (ts.function_format == tool_call_structure::FUNC_NAME_AS_KEY) {
|
||
// FUNC_NAME_AS_KEY uses comma-separated JSON objects in an array
|
||
// Format: [{"func1": args}, {"func2": args}]
|
||
// The brackets are included in section markers
|
||
auto tool_call = trigger_rule("tool-call", tool_choices);
|
||
auto tool_calls = tool_call;
|
||
if (parallel_tool_calls) {
|
||
tool_calls = tool_call + zero_or_more(space() + literal(",") + space() + tool_call);
|
||
}
|
||
return literal(ts.tool_section_start) + space() + tool_calls + space() + literal(ts.tool_section_end);
|
||
} else if (ts.function_format == tool_call_structure::FUNC_TAG_WITH_NAME && !ts.function_prefix.empty()) {
|
||
// Check if function_prefix contains a per-call marker like "<tool_call_begin>"
|
||
// This differentiates DeepSeek R1 (where function_prefix has its own call marker)
|
||
// from Nemotron (where function_prefix is just "<function=")
|
||
// DeepSeek pattern: function_prefix = "<|tool▁call▁begin|>function<|tool▁sep|>"
|
||
// Nemotron pattern: function_prefix = "<function="
|
||
bool prefix_has_call_marker = ts.function_prefix.find("call") != std::string::npos &&
|
||
(ts.function_prefix.find("begin") != std::string::npos ||
|
||
ts.function_prefix.find("start") != std::string::npos);
|
||
if (prefix_has_call_marker) {
|
||
has_separate_section_and_call_markers = true;
|
||
}
|
||
}
|
||
if (has_separate_section_and_call_markers) {
|
||
// Section markers wrap all calls, per-call markers are in function_prefix/close
|
||
// Format: <section_start> <call1> <call2> ... <section_end>
|
||
auto tool_call = trigger_rule("tool-call", tool_choices);
|
||
auto tool_calls = parallel_tool_calls ? one_or_more(tool_call + space()) : tool_call;
|
||
return literal(ts.tool_section_start) + space() + tool_calls + space() + literal(ts.tool_section_end);
|
||
} // Each tool call has its own wrapper: <tool_call>tool</tool_call>
|
||
auto single_tool_section =
|
||
trigger_rule("tool-call", literal(ts.tool_section_start) + space() + tool_choices + space() +
|
||
literal(ts.tool_section_end));
|
||
if (parallel_tool_calls) {
|
||
// Multiple wrapped tool calls
|
||
return one_or_more(single_tool_section + space());
|
||
}
|
||
return single_tool_section;
|
||
}
|
||
if (!ts.tool_section_start.empty()) {
|
||
// Start marker only (no end marker) - e.g., <|tool_call|>[...]
|
||
// Wrap all tool calls in an array after the start marker
|
||
auto tools_array = literal("[") + space();
|
||
if (parallel_tool_calls) {
|
||
tools_array = tools_array + tool_choices;
|
||
tools_array = tools_array + zero_or_more(space() + literal(",") + space() + tool_choices);
|
||
} else {
|
||
tools_array = tools_array + optional(tool_choices);
|
||
}
|
||
tools_array = tools_array + space() + literal("]");
|
||
|
||
return trigger_rule("tool-call", literal(ts.tool_section_start) + tools_array);
|
||
} // No section markers (raw JSON format, e.g., Llama 3.1)
|
||
// Use trigger rule since tool calls are identified by regex trigger on the grammar
|
||
if (parallel_tool_calls) {
|
||
return trigger_rule("tool-call", one_or_more(tool_choices + space()));
|
||
}
|
||
return trigger_rule("tool-call", tool_choices);
|
||
};
|
||
|
||
auto section = build_section();
|
||
if (!force_tool_calls) {
|
||
section = optional(section);
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::build_function(const tool_call_structure & ts,
|
||
const std::string & name,
|
||
const nlohmann::json & schema) {
|
||
auto args = build_arguments(ts, schema);
|
||
|
||
switch (ts.function_format) {
|
||
case tool_call_structure::FUNC_JSON_OBJECT:
|
||
{
|
||
// Build JSON object parser that accepts id field in either position:
|
||
// - Before name: {"id": "...", "name": "X", "arguments": {...}} (R7B style)
|
||
// - After args: {"name": "X", "arguments": {...}, "id": "..."} (Mistral style)
|
||
auto tool_name_ = json_member(ts.name_field, "\"" + tool_name(literal(name)) + "\"");
|
||
auto tool_args_ = json_member(ts.args_field, tool_args(args));
|
||
|
||
// id can appear before name or after args
|
||
auto id_member = json_member(ts.id_field, tool_id(json_string()));
|
||
auto id_before = ts.id_field.empty() ? eps() : optional(id_member << space() << "," << space());
|
||
auto id_after = ts.id_field.empty() ? eps() : optional(space() << "," << space() << id_member);
|
||
|
||
return tool(tool_open(literal("{")) << space() << id_before // optional id before name (R7B style)
|
||
<< tool_name_ << space() << "," << space() << tool_args_
|
||
<< id_after // optional id after args (Mistral style)
|
||
<< zero_or_more(space() << "," << space() << json_string()
|
||
<< space() << ":" << space() << json())
|
||
<< space() << "}");
|
||
}
|
||
|
||
case tool_call_structure::FUNC_TAG_WITH_NAME:
|
||
{
|
||
// Build tag parser: <function=X>{...}</function>
|
||
// Combine prefix + name + suffix into tool_open to ensure the tool is only created
|
||
// when the FULL opening tag is confirmed. This prevents partial name matches during
|
||
// incremental parsing (e.g., matching "special_function" when input is "special_function_")
|
||
auto opening = literal(ts.function_prefix) + tool_name(literal(name)) + literal(ts.function_suffix);
|
||
// Note: No space() before tool_close because function_close may start with newline
|
||
// (e.g., "\n```<close_tag>") and space() would consume it, preventing the literal match
|
||
return tool(tool_open(opening) + space() + tool_args(args) + tool_close(literal(ts.function_close)));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_TAG_NAME_ONLY:
|
||
{
|
||
// Build tag parser: <X>...</X>
|
||
// Combine < + name + > into tool_open to prevent partial matches
|
||
auto opening = literal("<") + tool_name(literal(name)) + literal(">");
|
||
return tool(tool_open(opening) + space() + tool_args(args) + space() +
|
||
tool_close(literal("</" + name + ">")));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_PREFIXED_INDEXED:
|
||
{
|
||
// Build prefixed-indexed parser (e.g., Kimi-K2):
|
||
// <|tool_call_begin|>functions.special_function:0<|tool_call_argument_begin|>{...}<|tool_call_end|>
|
||
// The index number after : is ignored (we use zero_or_more(digit) to skip it)
|
||
auto opening = literal(ts.per_call_start) + literal(ts.function_namespace) + tool_name(literal(name)) +
|
||
literal(":") + zero_or_more(chars("0-9", 1, 1)) + // Skip the index
|
||
literal(ts.args_marker);
|
||
return tool(tool_open(opening) + space() + tool_args(args) + space() +
|
||
tool_close(literal(ts.per_call_end)));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_NAME_AS_KEY:
|
||
{
|
||
// Build name-as-key parser (e.g., Apertus):
|
||
// {"function_name": {...arguments...}}
|
||
// The function name IS the JSON key, and arguments are the value directly
|
||
auto opening = literal("{\"") + tool_name(literal(name)) + literal("\":");
|
||
return tool(tool_open(opening) + space() + tool_args(args) + space() + literal("}"));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_BRACKET_TAG:
|
||
{
|
||
// Build bracket-tag parser (e.g., Mistral Small 3.2):
|
||
// [TOOL_CALLS]function_name[CALL_ID]call_id[ARGS]{...}
|
||
// per_call_start = "[TOOL_CALLS]"
|
||
// id_marker = "[CALL_ID]"
|
||
// args_marker = "[ARGS]"
|
||
auto opening = literal(ts.per_call_start) + tool_name(literal(name));
|
||
if (!ts.id_marker.empty()) {
|
||
// Add id_marker + id value (captured as tool_id)
|
||
opening = opening + literal(ts.id_marker) + tool_id(until(ts.args_marker));
|
||
}
|
||
if (!ts.args_marker.empty()) {
|
||
opening = opening + literal(ts.args_marker);
|
||
}
|
||
// No explicit closer for this format (EOS terminates)
|
||
return tool(tool_open(opening) + space() + tool_args(args));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_RECIPIENT_BASED:
|
||
{
|
||
// Build recipient-based parser (e.g., Functionary v3.2):
|
||
// >>>function_name
|
||
// {'param1': 'value1', 'param2': 'value2'}
|
||
// tool_section_start = ">>>"
|
||
// Function name directly follows ">>>" with newline, arguments are Python dict (parse as JSON)
|
||
auto opening = literal(ts.tool_section_start) + tool_name(literal(name));
|
||
// No explicit closer (newline + arguments, then EOS or next >>>)
|
||
return tool(tool_open(opening) + space() + tool_args(args));
|
||
}
|
||
|
||
case tool_call_structure::FUNC_MARKDOWN_CODE_BLOCK:
|
||
{
|
||
// Build markdown code block parser (e.g., Cohere Command-R Plus):
|
||
// Action:
|
||
// ```json
|
||
// [
|
||
// {
|
||
// "tool_name": "function_name",
|
||
// "parameters": {...}
|
||
// }
|
||
// ]
|
||
// ```
|
||
// The individual function is a JSON object within the array
|
||
auto tool_name_ = json_member(ts.name_field, "\"" + tool_name(literal(name)) + "\"");
|
||
auto tool_args_ = json_member(ts.args_field, tool_args(args));
|
||
|
||
// Build the JSON object: {"tool_name": "...", "parameters": {...}}
|
||
// Use same pattern as FUNC_JSON_OBJECT: tool_open with atomic wrapper
|
||
return tool(tool_open(literal("{")) << space() << tool_name_ << space() << "," << space() << tool_args_
|
||
<< zero_or_more(space() << "," << space() << json_string()
|
||
<< space() << ":" << space() << json())
|
||
<< space() << "}");
|
||
}
|
||
}
|
||
|
||
return eps();
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::build_arguments(const tool_call_structure & ts,
|
||
const nlohmann::json & params) {
|
||
switch (ts.argument_format) {
|
||
case tool_call_structure::ARGS_JSON:
|
||
{
|
||
// Standard JSON object arguments
|
||
if (params.is_object()) {
|
||
return schema(json(), "args", params);
|
||
}
|
||
return json();
|
||
}
|
||
|
||
case tool_call_structure::ARGS_TAGGED:
|
||
{
|
||
// Tagged arguments: <param=key>value</param>
|
||
if (!params.contains("properties") || params.at("properties").empty()) {
|
||
return eps();
|
||
}
|
||
|
||
auto arg_choice = choice();
|
||
for (const auto & el : params.at("properties").items()) {
|
||
const std::string & prop_name = el.key();
|
||
const auto & prop_schema = el.value();
|
||
|
||
// Check if the schema declares this as a string type
|
||
bool is_string_type = prop_schema.contains("type") && prop_schema.at("type") == "string";
|
||
|
||
auto arg_name_parser = choice(
|
||
{ literal(prop_name), literal("\"" + prop_name + "\""), literal("'" + prop_name + "'") });
|
||
|
||
// Use tool_arg_string_value for string types to prevent treating "[..." as JSON array
|
||
auto value_parser = is_string_type ? tool_arg_string_value(until(ts.arg_close))
|
||
: tool_arg_value(until(ts.arg_close));
|
||
|
||
auto arg_rule = tool_arg(tool_arg_open(literal(ts.arg_prefix)) + tool_arg_name(arg_name_parser) +
|
||
literal(ts.arg_suffix) + value_parser +
|
||
tool_arg_close(literal(ts.arg_close)) +
|
||
(ts.arg_separator.empty() ? eps() : optional(literal(ts.arg_separator))));
|
||
arg_choice |= arg_rule;
|
||
}
|
||
return zero_or_more(arg_choice + space());
|
||
}
|
||
|
||
case tool_call_structure::ARGS_KEY_VALUE_TAGS:
|
||
{
|
||
// Key-value tag arguments (GLM-4.6 style):
|
||
// <arg_key>key</arg_key>
|
||
// <arg_value>value</arg_value>
|
||
if (!params.contains("properties") || params.at("properties").empty()) {
|
||
return eps();
|
||
}
|
||
|
||
auto arg_choice = choice();
|
||
for (const auto & el : params.at("properties").items()) {
|
||
const std::string & prop_name = el.key();
|
||
const auto & prop_schema = el.value();
|
||
|
||
// Check if the schema declares this as a string type
|
||
bool is_string_type = prop_schema.contains("type") && prop_schema.at("type") == "string";
|
||
|
||
// Parse: <arg_key>key</arg_key>\n<arg_value>value</arg_value>
|
||
// ts.arg_prefix = "<arg_key>", ts.arg_suffix = "</arg_key>", ts.arg_close = "</arg_value>"
|
||
// Use tool_arg_string_value for string types to prevent treating "[..." as JSON array
|
||
auto value_parser = is_string_type ? tool_arg_string_value(until(ts.arg_close))
|
||
: tool_arg_value(until(ts.arg_close));
|
||
|
||
auto arg_rule = tool_arg(tool_arg_open(literal(ts.arg_prefix)) + tool_arg_name(literal(prop_name)) +
|
||
literal(ts.arg_suffix) + // </arg_key>
|
||
space() + literal("<arg_value>") + value_parser +
|
||
tool_arg_close(literal(ts.arg_close)));
|
||
arg_choice |= arg_rule;
|
||
}
|
||
return zero_or_more(arg_choice + space());
|
||
}
|
||
}
|
||
|
||
return eps();
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::standard_json_tools(const std::string & section_start,
|
||
const std::string & section_end,
|
||
const nlohmann::json & tools,
|
||
bool parallel_tool_calls,
|
||
bool force_tool_calls) {
|
||
if (!tools.is_array() || tools.empty()) {
|
||
return eps();
|
||
}
|
||
|
||
// Build tool choices for JSON format
|
||
auto tool_choices = choice();
|
||
|
||
for (const auto & tool_def : tools) {
|
||
if (!tool_def.contains("function")) {
|
||
continue;
|
||
}
|
||
const auto & function = tool_def.at("function");
|
||
std::string name = function.at("name");
|
||
nlohmann::json params = function.contains("parameters") ? function.at("parameters") : nlohmann::json::object();
|
||
|
||
// Build JSON object parser: {"name": "X", "arguments": {...}}
|
||
auto tool_name_ = json_member("name", "\"" + tool_name(literal(name)) + "\"");
|
||
auto tool_args_ = json_member("arguments", tool_args(schema(json(), "tool-" + name + "-schema", params)));
|
||
|
||
auto tool_parser =
|
||
tool(tool_open(literal("{")) << space() << tool_name_ << space() << "," << space() << tool_args_
|
||
<< zero_or_more(space() << "," << space() << json_string() << space() << ":"
|
||
<< space() << json())
|
||
<< space() << "}");
|
||
|
||
tool_choices |= rule("tool-" + name, tool_parser);
|
||
}
|
||
|
||
// Build the section with markers
|
||
auto tool_calls = tool_choices;
|
||
if (parallel_tool_calls) {
|
||
tool_calls = tool_calls + zero_or_more(space() + literal(",") + space() + tool_choices);
|
||
}
|
||
|
||
auto section =
|
||
trigger_rule("tool-call", literal(section_start) + space() + tool_calls + space() + literal(section_end));
|
||
|
||
return force_tool_calls ? section : optional(section);
|
||
}
|
||
|
||
common_peg_parser common_chat_peg_unified_builder::standard_constructed_tools(
|
||
const std::map<std::string, std::string> & markers,
|
||
const nlohmann::json & tools,
|
||
bool parallel_tool_calls,
|
||
bool force_tool_calls) {
|
||
if (!tools.is_array() || tools.empty()) {
|
||
return eps();
|
||
}
|
||
|
||
// Extract markers with defaults
|
||
auto get_marker = [&markers](const std::string & key, const std::string & default_val = "") -> std::string {
|
||
auto it = markers.find(key);
|
||
return it != markers.end() ? it->second : default_val;
|
||
};
|
||
|
||
std::string section_start = get_marker("tool_call_start_marker", "<tool_call>");
|
||
std::string section_end = get_marker("tool_call_end_marker", "</tool_call>");
|
||
std::string func_opener = get_marker("function_opener", "<function=");
|
||
std::string func_name_suffix = get_marker("function_name_suffix", ">");
|
||
std::string func_closer = get_marker("function_closer", "</function>");
|
||
std::string param_key_prefix = get_marker("parameter_key_prefix", "<param=");
|
||
std::string param_key_suffix = get_marker("parameter_key_suffix", ">");
|
||
std::string param_closer = get_marker("parameter_closer", "</param>");
|
||
|
||
// Build tool choices for tagged format
|
||
auto tool_choices = choice();
|
||
|
||
for (const auto & tool_def : tools) {
|
||
if (!tool_def.contains("function")) {
|
||
continue;
|
||
}
|
||
const auto & function = tool_def.at("function");
|
||
std::string name = function.at("name");
|
||
nlohmann::json params = function.contains("parameters") ? function.at("parameters") : nlohmann::json::object();
|
||
|
||
// Build argument parsers
|
||
auto args = eps();
|
||
if (params.contains("properties") && !params["properties"].empty()) {
|
||
auto arg_choice = choice();
|
||
for (const auto & el : params["properties"].items()) {
|
||
const std::string & prop_name = el.key();
|
||
|
||
auto arg_name_parser =
|
||
choice({ literal(prop_name), literal("\"" + prop_name + "\""), literal("'" + prop_name + "'") });
|
||
|
||
auto arg_rule = tool_arg(tool_arg_open(literal(param_key_prefix)) + tool_arg_name(arg_name_parser) +
|
||
literal(param_key_suffix) + tool_arg_value(until(param_closer)) +
|
||
tool_arg_close(literal(param_closer)));
|
||
arg_choice |= arg_rule;
|
||
}
|
||
args = zero_or_more(arg_choice + space());
|
||
}
|
||
|
||
// Build function parser: <function=name>args</function>
|
||
auto tool_parser = tool(tool_open(literal(func_opener) + tool_name(literal(name)) + literal(func_name_suffix)) +
|
||
space() + tool_args(args) + space() + tool_close(literal(func_closer)));
|
||
|
||
tool_choices |= rule("tool-" + name, tool_parser);
|
||
}
|
||
|
||
// Build the section with markers
|
||
auto section =
|
||
parallel_tool_calls ?
|
||
trigger_rule("tool-call", literal(section_start) + space() + one_or_more(tool_choices + space()) +
|
||
literal(section_end)) :
|
||
trigger_rule("tool-call", literal(section_start) + space() + tool_choices + space() + literal(section_end));
|
||
|
||
return force_tool_calls ? section : optional(section);
|
||
}
|
||
|
||
void common_chat_peg_unified_mapper::from_ast(const common_peg_ast_arena & arena,
|
||
const common_peg_parse_result & parse_result_arg) {
|
||
// Call base class to visit all nodes
|
||
common_chat_peg_mapper::from_ast(arena, parse_result_arg);
|
||
|
||
// Flush any pending tool call that was started but never got a name
|
||
// This happens during partial parsing when the tool call is incomplete
|
||
if (pending_tool_call.has_value()) {
|
||
// Transfer any buffered arguments
|
||
if (!args_buffer.empty()) {
|
||
pending_tool_call->arguments = args_buffer;
|
||
}
|
||
// Close any open quotes in buffered args
|
||
if (buffer_needs_closing_quote && !pending_tool_call->arguments.empty()) {
|
||
pending_tool_call->arguments += "\"";
|
||
}
|
||
// Add the incomplete tool call to results
|
||
result.tool_calls.push_back(pending_tool_call.value());
|
||
pending_tool_call.reset();
|
||
}
|
||
}
|
||
|
||
void common_chat_peg_unified_mapper::map(const common_peg_ast_node & node) {
|
||
// First call base class for reasoning/content handling
|
||
common_chat_peg_mapper::map(node);
|
||
|
||
// Handle tool-related tags (unified version supporting both JSON and tagged formats)
|
||
bool is_tool_open = node.tag == common_chat_peg_unified_builder::TOOL_OPEN;
|
||
bool is_tool_close = node.tag == common_chat_peg_unified_builder::TOOL_CLOSE;
|
||
bool is_tool_name = node.tag == common_chat_peg_unified_builder::TOOL_NAME;
|
||
bool is_tool_id = node.tag == common_chat_peg_unified_builder::TOOL_ID;
|
||
bool is_tool_args = node.tag == common_chat_peg_unified_builder::TOOL_ARGS;
|
||
bool is_arg_open = node.tag == common_chat_peg_unified_builder::TOOL_ARG_OPEN;
|
||
bool is_arg_close = node.tag == common_chat_peg_unified_builder::TOOL_ARG_CLOSE;
|
||
bool is_arg_name = node.tag == common_chat_peg_unified_builder::TOOL_ARG_NAME;
|
||
bool is_arg_value = node.tag == common_chat_peg_unified_builder::TOOL_ARG_VALUE;
|
||
bool is_arg_string_value = node.tag == common_chat_peg_unified_builder::TOOL_ARG_STRING_VALUE;
|
||
|
||
if (is_tool_open) {
|
||
// Don't create tool call yet - wait for name to be known
|
||
// This prevents sending incomplete tool calls in streaming mode
|
||
pending_tool_call = common_chat_tool_call();
|
||
current_tool = &pending_tool_call.value();
|
||
arg_count = 0;
|
||
// Clear the arguments buffer for the new tool
|
||
args_buffer.clear();
|
||
needs_closing_quote = false;
|
||
buffer_needs_closing_quote = false;
|
||
}
|
||
|
||
if (is_tool_id && current_tool) {
|
||
auto text = trim_trailing_space(node.text);
|
||
if (text.size() >= 2 && text.front() == '"' && text.back() == '"') {
|
||
text = text.substr(1, text.size() - 2);
|
||
}
|
||
current_tool->id = std::string(text);
|
||
}
|
||
|
||
if (is_tool_name && current_tool) {
|
||
current_tool->name = std::string(trim_trailing_space(node.text));
|
||
// Now that we have the name, we can populate the arguments from the buffer
|
||
if (!args_buffer.empty()) {
|
||
current_tool->arguments = args_buffer;
|
||
args_buffer.clear();
|
||
} else if (current_tool->arguments.empty()) {
|
||
// Initialize arguments if we're using tagged format and no buffered args
|
||
current_tool->arguments = "{";
|
||
}
|
||
// Now that we have the name, add the tool call to the result
|
||
if (pending_tool_call.has_value()) {
|
||
result.tool_calls.push_back(pending_tool_call.value());
|
||
pending_tool_call.reset();
|
||
current_tool = &result.tool_calls.back();
|
||
}
|
||
}
|
||
|
||
if (is_tool_args && current_tool) {
|
||
// For JSON format, the arguments come as a complete JSON object
|
||
// For tagged format, we build up arguments from individual arg_name/arg_value nodes
|
||
// Check if this looks like JSON (starts with {) vs tagged format (starts with <)
|
||
auto text = trim_trailing_space(node.text);
|
||
if (!text.empty() && text.front() == '{') {
|
||
// If we have the tool name, populate directly; otherwise buffer
|
||
if (!current_tool->name.empty()) {
|
||
current_tool->arguments = std::string(text);
|
||
} else {
|
||
args_buffer = std::string(text);
|
||
}
|
||
}
|
||
// If it's tagged format, we ignore this and let arg_name/arg_value build up the JSON
|
||
}
|
||
|
||
if (is_arg_open) {
|
||
// Reset for new argument
|
||
if (!current_tool->name.empty()) {
|
||
needs_closing_quote = false;
|
||
} else {
|
||
buffer_needs_closing_quote = false;
|
||
}
|
||
}
|
||
|
||
if (is_arg_name && current_tool) {
|
||
std::string arg_entry;
|
||
if (arg_count > 0) {
|
||
arg_entry = ",";
|
||
}
|
||
arg_entry += json(trim(node.text)).dump() + ":";
|
||
++arg_count;
|
||
|
||
// If we have the tool name, add directly; otherwise buffer
|
||
if (!current_tool->name.empty()) {
|
||
current_tool->arguments += arg_entry;
|
||
} else {
|
||
if (args_buffer.empty()) {
|
||
args_buffer = "{";
|
||
}
|
||
args_buffer += arg_entry;
|
||
}
|
||
}
|
||
|
||
if ((is_arg_value || is_arg_string_value) && current_tool) {
|
||
std::string value_content = std::string(trim_trailing_space(trim_leading_space(node.text, 1), 1));
|
||
|
||
std::string value_to_add;
|
||
if (!value_content.empty()) {
|
||
// For potential containers, normalize Python-style single quotes to JSON double quotes first
|
||
// This ensures consistent output during both partial and final parsing
|
||
// Note: is_arg_string_value means the schema explicitly declares this as a string type,
|
||
// so we should NOT treat it as a potential container even if it starts with [ or {
|
||
bool is_potential_container = !is_arg_string_value &&
|
||
(value_content[0] == '[' || value_content[0] == '{');
|
||
if (is_potential_container) {
|
||
value_content = normalize_quotes_to_json(value_content);
|
||
}
|
||
|
||
// Try to parse as JSON value (number, bool, null, object, array)
|
||
// For strings, we need special handling to support incremental parsing
|
||
try {
|
||
json parsed = json::parse(value_content);
|
||
if (parsed.is_string()) {
|
||
// For string values, don't add closing quote yet (added by arg_close)
|
||
// This ensures incremental parsing produces monotonic arguments
|
||
std::string escaped = parsed.dump();
|
||
// Remove the trailing quote
|
||
if (!escaped.empty() && escaped.back() == '"') {
|
||
escaped.pop_back();
|
||
}
|
||
value_to_add = escaped;
|
||
if (!current_tool->name.empty()) {
|
||
needs_closing_quote = true;
|
||
} else {
|
||
buffer_needs_closing_quote = true;
|
||
}
|
||
} else {
|
||
// For non-string values (number, bool, null, object, array), add raw value content
|
||
// Using raw content instead of dump() ensures monotonicity for streaming
|
||
// (prevents issues with spaces being removed by dump())
|
||
value_to_add = value_content;
|
||
}
|
||
} catch (...) {
|
||
// JSON parsing failed - content is either incomplete (partial) or not valid JSON
|
||
// Note: potential containers were already normalized above, so value_content
|
||
// already has double quotes if it started with [ or {
|
||
|
||
if (node.is_partial && is_potential_container) {
|
||
// During incremental parsing, if it looks like a JSON container, don't wrap in quotes yet
|
||
// and don't escape. Just pass through the (already normalized) content.
|
||
value_to_add = value_content;
|
||
} else {
|
||
// Not valid JSON and NOT a potential partial container - treat as string value
|
||
// Add opening quote if not already in a string
|
||
if (!current_tool->name.empty()) {
|
||
if (!needs_closing_quote) {
|
||
value_to_add = "\"";
|
||
needs_closing_quote = true;
|
||
}
|
||
} else {
|
||
if (!buffer_needs_closing_quote) {
|
||
value_to_add = "\"";
|
||
buffer_needs_closing_quote = true;
|
||
}
|
||
}
|
||
// Escape special characters in the string content
|
||
std::string escaped = json(value_content).dump();
|
||
// Remove the surrounding quotes from the escaped string
|
||
if (escaped.size() >= 2 && escaped.front() == '"' && escaped.back() == '"') {
|
||
escaped = escaped.substr(1, escaped.size() - 2);
|
||
}
|
||
value_to_add += escaped;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we have the tool name, add directly; otherwise buffer
|
||
if (!current_tool->name.empty()) {
|
||
current_tool->arguments += value_to_add;
|
||
} else {
|
||
if (args_buffer.empty()) {
|
||
args_buffer = "{";
|
||
}
|
||
args_buffer += value_to_add;
|
||
}
|
||
}
|
||
|
||
if (is_arg_close && current_tool) {
|
||
if (!current_tool->name.empty()) {
|
||
if (needs_closing_quote) {
|
||
current_tool->arguments += "\"";
|
||
needs_closing_quote = false;
|
||
}
|
||
} else {
|
||
if (buffer_needs_closing_quote) {
|
||
if (args_buffer.empty()) {
|
||
args_buffer = "{";
|
||
}
|
||
args_buffer += "\"";
|
||
buffer_needs_closing_quote = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (is_tool_close && current_tool) {
|
||
if (!current_tool->name.empty()) {
|
||
if (needs_closing_quote) {
|
||
current_tool->arguments += "\"";
|
||
needs_closing_quote = false;
|
||
}
|
||
// Close the arguments object if using tagged format
|
||
if (!current_tool->arguments.empty() && current_tool->arguments.back() != '}') {
|
||
current_tool->arguments += "}";
|
||
}
|
||
// If we have a pending tool call that wasn't added yet, add it now
|
||
if (pending_tool_call.has_value()) {
|
||
result.tool_calls.push_back(pending_tool_call.value());
|
||
pending_tool_call.reset();
|
||
}
|
||
} else {
|
||
// We're closing a tool without a name - flush the buffer
|
||
if (!args_buffer.empty()) {
|
||
current_tool->arguments = args_buffer;
|
||
args_buffer.clear();
|
||
}
|
||
if (buffer_needs_closing_quote) {
|
||
current_tool->arguments += "\"";
|
||
buffer_needs_closing_quote = false;
|
||
}
|
||
// Close the arguments object if using tagged format
|
||
if (!current_tool->arguments.empty() && current_tool->arguments.back() != '}') {
|
||
current_tool->arguments += "}";
|
||
}
|
||
// Don't add to result if no name - this prevents incomplete tool calls
|
||
pending_tool_call.reset();
|
||
}
|
||
}
|
||
}
|