serve/glmtools.py: GLM's own tool protocol, beside kimitools - #61
serve/glmtools.py: GLM's own tool protocol, beside kimitools#61hendrikras wants to merge 3 commits into
Conversation
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.
|
Independent third-party validation at the current PR head (
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. |
|
One concrete CI follow-up to the validation above, checked against the unchanged head Would you prefer a small follow-up that makes this a required, CPU-only CI check? Suggested scope:
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. |
Follow-up to sqliteai#61 (see the ad-hoc validation summary and @mfethe1's proposal): the three-check oracle in `tests/serve/test_glm_upstream.py` — our `<tool_call>`/`<arg_key>`/`<arg_value>` rendering vs GLM's own `chat_template.jinja` — becomes a required, CPU-only CI job instead of an optional local check that skips without a model checkout. - Vendors `chat_template.jinja` at `tests/serve/glm_upstream/` from `zai-org/GLM-5.3-Flash` @ `eb9eb208eb0d988989d07a6a12d0fdeb5f52574a` with full provenance and license metadata in that directory's README.md. Licensing note: the template lives in the HF model repo (tagged MIT; the MIT notice is reproduced in the README) — the GitHub code repo `zai-org/GLM-5` is Apache-2.0 but does not ship the file. - Adds `requirements-test.txt` pinning `Jinja2==3.1.6`; the new `glm_upstream_oracle` CI job installs it and runs the oracle with `CI_GLM_ORACLE_STRICT=1`, which turns every skip path (missing jinja2, missing template, unresolved markers) into a failure and asserts all three checks ran — the job cannot be green because it did nothing. - Local dev is unchanged in spirit: `tests/run.sh` now defaults to the vendored template (runs out of a fresh clone) and still skips loudly if jinja2/uv is absent; `GLM_DIR` still overrides with a real release directory.
Vendored template vs pinned download
Rationale:
The vendored file is taken from zai-org/GLM-5.3-Flash @ eb9eb208eb0d988989d07a6a12d0fdeb5f52574a (Hugging Face). The directory’s README.md includes:
A one‑liner that this is used only with FakeEngine in CI; no real GLM model or weights are involved. This gives clear provenance and respects the license as stated where the file actually comes from (the model repo). Pinning Jinja2
Rationale:
Strict mode: no silent skips in CI
In this mode:
Local development behavior is intentionally softer:
This split keeps CI strict while preserving a convenient local workflow. ScopeThis is narrow infrastructure to support the tool‑protocol validation from #61. It does not:
It supports the narrower claim that the PR addresses the reported tool‑protocol behavior under tests, as per the original ad‑hoc validation. Disclaimer: GLM-5.3-flash was used to implement, perplexity.ai was used to draft this message. |
|
Thanks @hendrikras — I independently exercised the revised strict oracle at Positive control: Negative controls, each in a separate subprocess:
The last two are deliberate in-memory fault injections, not modifications to production code. These checks confirm the designated oracle fails closed on the prerequisite/registration cases we discussed. Provenance: I fetched One small license-notice correction: the LICENSE fetched from that same revision begins Scope: independent CPU-only renderer/template validation through FakeEngine. I ran the targeted oracle, not the full canonical suite or the hosted Python 3.12 CI job; this is not real-model compatibility, GPU validation, or a merge-readiness claim. No weights were loaded. Disclosure: validation and this reply were performed by an AI agent under human authorization. |
…template - Add tests/serve/glm_upstream/LICENSE copied from zai-org/GLM-5.3-Flash @ eb9eb208eb0d988989d07a6a12d0fdeb5f52574a. - Update tests/serve/glm_upstream/README.md to: - Reference LICENSE verbatim. - Use the exact copyright line: "Copyright (c) 2026 Z.AI Co., Ltd". No change to oracle behavior; this addresses the license-notice correction raised in the independent strict-oracle validation.
|
added LICENCE file and edited README.md file |
|
I cannot merge this as-is because the GLM tool parser loses argument types. I reproduced this by rendering a call with Please preserve argument types according to the tool schema, including declared string values, and add regression coverage before merging. The existing GLM rendering oracle passes but does not cover this parser issue. |
|
Confirming Marco's report on Reproduction. Rendering with Every non-string arrives as its own source text. Why. This is not a lossy parse — it is a protocol fact. GLM's grammar states an argument as What I'd suggest, in the shape the repo already uses:
One judgement call worth flagging: I convert only when a type was declared. An undeclared argument keeps the text it was, because guessing types here is how a zip code Verified on the full matrix you listed — number, boolean, array, object, null all round-trip, and a declared string
Happy to open this as a PR against your branch, @hendrikras, or leave it for you — your call, I don't want to duplicate work you've already started. |
Integrate GLM tool protocol work from PR sqliteai#61, validate rendering against the vendored template, preserve schema argument types, and reject unsupported tool history. Report token-limit exhaustion without mislabeling natural stops. Co-authored-by: hendrikras <3663335+hendrikras@users.noreply.github.com>
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
toolsrequest 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.
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.