Skip to content

serve/glmtools.py: GLM's own tool protocol, beside kimitools - #61

Open
hendrikras wants to merge 1 commit into
sqliteai:mainfrom
hendrikras:feature/glm5.3-tool-support
Open

serve/glmtools.py: GLM's own tool protocol, beside kimitools#61
hendrikras wants to merge 1 commit into
sqliteai:mainfrom
hendrikras:feature/glm5.3-tool-support

Conversation

@hendrikras

Copy link
Copy Markdown

A container served from chat.json could declare no tools unless its tokenizer carried Kimi K2's five control tokens, so GLM-5.3-Flash — whose tokenizer instead carries <tool_call>, <arg_key> and <arg_value> as single specials — answered a tools request with a 400 naming the field. That refusal was correct about the declarative format and wrong about the container: the grammar was there, in specials.json and in the release's own chat_template.jinja, and four prefix/suffix strings were never going to be where it lived.

So the protocol gets the same thing K2's got: a module of its own, beside xtml.py and kimitools.py, and an oracle that diffs its rendering against the template that defines it.

  • glmtools.detect() requires all nine markers to be single tokens, the same all-or-none gate kimitools keeps: half of this XML renders as ordinary text, so a partial set is a different protocol, not a smaller one.
  • The grammars differ where it matters. A Kimi call is an id and a JSON block inside a section; a GLM call is flat XML, the name after the opening tag and one <arg_key>/<arg_value> pair per argument, no id — so calls come back named by position. A Kimi result is a system turn named for the tool; a GLM result is an <|observation|> turn wrapping <tool_response> blocks, and consecutive results share one opener, which chatfmt now emits by looking back a message.
  • ChatFormat carries which protocol resolved and branches on it; the container with neither is still refused by name, now naming both.
  • PlainParser takes the tool reader from the caller, and server.new_parser hands it glmtools.ToolParser on a GLM container. api.py maps GlmToolError to a 400 the way it maps KimiToolError.

The security boundary survives unchanged: the tags go out as markup segments, the signatures and argument values as plain text, so a caller's JSON cannot forge a control token the message content is not.

tests/serve/test_glm_upstream.py is the oracle, GLM_DIR naming the release, the way K2_DIR does for test_chatfmt_upstream; run.sh grew the matching check. The template needs jinja2's loopcontrols extension — it uses {% break %}.

Docs updated where they said GLM is refused: README, CLAUDE.md, docs/SERVE.md, docs/GLM.md.

A container served from chat.json could declare no tools unless its
tokenizer carried Kimi K2's five control tokens, so GLM-5.3-Flash — whose
tokenizer instead carries <tool_call>, <arg_key> and <arg_value> as single
specials — answered a `tools` request with a 400 naming the field. That
refusal was correct about the declarative format and wrong about the
container: the grammar was there, in specials.json and in the release's
own chat_template.jinja, and four prefix/suffix strings were never going
to be where it lived.

So the protocol gets the same thing K2's got: a module of its own, beside
xtml.py and kimitools.py, and an oracle that diffs its rendering against
the template that defines it.

- glmtools.detect() requires all nine markers to be single tokens, the
  same all-or-none gate kimitools keeps: half of this XML renders as
  ordinary text, so a partial set is a different protocol, not a smaller
  one.
- The grammars differ where it matters. A Kimi call is an id and a JSON
  block inside a section; a GLM call is flat XML, the name after the
  opening tag and one <arg_key>/<arg_value> pair per argument, no id — so
  calls come back named by position. A Kimi result is a system turn named
  for the tool; a GLM result is an <|observation|> turn wrapping
  <tool_response> blocks, and consecutive results share one opener, which
  chatfmt now emits by looking back a message.
- ChatFormat carries which protocol resolved and branches on it; the
  container with neither is still refused by name, now naming both.
- PlainParser takes the tool reader from the caller, and server.new_parser
  hands it glmtools.ToolParser on a GLM container. api.py maps
  GlmToolError to a 400 the way it maps KimiToolError.

The security boundary survives unchanged: the tags go out as markup
segments, the signatures and argument values as plain text, so a caller's
JSON cannot forge a control token the message content is not.

tests/serve/test_glm_upstream.py is the oracle, GLM_DIR naming the
release, the way K2_DIR does for test_chatfmt_upstream; run.sh grew the
matching check. The template needs jinja2's loopcontrols extension — it
uses {% break %}.

Docs updated where they said GLM is refused: README, CLAUDE.md,
docs/SERVE.md, docs/GLM.md.
@hendrikras hendrikras mentioned this pull request Sep 3, 2026
@mfethe1

mfethe1 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Independent third-party validation at the current PR head (e354221517159f5ffb30ec5d26723fed54da812b):

  • PR: 263 repository unittests passed, 5 skipped
  • Base c66c7b3febf261590abea540e74602d8e7759d62: 244 passed, 4 skipped
  • Exact test-ID diff: 19 added, 0 removed
  • Five targeted mutations were detected by the tests
  • A three-case oracle using the fetched upstream GLM tool template plus this PR's parser/renderer passed

Scope: this is ad-hoc Tier B software validation, not canonical CI green. The oracle executes the real PR parser/renderer but uses a synthetic tokenizer/engine; no licensed real GLM model or container was run. So this supports the narrower claim that the PR addresses the reported tool-protocol behavior under tests, not end-to-end model compatibility or merge readiness.

Disclosure: the validation was executed and analyzed by an AI agent under my direction; I reviewed the evidence and am sharing the scoped result.

@mfethe1

mfethe1 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

One concrete CI follow-up to the validation above, checked against the unchanged head e354221517159f5ffb30ec5d26723fed54da812b: the upstream-template oracle can run without model weights. tests/serve/test_glm_upstream.py needs the release's chat_template.jinja and Jinja2; it uses FakeEngine for the tokenizer/engine boundary. Both the test and tests/run.sh currently allow the check to skip when its prerequisites are absent.

Would you prefer a small follow-up that makes this a required, CPU-only CI check? Suggested scope:

  • Fetch only the template from an immutable upstream revision and verify its SHA-256 (or vendor it with appropriate license/provenance, if preferred).
  • Supply GLM_DIR and a pinned Jinja2 dependency, then run tests.serve.test_glm_upstream.
  • Require all three oracle tests to execute with zero skips; missing dependencies/template or unresolved markers must fail this designated CI job rather than produce a green skipped run.

That would preserve the existing optional local workflow while making the independent grammar comparison repeatable in CI. This is a proposal, not a claim that CI has been wired or run. The earlier result remains ad-hoc verification passed; this is not canonical suite green, and this template/FakeEngine check does not establish real-model compatibility.

Happy to keep it as a narrow follow-up rather than expand this PR. Is a pinned download or a licensed vendored fixture preferable?

Disclosure: AI-agent-assisted source review and drafting, posted with human authorization.

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.

2 participants