cd /news/large-language-models/a-drop-in-replacement-chat-template-… · home topics large-language-models article
[ARTICLE · art-13777] src=gist.github.com pub= topic=large-language-models verified=true sentiment=· neutral

A drop-in replacement chat template for Qwen/Qwen3.6-27B tuned for open-source agentic coding harnesses.

A developer has released a drop-in replacement chat template for Qwen/Qwen3.6-27B that fixes six critical bugs affecting open-source agentic coding harnesses. The fork addresses issues including multi-turn tool argument collapse caused by the upstream template's `preserve_thinking=false` default, rejection of the `developer` message role used by modern coding tools, and crashes from tool call arguments arriving as JSON strings. The template also patches the upstream's failure to recognize malformed `` tags and wasteful token usage from passing OpenAI envelope tool definitions verbatim.

read23 min publishedMay 25, 2026

| {#--------------------------------------------------------------------- | | | 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 %} |
── more in #large-language-models 4 stories · sorted by recency
── more on @qwen 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-drop-in-replacemen…] indexed:0 read:23min 2026-05-25 ·