Skip to content

fix(server): gemma4 bare call: tool extract for OpenAI chat - #328

Closed
bakon11 wants to merge 2 commits into
mudler:mainfrom
bakon11:feat/serve-gemma4-bare-toolcall
Closed

fix(server): gemma4 bare call: tool extract for OpenAI chat#328
bakon11 wants to merge 2 commits into
mudler:mainfrom
bakon11:feat/serve-gemma4-bare-toolcall

Conversation

@bakon11

@bakon11 bakon11 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Gemma-4 free-form tool use (no structural tags / specials stripped at detokenize) often emits bare call:NAME{ARGS} instead of wrapped <|tool_call>… bodies. The engine-backed gemma4 parser needs special-token IDs; OpenAI /v1/chat/completions then returned plain content and clients (e.g. Hermes) never executed tools.

Changes

  • Text-seam Gemma4ToolParser: extract bare call:NAME{ARGS} (stream + non-stream), still supports wrapped blocks
  • OpenAI chat: for --tool-call-parser gemma4, prefer text-seam MakeToolParser over engine path so detokenized/free-form bodies become real tool_calls
  • USAGE: document the gemma4 dual surface

Test plan

  • Lab: non-stream + stream tools=[{terminal}]finish_reason=tool_calls, args {"command":"date"}
  • CI gates
  • Regression: wrapped <|tool_call>call:… still extracts

Notes

Small serve/parser fix; independent of ROCm #317. Does not change structural-tag defaults.

FOLLOWING_AGENTS_PROTOCOL

Free-form Gemma-4 (structural tags off / detokenized specials) often emits
bare `call:NAME{ARGS}` instead of wrapped <|tool_call>… bodies. The engine
parser needs special-token IDs; OpenAI chat now prefers the text-seam gemma4
tool parser and accepts both wrapped and bare call surfaces so clients get
real tool_calls instead of plain content.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Hermes:grok-4.5 [Hermes]
bakon11 pushed a commit to bakon11/vllm.cpp that referenced this pull request Aug 11, 2026
Lab live :8010 stack. Serve/parser portion also filed as mudler mudler#328.
RandomSample parallel already on mudler#234; keep local park in sync.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Hermes:grok-4.5 [Hermes]

@localai-org-maint-bot localai-org-maint-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@bakon11 Please add automated stream and non-stream regression coverage before this parser/dispatch change merges. In particular, exercise wrapped calls, bare calls, ordinary prose containing call:, nested braces/string delimiters, incomplete calls, and a marker split across streaming chunks. The last case matters because the new no-marker branch immediately emits all current text; a partial ca/call: prefix can otherwise leak as content before the next chunk turns it into a tool call. The PR test plan currently leaves wrapped regression unchecked and the diff changes parsing behavior without a checked-in test.

The bare `call:` streaming path emitted any no-marker text immediately, so a
split `<|tool` / `call` prefix leaked as content before the next chunk completed
the marker (CI: start tag split across chunks). Buffer trailing partial
`<|tool_call>` and `call:` prefixes on the plain path; do not treat incomplete
bare calls (no `{`) as content. Move ScanBareCalls into the anon namespace and
share StripTrailingToolMarkerPrefixes with ScanBlocks.

Adds maint-bot regressions: wrapped, bare, prose `call:`/callback:, nested
braces+delims, incomplete, stream splits for start-tag and bare `call:`.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Hermes:grok-4.5 [Hermes]
@bakon11

