Skip to content
Closed
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
170 changes: 170 additions & 0 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,153 @@ static common_chat_params common_chat_params_init_ministral_3(const common_chat_
return data;
}

static common_chat_params common_chat_params_init_tagged_thinking_tools(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;

const bool has_tools = inputs.tools.is_array() && !inputs.tools.empty();
const bool extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;

data.supports_thinking = true;
data.thinking_start_tag = "<think>";
data.thinking_end_tag = "</think>";
data.prompt = common_chat_template_direct_apply(tmpl, inputs);
data.generation_prompt = common_chat_template_generation_prompt(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
data.strict_eof_on_complete = true;
data.preserved_tokens = {
"<think>",
"</think>",
"<tool_call>",
"</tool_call>",
};

if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
return data;
}

auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
auto generation_prompt = p.literal(data.generation_prompt);
auto tool_choices = p.choice();

foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
const auto name = function.at("name").get<std::string>();

auto arg_choices = p.choice();
foreach_parameter(function, [&](const std::string & prop_name, const json & prop_schema, bool) {
const bool is_string = prop_schema.value("type", "") == "string";

auto value_until = [&](const std::string & marker) {
return is_string ?
p.tool_arg_string_value(p.until(marker)) :
p.tool_arg_value(p.until(marker));
};

auto arg_open = p.tool_arg_open(
p.literal("<parameter=") + p.tool_arg_name(p.literal(prop_name)) + p.literal(">"));

auto arg = p.tool_arg(arg_open + p.choice({
p.literal("\n") + value_until("\n</parameter>") + p.tool_arg_close(p.literal("\n</parameter>")),
value_until("</parameter>") + p.tool_arg_close(p.literal("</parameter>")),
}));
arg_choices |= p.rule("tool-" + name + "-arg-" + prop_name, arg);
});

auto args = p.tool_args(p.zero_or_more(arg_choices + p.space()));
auto func = p.tool(
p.tool_open(p.literal("<function=") + p.tool_name(p.literal(name)) + p.literal(">")) +
p.space() + args + p.space() +
p.tool_close(p.literal("</function>")));

tool_choices |= p.rule("tool-" + name, func);
});

auto tool_call =
p.literal("<tool_call>") + p.space() + tool_choices + p.space() + p.literal("</tool_call>");

const int max_calls = inputs.parallel_tool_calls ? -1 : 1;
auto tool_calls = p.repeat(tool_call + p.space(), 1, max_calls);

p.rule("tool-tail", [&]() {
auto content_until_boundary = p.content(p.until_one_of({"<tool_call>", "</think>"}));
return p.choice({
p.literal("</think>") + p.space() + p.content(p.rest()),
content_until_boundary + p.choice({
p.ref("tool-call"),
p.literal("</think>") + p.space() + p.content(p.rest()),
p.content(p.rest()),
}),
});
});
auto tool_suffix = p.trigger_rule("tool-call", tool_calls + p.ref("tool-tail"));

auto outside = p.choice({
p.content(p.until("<tool_call>")) + tool_suffix,
p.content(p.rest()),
});

if (!extract_reasoning || !inputs.enable_thinking) {
return generation_prompt + outside;
}

// Classify the emitted completion rather than the generation prompt. Some
// templates prefill "<think>\n", but Qwen-style models may omit the
// matching close tag for tool-call and final-answer turns. In practice:
// - emitted </think> closes reasoning;
// - emitted <tool_call> makes any preceding text reasoning;
// - no emitted reasoning/tool tags means final assistant content.
auto reasoning_before_boundary =
p.reasoning(p.until_one_of({"</think>", "\n</think>", "\n<tool_call>"})) +
p.optional(p.literal("\n"));

auto reasoning_until_tool =
p.peek(p.until_one_of({"</think>", "\n</think>", "\n<tool_call>"}) + p.literal("\n<tool_call>")) +
reasoning_before_boundary +
tool_suffix;

auto reasoning_until_close =
p.peek(p.until_one_of({"</think>", "\n</think>", "\n<tool_call>"}) + p.choice({
p.literal("</think>"),
p.literal("\n</think>"),
})) +
reasoning_before_boundary +
p.literal("</think>") + p.space() + outside;

auto reasoning_then_boundary = p.choice({
tool_suffix,
reasoning_until_tool,
reasoning_until_close,
p.content(p.rest()),
});

auto generated_reasoning = p.literal("<think>") + p.space() + reasoning_then_boundary;

return generation_prompt + p.choice({
generated_reasoning,
reasoning_then_boundary,
});
});

data.parser = parser.save();
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
if (data.grammar_lazy) {
data.grammar_triggers = {
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<tool_call>" },
};
}

return data;
}

static common_chat_params common_chat_params_init_gpt_oss(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;
Expand Down Expand Up @@ -2398,6 +2545,20 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_ministral_3(tmpl, params);
}

// Tagged thinking/tool protocol: reasoning uses <think>...</think> and tools use
// <tool_call><function=name><parameter=arg>...</parameter></function></tool_call>.
if (params.tools.is_array() && !params.tools.empty() &&
params.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE &&
src.find("<think>") != std::string::npos &&
src.find("</think>") != std::string::npos &&
src.find("<tool_call>") != std::string::npos &&
src.find("</tool_call>") != std::string::npos &&
src.find("<function=") != std::string::npos &&
src.find("<parameter=") != std::string::npos) {
LOG_DBG("Using specialized template: tagged thinking tools\n");
return common_chat_params_init_tagged_thinking_tools(tmpl, params);
}

// GPT-OSS - has unique channel-based structure that needs dedicated handler
if (src.find("<|channel|>") != std::string::npos) {
LOG_DBG("Using specialized template: GPT-OSS\n");
Expand Down Expand Up @@ -2707,6 +2868,15 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
common_peg_parse_context ctx(effective_input, flags);
auto result = parser.parse(ctx);

// Streaming parses are lenient so an unterminated prefix can still produce deltas.
// Some complete parses intentionally use EOF as a boundary; retry those strictly
// when requested so "until"/"rest" parsers can commit at the actual end.
if (!is_partial && params.strict_eof_on_complete && result.need_more_input()) {
flags = common_peg_parse_flags(flags & ~COMMON_PEG_PARSE_FLAG_LENIENT);
ctx = common_peg_parse_context(effective_input, flags);
result = parser.parse(ctx);
}

if (result.fail()) {
// During partial parsing, return partial results if any AST nodes were captured
// This allows streaming to work correctly for formats like FUNC_MARKDOWN_CODE_BLOCK
Expand Down
9 changes: 7 additions & 2 deletions common/chat.h
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ struct common_chat_params {
std::vector<std::string> preserved_tokens;
std::vector<std::string> additional_stops;
std::string parser;
// If a complete parse reports NEED_MORE in lenient streaming mode, retry without
// leniency so EOF can be used as a real boundary by parsers that opt in.
bool strict_eof_on_complete = false;
common_chat_msg_delimiters message_delimiters;
};

Expand All @@ -294,11 +297,13 @@ struct common_chat_parser_params {
bool is_continuation = false;
bool echo = false; // Include assistant prefilled msg in output
bool debug = false; // Enable debug output for PEG parser
bool strict_eof_on_complete = false;
common_peg_arena parser = {};
common_chat_parser_params() = default;
common_chat_parser_params(const common_chat_params & chat_params) {
format = chat_params.format;
generation_prompt = chat_params.generation_prompt;
format = chat_params.format;
generation_prompt = chat_params.generation_prompt;
strict_eof_on_complete = chat_params.strict_eof_on_complete;
}
};

Expand Down
Loading