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
26 changes: 22 additions & 4 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3148,7 +3148,13 @@ static common_chat_params common_chat_params_init_muse_glimmer(const common_chat
auto analysis = p.ref("analysis");

auto recipient = p.optional(p.literal(" to=user"));
auto final_msg = p.rule("final", recipient + p.literal("<|message|>") + p.content(p.until("<|eot|>")));
// A user-facing turn nominally ends at <|eot|>. In practice the model also ends
// it with <|eom|> and continues into a tool call, or simply appends the tool
// markup to the prose with no delimiter at all. Stopping only at <|eot|> makes
// this greedy: the tool call is swallowed into content and never parsed.
auto final_msg = p.rule("final",
recipient + p.literal("<|message|>") +
p.content(p.until_one_of({ "<|eot|>", "<|eom|>", "<atem:function_calls>" })));

if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
auto string_value = p.ac(
Expand Down Expand Up @@ -3186,13 +3192,20 @@ static common_chat_params common_chat_params_init_muse_glimmer(const common_chat
args = p.zero_or_more(arg_choice + p.space());
}

// The recipient header is emitted inconsistently: canonically the call is
// " to=<tool><|message|><atem:function_calls>", but the model also appends
// the markup straight onto a user-facing message with no header at all.
// Accept either so the call is parsed rather than left in content.
auto call_header = p.optional(p.literal(" to=") + p.until("<|message|>") + p.literal("<|message|>"));

auto tool_parser = p.tool(
p.tool_open(p.literal(" to=") + p.until("<|message|>") +
p.literal("<|message|><atem:function_calls>") + p.space() +
p.tool_open(call_header +
p.literal("<atem:function_calls>") + p.space() +
p.literal("<atem:invoke name=\"") + p.tool_name(p.literal(name)) + p.literal("\">") + p.space())
<< p.tool_args(args)
<< p.tool_close(p.literal("</atem:invoke>") + p.space() + p.literal("</atem:function_calls>")));


tool_choice |= p.rule("tool-" + name, tool_parser);
});

Expand All @@ -3204,7 +3217,12 @@ static common_chat_params common_chat_params_init_muse_glimmer(const common_chat
if (inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED) {
return p.zero_or_more(start + analysis) + start + tool_calls;
}
return p.zero_or_more(start + analysis) + start + (tool_calls | final_msg);
// A turn may be content, tool calls, or content followed by tool calls. The
// delimiters between the two halves are unreliable — sometimes <|eom|> and a
// fresh <|start|>assistant header, sometimes nothing at all — so treat both
// as optional rather than dropping the call.
auto trailing_calls = p.optional(p.optional(p.literal("<|eom|>")) + p.optional(start) + tool_calls);
return p.zero_or_more(start + analysis) + start + (tool_calls | (final_msg + trailing_calls));
}

return p.zero_or_more(start + analysis) + start + final_msg;
Expand Down
49 changes: 48 additions & 1 deletion tools/server/server-common.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,54 @@ json oaicompat_chat_params_parse(
auto tools = json_value(body, "tools", json());
auto has_tools = tools.is_array() && !tools.empty();
auto stream = json_value(body, "stream", false);
auto tool_choice = json_value(body, "tool_choice", std::string("auto"));

// `tool_choice` is either a string ("auto" / "none" / "required") or, to force one
// specific function, an object: {"type":"function","function":{"name":"..."}}.
//
// json_value() only understands the string form: given the object it logs a type
// warning and silently falls back to "auto". That downgrade is invisible to the
// caller and, for templates whose grammar is lazy under "auto", means nothing
// constrains the model to emit a tool call at all.
//
// Named choice is "call exactly this function", so narrow `tools` to that one entry
// and treat it as "required". The grammar then admits only that call, which is the
// OpenAI semantics and needs no per-template support.
Comment on lines +924 to +926

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.

I am not privy to internal discussions, but AFAIK vLLM does not do this. In vLLM, all tools are kept in the context and the specified tool is forced via grammar constrained decoding. Filtering tools also has negative connotations for prefix caching, as two requests with the same system prompt, same tool definitions, but different tool_choice will trigger reprocessing of the system prompt.

std::string tool_choice = "auto";
if (body.contains("tool_choice") && !body.at("tool_choice").is_null()) {
const auto & tc = body.at("tool_choice");
if (tc.is_string()) {
tool_choice = tc.get<std::string>();
} else if (tc.is_object()) {
const std::string tc_type = json_value(tc, "type", std::string());
if (tc_type != "function") {
throw std::invalid_argument("Invalid tool_choice: object form must have type \"function\"");
}
if (!tc.contains("function") || !tc.at("function").is_object()) {
throw std::invalid_argument("Invalid tool_choice: missing \"function\" object");
}
const std::string tc_name = json_value(tc.at("function"), "name", std::string());
if (tc_name.empty()) {
throw std::invalid_argument("Invalid tool_choice: missing function name");
}
if (!has_tools) {
throw std::invalid_argument("tool_choice names a function but no tools were provided");
}

json filtered = json::array();
for (const auto & tool : tools) {
if (tool.contains("function") && json_value(tool.at("function"), "name", std::string()) == tc_name) {
filtered.push_back(tool);
}
}
if (filtered.empty()) {
throw std::invalid_argument("tool_choice names function \"" + tc_name + "\" which is not in tools");
}
tools = filtered;
tool_choice = "required";
} else {
throw std::invalid_argument("Invalid tool_choice: expected a string or an object");
}
}

if (!opt.use_jinja) {
if (has_tools) {
Expand Down