bakon11 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@localai-org-maint-bot Addressed CHANGES_REQUESTED:

  1. Bugfix — streaming no-marker branch no longer emits trailing partial <|tool_call> / bare call: prefixes (the CI failure gemma4 stream: start tag split across chunks buffers cleanly). Incomplete bare calls without { are buffered, not leaked as content.
  2. Regressions in tests/.../test_gemma4.cpp:
    • non-stream: wrapped, bare complete, prose prefix, callback: prose (not a tool), nested braces + string delims, incomplete bare
    • stream: bare complete, call: split across chunks, prose-then-bare, incomplete call buffer, start-tag split (extra coverage)

Local: test_tool_parser_gemma4 69/69 (was 58 + new cases; previously 1 fail on tip).

@localai-bot

Copy link
Copy Markdown
Collaborator

Landed on main (as one commit authored to you — the branch's two commits had to be squashed because check-doc-checkpoint is a per-commit obligation and the second moved the parser without its docs/USAGE.md line; Don Mirror's marker-buffering work is credited in the commit body). The bare-call: scanner itself is a good catch and went in as written.

One repair applied on landing — worth knowing about because it is silent and easy to reintroduce:

StripTrailingToolMarkerPrefixes was widened from <|tool_call> prefixes to call: prefixes unconditionally. Since kCall is "call:", that buffers a trailing c, ca, cal or call out of streamed content. This seam has no end-of-stream flushextract_tool_calls_streaming emits content.substr(streamed_content_len_) and nothing ever calls back to release the tail — so anything held when the stream ends is simply lost.

Concretely: a reply ending in "topic", "music", "specific" or "logic" loses its last letter. It was <|tool_call> before, which starts with < and effectively never ends a message, so widening it changed the risk class.

The fix reuses your own rule. ScanBareCalls already refuses to treat call: as a marker when the preceding character is alphanumeric, _ or /; the buffer now applies the same test, so a partial inside a word streams through while a genuine split marker after whitespace is still buffered:

const std::size_t at = content_out.size() - k;
if (at > 0) {
  const char prev = content_out[at - 1];
  if (std::isalnum(static_cast<unsigned char>(prev)) || prev == '_' || prev == '/') {
    break;  // part of a word, not the start of a bare call
  }
}
content_out.erase(at);

Three regressions added, test_tool_parser_gemma4 72/72. They are mutation-checked: removing the boundary guard fails two of them on the swallowed trailing letter.

Left open, recorded rather than fixed: bare-call detection is inherently heuristic — prose of the form ... call:name{...} becomes a tool call, and the args text is consumed out of content. That is the premise of the feature, not a defect in this PR, and it is only reachable with --tool-call-parser gemma4 and tools enabled. If you want it tightened later, matching the parsed name against request.tools would remove nearly all false positives.

Reviewed and landed with Claude Code.

localai-bot pushed a commit that referenced this pull request Aug 11, 2026
Free-form / detokenized Gemma-4 output often carries the tool body without the
`<|tool_call>` specials, so the wrapped-only scanner returned it as plain
content and the request never produced tool_calls. The gemma4 parser gains a
bare `call:NAME{ARGS}` scanner (brace depth, `<|"|>` string-aware) used when no
wrapped marker is present, in both the complete and streaming paths, and OpenAI
chat routes `gemma4` to the text seam so both shapes extract.

The branch's two commits land as one because check-doc-checkpoint is a
per-commit obligation and the second moved the parser without its USAGE.md
line; the marker-buffering work and its regressions are Don Mirror's, unchanged
apart from the repair below. docs/USAGE.md keeps main's parser counts (41 names
/ 37 families) and `muse_glimmer` in the reasoning list, both of which landed
after this branch forked, and takes the PR's gemma4 sentence.

REPAIR APPLIED ON LANDING: the partial-marker buffer was widened from
`<|tool_call>` prefixes to `call:` prefixes unconditionally, so a streamed
reply ending in "c", "ca", "cal" or "call" had those characters held back — and
this seam has NO end-of-stream flush, so held-back text is never emitted. Any
reply ending in "topic", "music" or "specific" would silently lose its last
letter. The buffer now applies the same boundary rule ScanBareCalls already
uses (not preceded by an identifier character or '/'), so a partial inside a
word streams through while a genuine split marker after whitespace is still
buffered.

  test_tool_parser_gemma4   72/72   (69 before; 3 added)

The three added cases are mutation-checked: removing the boundary guard fails
two of them on the swallowed trailing letter.

KNOWN LIMIT, not introduced here and not closed here: bare-call detection is
heuristic. Text of the form "... call:name{...}" in ordinary prose becomes a
tool call. The wrapped path is unambiguous; the bare path is a lab/Hermes
affordance, reachable only when tools are enabled with --tool-call-parser
gemma4.

Refs #328.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [ClaudeCode]
@localai-bot

Copy link
Copy Markdown
Collaborator

Landed on main as bc6bd88c456d, authored to you.

Closing manually rather than by merge because the branch's commits were squashed into one, so the head SHA here never becomes reachable from main and GitHub cannot auto-close it. The squash was forced by check-doc-checkpoint, which runs per non-merge commit (git rev-list --no-merges): this branch split code from the public document that had to change with it, so at least one commit owed a docs/ line it did not carry, and the range gate went red. Squashing with --author keeps your attribution on the landing commit.

The commit message records what changed on landing and why. Nothing was dropped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants