Skip to content

Goal continuation block sent as a second role: system message breaks strict-template backends (Qwen vLLM and similar) #1

Closed

Description

@fank

Summary

opencode-goal-plugin injects its <goal_continuation> block by calling output.system.push(...) inside the experimental.chat.system.transform hook. On any backend that enforces the OpenAI convention of exactly one role: "system" message at index 0, the resulting request is rejected.

The opencode community has already documented that plugins using this hook should merge their content into the existing primary system block rather than push a new array entry (see anomalyco/opencode#23660, anomalyco/opencode#15059). Goal plugin v0.1.8 still uses push.

Where the bug is

src/goal-plugin.js, line 659 (v0.1.8):

"experimental.chat.system.transform": async (input, output) => {
  if (!input.sessionID) return
  const goal = goalStates.get(input.sessionID)
  if (!goal) return
  if (goal.stopped) return
  if (output.system.some((block) => block.includes("<goal_objective>"))) return

  output.system.push(                              // ← problem
    [
      buildGoalBlock(goal),
      "Keep working until the goal is fully satisfied.",
      "When fully satisfied, end the response with `[goal:complete]`.",
      "If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
      buildLimitWarning(goal),
    ].filter(Boolean).join("\n"),
  )
}

The result on the wire (captured from opencode's debug log against my Qwen3.5-122B vLLM endpoint):

"messages": [
  { "role": "system", "content": "You are opencode, an interactive CLI tool ..." },
  { "role": "system", "content": "<goal_continuation>\n...\n<goal_objective>\ndraw a ball\n</goal_objective>\n..." },
  { "role": "user",   "content": "draw a ball" }
]

Two consecutive role: "system" messages. The Qwen3.5 chat template (and several other vLLM-served model templates) explicitly rejects this:

{%- if message.role == "system" -%}
    {%- if not loop.first -%}
        {{- raise_exception('System message must be at the beginning.') -}}
    {%- endif -%}

vLLM returns HTTP 400 with the exception text in the response body. opencode propagates this to the user as AI_APICallError.

Steps to reproduce

  1. Install opencode-goal-plugin (tested v0.1.8) into opencode (tested v1.15.11).
  2. Configure an OpenAI-compatible provider pointing at a vLLM server hosting any Qwen3.5 model with the upstream chat template (e.g. Qwen3.5-122B-A10B).
  3. In an opencode session, run /goal draw a ball (any goal text works).
  4. opencode reports AI_APICallError with the response body \"System message must be at the beginning.\".

Affected backends

This is not Qwen-specific — every backend that enforces a single-system-message invariant in its chat template is affected:

OpenAI's and Anthropic's hosted APIs silently tolerate multiple system messages, which is why this bug is invisible to users on those providers.

Suggested fix

Merge the goal block into the existing first system entry instead of pushing a new one. Minimal change:

"experimental.chat.system.transform": async (input, output) => {
  if (!input.sessionID) return
  const goal = goalStates.get(input.sessionID)
  if (!goal) return
  if (goal.stopped) return
  if (output.system.some((block) => block.includes("<goal_objective>"))) return

  const goalBlock = [
    buildGoalBlock(goal),
    "Keep working until the goal is fully satisfied.",
    "When fully satisfied, end the response with `[goal:complete]`.",
    "If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
    buildLimitWarning(goal),
  ].filter(Boolean).join("\n")

  if (output.system.length === 0) {
    output.system.push(goalBlock)
  } else {
    output.system[0] = `${output.system[0]}\n\n${goalBlock}`
  }
}

This:

Related

Happy to send a PR if helpful.

Environment

  • opencode: 1.15.11
  • opencode-goal-plugin: 0.1.8
  • Provider: @ai-sdk/openai-compatible -> self-hosted vLLM 0.19.x
  • Model: Qwen3.5-122B-A10B Hybrid INT4/FP8
  • Chat template: enhanced Qwen3.5 Jinja from allanchan339/vLLM-Qwen3-3.5-3.6-chat-template-fix (same raise_exception guard exists in the upstream Qwen template)

Activity

  1. fank commented on May 27, 2026

    @fank
    ContributorAuthor

    Cross-link: a fresh repro of the same root cause against opencode 1.15.11 with this plugin has been posted upstream at anomalyco/opencode#15059 (this comment). That issue tracks the off-by-one in opencode's plugin-system coalescer.

    The two fixes are independent and complementary:

    • This plugin issue — merge into output.system[0] defensively. Unblocks users on strict-template backends immediately, regardless of upstream state.
    • opencode#15059 — fix the coalescer guard so plugins can safely keep using push per the hook's natural shape.
  2. fank commented on May 27, 2026

    @fank
    ContributorAuthor

    Validated the proposed fix locally against the exact reproducer.

    A/B test, same command (opencode run --command goal …), same backend, only the plugin source differs:

    Plugin code Result
    output.system.push(...) (current v0.1.8) HTTP 400 from vLLM: {"message":"System message must be at the beginning.","type":"BadRequestError","code":400}
    Merge into output.system[0] (proposed fix) Goal continuation accepted, model executed write tool, completed step cleanly, auto-continued to next iteration

    Token usage in the passing case: 9120 input / 283 output — confirming both the base system prompt and the goal continuation block reached the model (just collapsed into a single role: "system" message instead of two).

    The patch I tested is the one in the issue body:

    const goalBlock = [
      buildGoalBlock(goal),
      "Keep working until the goal is fully satisfied.",
      "When fully satisfied, end the response with \`[goal:complete]\`.",
      "If user input is required, explain the blocker in the line immediately before \`[goal:blocked]\`.",
      buildLimitWarning(goal),
    ].filter(Boolean).join("\n")
    
    if (output.system.length === 0) {
      output.system.push(goalBlock)
    } else {
      output.system[0] = `${output.system[0]}\n\n${goalBlock}`
    }

    No behavior change for users on lenient backends (OpenAI/Anthropic), and unblocks every strict-template backend in one ~6-line change. Happy to send this as a PR if it'd help.

  3. fank commented on May 27, 2026

    @fank
    ContributorAuthor

    PR opened: #2 — applies the fix, adds two regression tests (merge case + empty-array fallback), passes npm run check (35/35) and npm run pack:check. Validated against the same Qwen3.5-122B vLLM reproducer described above.

  4. added 2 commits that reference this issue on May 30, 2026
  5. johnson-johns-codes-openez commented on Aug 14, 2026

    @johnson-johns-codes-openez

    Verified fixed in current releases — this can be closed.

    The failure mode described here (a second role: "system" entry pushed via output.system.push(...)) was fixed in commit e84ddcc ("Harden goal plugin verification and compatibility", 2026-06-04), first shipped in v0.1.11 and still present in the current release (I verified against the installed v0.8.1).

    Current behavior of experimental.chat.system.transform in src/goal-plugin.js:

    • System array empty → pushes the goal block alone (nothing to merge into).
    • Otherwise → merges the goal block into the existing first entry via appendGoalToSystemBlock, which handles plain strings, { text } objects, { content: string }, and { content: array } shapes; only if the block matches none of those does it fall back to unshift as a separate entry.

    Result: exactly one role: "system" message on the wire, so strict-template backends (Qwen3.5 family on vLLM, Llama.cpp templates, etc.) are satisfied.

    Regression coverage in test/goal-plugin.test.js:

    • "system transform merges into existing system block instead of adding a second one" — asserts output.system.length === 1, the primary system block is preserved, and the goal block content is merged into it.
    • "system transform pushes a new block when system array is empty" — covers the no-primary-system case.

    Suggest closing this issue; the reporter's proposed fix direction is exactly what shipped (plus object/array content-shape hardening).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions