Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jscott3201/e4b155885cc68c038d6ac8909a3bd9fe to your computer and use it in GitHub Desktop.

Select an option

Save jscott3201/e4b155885cc68c038d6ac8909a3bd9fe to your computer and use it in GitHub Desktop.
A drop-in replacement chat template for Qwen/Qwen3.6-27B tuned for open-source agentic coding harnesses.
{#---------------------------------------------------------------------
custom_pub_chat_template_qwen36.jinja
=====================================
A public, harness-friendly fork of Qwen's Qwen3.6-27B 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 `Qwen/Qwen3.6-27B` is correct for chat
use, but six real edge cases bite agentic coding harnesses pointing
at a self-hosted SGLang / vLLM / llama.cpp endpoint serving Qwen3.6:
1. Multi-turn tool argument collapse. After 2-3 turns of calling the
same tool, the model emits arguments: {} despite its prior
reasoning correctly identifying the parameters. Root cause: the
upstream template defaults preserve_thinking=false, which means
prior-turn <think> blocks are silently dropped from history; the
model loses its own trace of "how did I pick the parameters last
time?" and degenerates. Documented at:
https://github.com/earendil-works/pi/issues/3325
The Qwen3.6 model card explicitly states the model was post-
trained for "Thinking Preservation" in agent scenarios — the
preserve_thinking-FALSE default is wrong for our use case.
2. The `developer` role rejected. Modern coding harnesses
(opencode, Claude Code, openclaw, Continue) send a `developer`
role for reasoning-capable models, following OpenAI's Responses
API convention. Upstream raises "Unexpected message role" —
crashing the entire request. Reported and documented at:
https://gist.github.com/sudoingX/c2facf7d8f7608c65c1024ef3b22d431
("Qwen 3.5 GGUF templates reject the developer role sent by
OpenCode, Claude Code, and other modern agent tools.")
3. tool_call.arguments arriving as a JSON string crashes with a
cryptic Jinja error ("Can only get item pairs from a mapping").
The Vercel AI SDK (used by opencode) and several other OpenAI-
compatible adapters hand arguments back as a JSON-encoded
STRING rather than the deserialized object. Diagnosing this
from the upstream error message is painful. Documented at:
https://github.com/earendil-works/pi/issues/3325
https://github.com/anomalyco/opencode/issues/24264
4. The opening `<tool_call>` tag is sometimes omitted by the model
(documented at https://github.com/QwenLM/Qwen3-Coder/issues/475)
and `<tool_call>` can appear inside an unclosed `<think>` block
(https://github.com/ollama/ollama/issues/14493). The upstream
template's content-parsing only recognizes `</think>` and only
when properly closed, so reasoning bleeds into the conversation
content channel. Whitespace variants of `</think>` aren't
recognized either.
5. The OpenAI envelope around tool definitions
({"type":"function","function":{...}}) is passed verbatim
through `tool | tojson`, wasting tokens and diverging from
what the model expects. Qwen's own most recent coder model,
Qwen3-Coder-Next, unwraps this envelope in its own canonical
chat template:
https://huggingface.co/Qwen/Qwen3-Coder-Next/blob/main/chat_template.jinja
(lines 35-37). The Qwen3.6-27B upstream template just hasn't
caught up to the newer convention.
6. The upstream IMPORTANT instructions block is missing three
bullets that address the most common public Qwen3-Coder
failure modes:
- Omitting the opening <tool_call> tag (Qwen3-Coder #475)
- Indenting <tool_call> with leading whitespace
(https://github.com/block/goose/issues/6883)
- Nesting <tool_call> blocks instead of emitting parallel
calls
PATCH INVENTORY (full details next to each patch site below)
------------------------------------------------------------
Q1 preserve_thinking default flipped FALSE→TRUE
Q2 `developer` role accepted as alias for `system`
Q3 Raise a clear, debuggable error on string tool_call.arguments
Q4 Robust </think> variant handling + unclosed-think rescue
Q5 Unwrap OpenAI tool envelope to inner function spec (gated)
Q6 Strengthened IMPORTANT instructions block (gated)
INVARIANTS
----------
1. STRICT-EQUIVALENCE: With kwargs
preserve_thinking=false, (recovers Q1)
unwrap_tool_envelope=false, (recovers Q5)
verbose_tool_instructions=false (recovers Q6)
AND inputs that don't exercise Q2 (no `developer` role),
Q3 (no string-typed arguments), or Q4 (no `</thinking>` or
whitespace variants of `</think>`), this template renders
byte-for-byte identical to upstream. The conformance suite
at tests/test_custom_pub_chat_template_qwen36.py locks this in
across the simple-input matrix.
2. STRICT-SAFETY: For every input upstream handles without error,
this template handles correctly with semantically equivalent
or strictly safer output. The strict-where-upstream-silent
patches (Q3, Q4) only fire on inputs that hit the documented
bug surfaces.
USAGE
-----
Server side (e.g. SGLang or vLLM):
# SGLang
python -m sglang.launch_server \
--model-path Qwen/Qwen3.6-27B \
--chat-template /path/to/custom_pub_chat_template_qwen36.jinja \
--tool-call-parser qwen3_coder \
--reasoning-parser qwen3
# vLLM
vllm serve Qwen/Qwen3.6-27B \
--chat-template /path/to/custom_pub_chat_template_qwen36.jinja \
--tool-call-parser qwen3_coder \
--reasoning-parser qwen3 \
--enable-auto-tool-choice
Harness side: no changes required for the common case. The
defaults are tuned for agentic coding out of the box. If you need
to recover the upstream defaults explicitly:
{
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": true,
"preserve_thinking": false,
"unwrap_tool_envelope": false,
"verbose_tool_instructions": false
}
}
}
For opencode-style providers, this maps to chat_template_args in
the model config; for pi, use compat.thinkingFormat="qwen-chat-
template" and pi will inject the kwargs correctly.
PINS
----
Forked from Qwen/Qwen3.6-27B/chat_template.jinja
Upstream MD5: 52b6d51ae5b203cb67e64b648494dad2 (153 lines)
Fork date: 2026-05-25
License: Apache 2.0 (same as upstream)
Maintainer: see repo README
---------------------------------------------------------------------#}
{#- Vision counters (identical to upstream). -#}
{%- set image_count = namespace(value=0) %}
{%- set video_count = namespace(value=0) %}
{#- ============================================================================
Content rendering macro.
Functionally identical to upstream's macro of the same name. The only
cosmetic difference is the `add_vision_id is defined and add_vision_id`
guard instead of upstream's bare `if add_vision_id` — a defensive
rewrite that prevents undefined-variable errors in some minijinja
runtimes (llama.cpp, MLX). No rendering-time behavior change for
Python Jinja2 (SGLang/vLLM) since both runtimes treat undefined as
falsy.
============================================================================ -#}
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
{%- if content is string %}
{{- content }}
{%- elif content is iterable and content is not mapping %}
{%- for item in content %}
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain images.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set image_count.value = image_count.value + 1 %}
{%- endif %}
{%- if add_vision_id is defined and add_vision_id %}
{{- 'Picture ' ~ image_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
{%- elif 'video' in item or item.type == 'video' %}
{%- if is_system_content %}
{{- raise_exception('System message cannot contain videos.') }}
{%- endif %}
{%- if do_vision_count %}
{%- set video_count.value = video_count.value + 1 %}
{%- endif %}
{%- if add_vision_id is defined and add_vision_id %}
{{- 'Video ' ~ video_count.value ~ ': ' }}
{%- endif %}
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
{%- elif 'text' in item %}
{{- item.text }}
{%- else %}
{{- raise_exception('Unexpected item type in content.') }}
{%- endif %}
{%- endfor %}
{%- elif content is none or content is undefined %}
{{- '' }}
{%- else %}
{{- raise_exception('Unexpected content type.') }}
{%- endif %}
{%- endmacro %}
{#- ============================================================================
Q1 (public fork): preserve_thinking default flipped FALSE → TRUE.
Why: upstream's preserve_thinking gate at the assistant-rendering site
is:
{%- if (preserve_thinking is defined and preserve_thinking is true)
or (loop.index0 > ns.last_query_index) %}
With preserve_thinking unset, prior-turn <think> blocks (assistant
turns at indices <= last_query_index) are dropped from history. The
model loses its own trace of how it chose tool arguments on prior
turns and degenerates after 2-3 multi-turn calls of the same tool.
The canonical public bug-report on this exact failure mode for
Qwen3.6 is `earendil-works/pi#3325`:
https://github.com/earendil-works/pi/issues/3325
"Qwen3.6 tool calls loop with empty arguments: qwen-chat-template
missing preserve_thinking ... After 2-3 turns every tool call has
arguments: {}."
The Qwen3.6 model card explicitly states (verbatim):
"Qwen3.6 has been additionally trained to preserve and leverage
thinking traces from historical messages ... particularly
beneficial for agent scenarios."
So this is not just a workaround — preserve_thinking=true is the
model-card-recommended setting for agentic harnesses. The public
fork makes it the default.
Recover upstream behavior: pass preserve_thinking=false explicitly.
============================================================================ -#}
{%- if preserve_thinking is not defined %}
{%- set preserve_thinking = true %}
{%- endif %}
{#- Q5 / Q6 (public fork): both gated by kwargs, default true. See the
patch sites below for the full rationale and citations. -#}
{%- if unwrap_tool_envelope is not defined %}
{%- set unwrap_tool_envelope = true %}
{%- endif %}
{%- if verbose_tool_instructions is not defined %}
{%- set verbose_tool_instructions = true %}
{%- endif %}
{%- if not messages %}
{{- raise_exception('No messages provided.') }}
{%- endif %}
{#- ============================================================================
Q2 (public fork): `developer` role accepted as an alias for `system`.
Upstream's role check (in the index-0 system handling AND in the
main message loop) only accepts `system`; a `developer` role
raises "Unexpected message role" and crashes the request.
Modern coding harnesses (opencode, Claude Code, openclaw, Continue)
emit a `developer` role for reasoning-capable models, following
OpenAI's Responses API convention. This causes the entire request
to fail when pointed at a stock Qwen3.6 server.
Reference (gist documenting the bite for OpenCode + Qwen3.5):
https://gist.github.com/sudoingX/c2facf7d8f7608c65c1024ef3b22d431
Below: we normalize the index-0 role for the upcoming system-block
decision, then in the main message loop we treat both as system.
The change is invisible for inputs that only use `system`.
============================================================================ -#}
{%- if tools and tools is iterable and tools is not mapping %}
{{- '<|im_start|>system\n' }}
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{#- Q5 (public fork): unwrap the OpenAI envelope.
Background: harnesses speaking OpenAI tool-call protocol send
tool definitions wrapped in {"type":"function","function":{...}}.
Upstream passes the WHOLE wrapper through `tool | tojson`,
emitting an extra layer the model has to mentally peel off,
and wasting ~12 tokens per tool.
Qwen's own most recent coder model unwraps this envelope in
its canonical chat template:
https://huggingface.co/Qwen/Qwen3-Coder-Next/blob/main/chat_template.jinja
(lines 35-37: `{%- if tool.function is defined %}{%- set tool =
tool.function %}{%- endif %}`).
Qwen3.6-27B's upstream template predates that change; this
patch backports the unwrap behavior so Qwen3.6 sees the same
tool format Qwen3-Coder-Next was trained on.
Recover upstream behavior: pass unwrap_tool_envelope=false. -#}
{%- if unwrap_tool_envelope and tool.function is defined %}
{{- tool.function | tojson }}
{%- else %}
{{- tool | tojson }}
{%- endif %}
{%- endfor %}
{{- "\n</tools>" }}
{#- Q6 (public fork): strengthened IMPORTANT instructions block.
Upstream's IMPORTANT block has 4 bullets. The strengthened
version adds three bullets that address documented public Qwen
coder failure modes:
- "Do NOT omit the opening <tool_call> tag":
https://github.com/QwenLM/Qwen3-Coder/issues/475
- "MUST be at the very beginning of a new line, with NO leading
spaces or indentation":
https://github.com/block/goose/issues/6883
- "Do NOT nest <tool_call> blocks inside one another":
same #6883 + Roo Code custom-XML interaction patterns
These bullets are pure additive guidance to the model; they
don't change tool-call wire-format behavior for well-formed
outputs, but they reduce error rates on the documented edge
cases.
Recover upstream behavior: pass verbose_tool_instructions=false. -#}
{%- if verbose_tool_instructions %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- Do NOT omit the opening <tool_call> tag. Every function call MUST be wrapped in a complete <tool_call>...</tool_call> block.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO leading spaces or indentation.\n- Required parameters MUST be specified.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks inside one another.\n- You may provide reasoning inside <think>...</think> blocks BEFORE the <tool_call>, but NOT after. After a tool call there must be NO suffix on the same turn.\n- If no function call is needed, answer the question normally and do not mention function calls.\n</IMPORTANT>' }}
{%- else %}
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
{%- endif %}
{#- Q2 (public fork): accept developer role at index 0. -#}
{%- if messages[0].role == 'system' or messages[0].role == 'developer' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{%- if content %}
{{- '\n\n' + content }}
{%- endif %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{#- Q2 (public fork): accept developer role at index 0. -#}
{%- if messages[0].role == 'system' or messages[0].role == 'developer' %}
{%- set content = render_content(messages[0].content, false, true)|trim %}
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{#- last_query_index walk (identical to upstream). When preserve_thinking=true
(the public fork's default), the index produced here is not consulted —
the assistant-render guard below only checks preserve_thinking first. -#}
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
{%- for message in messages[::-1] %}
{%- set index = (messages|length - 1) - loop.index0 %}
{%- if ns.multi_step_tool and message.role == "user" %}
{%- set content = render_content(message.content, false)|trim %}
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
{%- set ns.multi_step_tool = false %}
{%- set ns.last_query_index = index %}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- if ns.multi_step_tool %}
{{- raise_exception('No user query found in messages.') }}
{%- endif %}
{%- for message in messages %}
{%- set content = render_content(message.content, true)|trim %}
{%- if message.role == "system" or message.role == "developer" %}
{#- Q2 (public fork): both roles are valid at the start; upstream
rejected `developer` here. The system block was already rendered
above; nothing to emit per-message. -#}
{%- if not loop.first %}
{{- raise_exception('System/developer message must be at the beginning.') }}
{%- endif %}
{%- elif message.role == "user" %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{#- ----------------------------------------------------------------
Q4 (public fork): robust </think> variant handling + unclosed-
think rescue.
Upstream's content parsing only recognizes `</think>`, and only
when it appears with a properly opened `<think>` somewhere
earlier in the content. Three documented failure modes leak:
- The model emits `</thinking>` (long form) — upstream treats
the entire content as non-reasoning text, then `<think>` and
`</thinking>` literals leak into the model's view of history.
- Whitespace variants `</ think>` and `</think >` happen with
some quantization runtimes (especially older llama.cpp builds).
- `<tool_call>` appears INSIDE an unclosed `<think>` block (the
model started reasoning, decided to call a tool, and forgot
to close the think block first).
The Ollama equivalent of this bug:
https://github.com/ollama/ollama/issues/14493
"tool calls in the Qwen 3 and Qwen 3.5 model families would
not be parsed correctly if emitted during thinking"
(fixed in Ollama 0.17.3).
The Qwen3-Coder equivalent (model omitting opening tag):
https://github.com/QwenLM/Qwen3-Coder/issues/475
Q4 handles all four cases. The strict-improvement contract:
for any input upstream parses correctly (only `</think>`,
properly closed), behavior here is identical.
---------------------------------------------------------------- #}
{%- set reasoning_content = '' %}
{%- if message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- else %}
{%- set think_end = '' %}
{%- if '</think>' in content %}
{%- set think_end = '</think>' %}
{%- elif '</thinking>' in content %}
{%- set think_end = '</thinking>' %}
{%- elif '</ think>' in content %}
{%- set think_end = '</ think>' %}
{%- elif '</think >' in content %}
{%- set think_end = '</think >' %}
{%- endif %}
{%- if think_end %}
{%- set parts = content.split(think_end) %}
{%- set reasoning_content = parts[0] %}
{%- set content = parts[1:] | join(think_end) %}
{%- if '<think>' in reasoning_content %}
{%- set reasoning_content = reasoning_content.split('<think>')[1:] | join('<think>') %}
{%- endif %}
{%- elif '<think>' in content %}
{#- Unclosed think; rescue when followed by <tool_call>
(ollama#14493 pattern). -#}
{%- set prefix = content.split('<think>')[0] %}
{%- set think_part = content.split('<think>')[1:] | join('<think>') %}
{%- if '<tool_call>' in think_part %}
{%- set reasoning_content = think_part.split('<tool_call>')[0] %}
{%- set content = prefix ~ '\n<tool_call>' ~ think_part.split('<tool_call>')[1:] | join('<tool_call>') %}
{%- else %}
{%- set reasoning_content = think_part %}
{%- set content = prefix %}
{%- endif %}
{%- endif %}
{%- endif %}
{%- set reasoning_content = reasoning_content | trim %}
{%- set content = content | trim %}
{#- Strip any leaked <tool_call> text from content; real tool_calls
come from the dedicated field. (Identical to upstream's intent
but expressed inline rather than relying on upstream's regex.) -#}
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- if '<tool_call>' in content %}
{%- set content = content.split('<tool_call>')[0] | trim %}
{%- endif %}
{%- endif %}
{#- Reasoning-emission gate. Mirrors upstream's structure exactly,
but with the Q1 default flip in effect: preserve_thinking
defaults true, so prior-turn <think> blocks survive. -#}
{%- if (preserve_thinking is defined and preserve_thinking is true) or (loop.index0 > ns.last_query_index) %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
{{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.function is defined %}
{%- set tool_call = tool_call.function %}
{%- endif %}
{%- if loop.first %}
{%- if content|trim %}
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- else %}
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- else %}
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
{%- endif %}
{%- if tool_call.arguments is defined %}
{#- ----------------------------------------------------
Q3 (public fork): debuggable raise on string args.
Upstream uses `tool_call.arguments | items` (line 120
of upstream/chat_template.jinja). When arguments
is a JSON-encoded STRING — which is the wire-format
the OpenAI spec defines, and what some harness
adapters (notably the Vercel AI SDK used by
opencode) hand back to the harness — `.items`
raises:
"Can only get item pairs from a mapping"
which is impossible to debug without reading the
Jinja runtime source.
Q3 type-checks first and raises a clear error that
names the bug surface and links to the canonical
discussion. Harnesses MUST deserialize the JSON-
encoded arguments string exactly once on ingest
and store the resulting dict. See:
https://github.com/earendil-works/pi/issues/3325
https://github.com/anomalyco/opencode/issues/24264
For inputs where arguments is already a dict (the
well-formed case), behavior is identical to upstream.
---------------------------------------------------- #}
{%- if tool_call.arguments is mapping %}
{%- for args_name, args_value in tool_call.arguments|items %}
{{- '<parameter=' + args_name + '>\n' }}
{%- set args_value = args_value | string if args_value is string else args_value | tojson | safe %}
{{- args_value }}
{{- '\n</parameter>\n' }}
{%- endfor %}
{%- elif tool_call.arguments is string %}
{{- raise_exception(
"custom_pub_chat_template_qwen36: "
"tool_call.arguments must be a JSON object "
"(mapping). Got a string. This is almost "
"always the harness handing back a JSON-"
"encoded STRING rather than the deserialized "
"object (common with Vercel AI SDK). "
"Deserialize once on ingest and store the "
"object. See: "
"github.com/earendil-works/pi/issues/3325"
) }}
{%- endif %}
{%- endif %}
{{- '</function>\n</tool_call>' }}
{%- endfor %}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{%- if loop.previtem and loop.previtem.role != "tool" %}
{{- '<|im_start|>user' }}
{%- endif %}
{{- '\n<tool_response>\n' }}
{{- content }}
{{- '\n</tool_response>' }}
{%- if not loop.last and loop.nextitem.role != "tool" %}
{{- '<|im_end|>\n' }}
{%- elif loop.last %}
{{- '<|im_end|>\n' }}
{%- endif %}
{%- else %}
{{- raise_exception('Unexpected message role.') }}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined and enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- else %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment