A drop-in replacement chat template for google/gemma-4-31B-it tuned for open-source agentic coding harnesses. A developer has released a public fork of Google's Gemma 4 chat template that fixes four critical bugs affecting open-source agentic coding harnesses. The fork addresses issues including corrupted tool call arguments from nested JSON braces, dropped prior-turn reasoning, disabled thinking mode, and Python "None" strings appearing in place of JSON null values. The patched template ships with a conformance test suite and is designed for use with harnesses like OpenCode and Pi. | { --------------------------------------------------------------------- | | | custom pub chat template gemma4.jinja | | | ===================================== | | | A public, harness-friendly fork of Google's Gemma 4 chat template, | | | tuned for open-source agentic coding harnesses like: | | | - anomalyco/opencode https://github.com/anomalyco/opencode | | | - earendil-works/pi https://github.com/earendil-works/pi | | | - openclaw, OpenHarness, similar Claude-Code-style harnesses | | | WHY THIS FORK EXISTS | | | -------------------- | | | The upstream chat template at google/gemma-4-31B-it is correct for | | | chat use, but four real edge cases bite agentic coding harnesses: | | | 1. tool call.arguments arriving as a JSON string Vercel AI SDK and | | | several OpenAI-compatible adapters serialize this way is silently | | | wrapped in extra braces by the upstream template, producing invalid | | | Gemma 4 DSL like call:fn{{"city":"Tokyo"}} — nested braces, JSON | | | colons, and quoted keys, none of which the model was trained on. | | | Symptom: degraded tool-call accuracy, mysterious arguments collapse | | | to {} on repeated calls. | | | 2. Prior-turn reasoning is dropped from history. The model card says | | | "historical model output should only include the final response," | | | but agentic harnesses doing multi-step tool calls benefit | | | materially from keeping the prior reasoning visible. The Qwen3.6 | | | analogue of this bug is documented at: | | | https://github.com/earendil-works/pi/issues/3325 | | | Symptom on Qwen and Gemma alike : after 2-3 turns, every tool call | | | collapses to arguments: {} even though the model's prior reasoning | | | correctly identified the parameters it needed. | | | 3. enable thinking defaults to FALSE in the upstream template, and | | | most OpenAI-compatible adapters drop unknown request fields: | | | https://github.com/anomalyco/opencode/issues/24264 | | | So the harness ends up with thinking permanently off, agentic | | | tool-call accuracy suffers, and there's no obvious failure signal. | | | 4. JSON null values inside tool call.arguments render as the bare | | | string "None" Python repr of None survives Jinja . Optional | | | fields are very common in coding tools find files, search: | | | pattern=..., language=null and this corrupts the prompt silently. | | | This fork is also forked from a private engineering fork used in | | | internal harnesses; the public copy reuses the same five patches but | | | adds expanded comments, removes references to private design docs, | | | and ships with a self-contained pytest conformance suite. | | | PATCH INVENTORY full details next to each patch site below | | | ------------------------------------------------------------ | | | P1 format argument: emit JSON null instead of bare "None" | | | P2 enable thinking defaults to TRUE | | | P3 tool call.arguments as string: RAISE instead of silent corruption | | | P4 preserve thinking kwarg default TRUE keeps prior <|channel | | | P5 fix HF discussion 62 turn-tag close asymmetry | | | INVARIANT | | | --------- | | | With enable thinking=False AND preserve thinking=False passed | | | explicitly, this template renders byte-for-byte identical to the | | | upstream verbatim template on every input that doesn't hit P1, P3, | | | or P5's bug sites. The conformance suite at | | | tests/test custom pub chat template.py | | | locks this in across 21 representative cases. | | | USAGE | | | ----- | | | Server side e.g. vLLM or SGLang : | | | --chat-template /path/to/custom pub chat template gemma4.jinja | | | Harness side: no changes required for the common case. If you need | | | to force defaults off e.g. to match upstream behaviour exactly : | | | { | | | "extra body": { | | | "chat template kwargs": { | | | "enable thinking": false, | | | "preserve thinking": false | | | } | | | } | | | } | | | For opencode-style providers, this maps to the chat template args | | | field in models config; for pi, set thinkingFormat appropriately | | | in the provider's compat block and pi will inject these kwargs. | | | PINS | | | ---- | | | Forked from google/gemma-4-31B-it @ fcf2302760ae9c6e528a8dbba9dd636e56848237 | | | Fork date: 2026-05-22 | | | License: Apache 2.0 same as upstream | | | Maintainer: see repo README | | | --------------------------------------------------------------------- } | | | {%- macro format parameters properties, required, filter keys=false -%} | | | {%- set standard keys = 'description', 'type', 'properties', 'required', 'nullable' -%} | | | {%- set ns = namespace found first=false -%} | | | {%- for key, value in properties | dictsort -%} | | | {%- set add comma = false -%} | | | {%- if not filter keys or key not in standard keys -%} | | | {%- if ns.found first %},{% endif -%} | | | {%- set ns.found first = true -%} | | | {{ key }}:{ | | | {%- if value 'description' -%} | | | description:<|"| {{ value 'description' }}<|"| | | | {%- set add comma = true -%} | | | {%- endif -%} | | | {%- if value 'type' | upper == 'STRING' -%} | | | {%- if value 'enum' -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | enum:{{ format argument value 'enum' }} | | | {%- endif -%} | | | {%- elif value 'type' | upper == 'ARRAY' -%} | | | {%- if value 'items' is mapping and value 'items' -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | items:{ | | | {%- set ns items = namespace found first=false -%} | | | {%- for item key, item value in value 'items' | dictsort -%} | | | {%- if item value is not none -%} | | | {%- if ns items.found first %},{% endif -%} | | | {%- set ns items.found first = true -%} | | | {%- if item key == 'properties' -%} | | | properties:{ | | | {%- if item value is mapping -%} | | | {{- format parameters item value, value 'items' 'required' | default -}} | | | {%- endif -%} | | | } | | | {%- elif item key == 'required' -%} | | | required: | | | {%- for req item in item value -%} | | | <|"| {{- req item -}}<|"| | | | {%- if not loop.last %},{% endif -%} | | | {%- endfor -%} | | | | | | {%- elif item key == 'type' -%} | | | {%- if item value is string -%} | | | type:{{ format argument item value | upper }} | | | {%- else -%} | | | type:{{ format argument item value | map 'upper' | list }} | | | {%- endif -%} | | | {%- else -%} | | | {{ item key }}:{{ format argument item value }} | | | {%- endif -%} | | | {%- endif -%} | | | {%- endfor -%} | | | } | | | {%- endif -%} | | | {%- endif -%} | | | {%- if value 'nullable' %} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | nullable:true | | | {%- endif -%} | | | {%- if value 'type' | upper == 'OBJECT' -%} | | | {%- if value 'properties' is defined and value 'properties' is mapping -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | properties:{ | | | {{- format parameters value 'properties' , value 'required' | default -}} | | | } | | | {%- elif value is mapping -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | properties:{ | | | {{- format parameters value, value 'required' | default , filter keys=true -}} | | | } | | | {%- endif -%} | | | {%- if value 'required' -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | required: | | | {%- for item in value 'required' | default -%} | | | <|"| {{- item -}}<|"| | | | {%- if not loop.last %},{% endif -%} | | | {%- endfor -%} | | | | | | {%- endif -%} | | | {%- endif -%} | | | {%- if add comma %},{%- else -%} {%- set add comma = true -%} {% endif -%} | | | type:<|"| {{ value 'type' | upper }}<|"| } | | | {%- endif -%} | | | {%- endfor -%} | | | {%- endmacro -%} | | | {%- macro format function declaration tool data -%} | | | declaration:{{- tool data 'function' 'name' -}}{description:<|"| {{- tool data 'function' 'description' -}}<|"| | | | {%- set params = tool data 'function' 'parameters' -%} | | | {%- if params -%} | | | ,parameters:{ | | | {%- if params 'properties' -%} | | | properties:{ {{- format parameters params 'properties' , params 'required' -}} }, | | | {%- endif -%} | | | {%- if params 'required' -%} | | | required: | | | {%- for item in params 'required' -%} | | | <|"| {{- item -}}<|"| | | | {{- ',' if not loop.last -}} | | | {%- endfor -%} | | | , | | | {%- endif -%} | | | {%- if params 'type' -%} | | | type:<|"| {{- params 'type' | upper -}}<|"| } | | | {%- endif -%} | | | {%- endif -%} | | | {%- if 'response' in tool data 'function' -%} | | | {%- set response declaration = tool data 'function' 'response' -%} | | | ,response:{ | | | {%- if response declaration 'description' -%} | | | description:<|"| {{- response declaration 'description' -}}<|"| , | | | {%- endif -%} | | | {%- if response declaration 'type' | upper == 'OBJECT' -%} | | | type:<|"| {{- response declaration 'type' | upper -}}<|"| } | | | {%- endif -%} | | | {%- endif -%} | | | } | | | {%- endmacro -%} | | | {%- macro format argument argument, escape keys=True -%} | | | { - P1 public fork : emit JSON null for None values rather than the | | | bare string "None". Jinja's default coercion of Python's None | | | goes through str None - "None", which then leaks into the | | | Gemma 4 DSL as a literal token the model has never been trained | | | on. Common bite path: a coding tool's optional argument | | | language=null in a find-files call, after=null in a search, | | | etc. → upstream emits after:None in the DSL → model | | | confusion. We emit after:null instead, matching the JSON wire | | | format the model has actually seen. | | | Branch ordering: is none must precede is string , is | | | mapping , is sequence , etc., because None matches NONE of | | | them in Jinja's type tests but the final else-branch | | | {{ argument }} would otherwise stringify it. - } | | | {%- if argument is none -%} | | | {{- 'null' -}} | | | {%- elif argument is string -%} | | | {{- '<|"| ' + argument + '<|"| ' -}} | | | {%- elif argument is boolean -%} | | | {{- 'true' if argument else 'false' -}} | | | {%- elif argument is mapping -%} | | | {{- '{' -}} | | | {%- set ns = namespace found first=false -%} | | | {%- for key, value in argument | dictsort -%} | | | {%- if ns.found first %},{% endif -%} | | | {%- set ns.found first = true -%} | | | {%- if escape keys -%} | | | {{- '<|"| ' + key + '<|"| ' -}} | | | {%- else -%} | | | {{- key -}} | | | {%- endif -%} | | | :{{- format argument value, escape keys=escape keys -}} | | | {%- endfor -%} | | | {{- '}' -}} | | | {%- elif argument is sequence -%} | | | {{- ' ' -}} | | | {%- for item in argument -%} | | | {{- format argument item, escape keys=escape keys -}} | | | {%- if not loop.last %},{% endif -%} | | | {%- endfor -%} | | | {{- ' ' -}} | | | {%- else -%} | | | {{- argument -}} | | | {%- endif -%} | | | {%- endmacro -%} | | | {%- macro strip thinking text -%} | | | {%- set ns = namespace result='' -%} | | | {%- for part in text.split '