Skip to content

fix: merge goal continuation into primary system block (closes #1)#2

Open
fank wants to merge 1 commit into
willytop8:mainfrom
fank:fix/merge-system-block-instead-of-push
Open

fix: merge goal continuation into primary system block (closes #1)#2
fank wants to merge 1 commit into
willytop8:mainfrom
fank:fix/merge-system-block-instead-of-push

Conversation

@fank
Copy link
Copy Markdown

@fank fank commented May 27, 2026

What changed

experimental.chat.system.transform now merges the goal continuation block into output.system[0] instead of output.system.push(...)-ing a second array entry. When the system array is empty, it falls back to pushing — preserving current behavior for callers that rely on goal-plugin owning the entire system prompt.

Why

The previous push pattern produced two consecutive role: "system" messages on the wire. Backends that enforce the OpenAI convention of exactly one system message at index 0 reject this with HTTP 400 "System message must be at the beginning.". Affected backends include:

  • Qwen family on vLLM (the Qwen3.5/3.6 chat templates raise_exception on a second system message)
  • Several Llama.cpp templates
  • Some Mistral templates

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

The opencode community has documented that plugins using experimental.chat.system.transform should merge rather than push (see anomalyco/opencode#23660 and anomalyco/opencode#15059). This change brings goal-plugin in line with that guidance.

How

// before
output.system.push(
  [buildGoalBlock(goal), ...].filter(Boolean).join("\n"),
)

// after
const goalBlock = [buildGoalBlock(goal), ...].filter(Boolean).join("\n")
if (output.system.length === 0) {
  output.system.push(goalBlock)
} else {
  output.system[0] = `${output.system[0]}\n\n${goalBlock}`
}

Verification

Checks

$ npm run check
... 35 pass, 0 fail
$ npm run pack:check
... opencode-goal-plugin-0.1.8.tgz (12.0 kB)

Manual OpenCode smoke test

Against opencode 1.15.11 with this plugin, provider @ai-sdk/openai-compatible pointing at vLLM 0.19.x serving Qwen3.5-122B-A10B (same setup as issue #1):

Plugin code Result
Original (push) HTTP 400 from vLLM: {"message":"System message must be at the beginning.","type":"BadRequestError","code":400}
This PR (merge) /goal draw a ball succeeds; model executes write tool, creates the artifact, step finishes cleanly with reason: "tool-calls", goal auto-continues to next iteration

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

Tests

  • Existing "system transform is idempotent" — unchanged, still passes (idempotence guard preserved).
  • New "merges into existing system block instead of adding a second one" — pins the bug fix; asserts output.system.length === 1 and the base prompt is preserved as a prefix.
  • New "pushes a new block when system array is empty" — pins the empty-array fallback.

Related

Closes #1.

The `experimental.chat.system.transform` hook previously pushed the goal
continuation as a separate `role: "system"` entry. On backends that
enforce the OpenAI convention of exactly one system message at index 0
(Qwen on vLLM, several Llama.cpp / Mistral templates), this produced an
HTTP 400 "System message must be at the beginning." error.

Merge the goal block into the existing primary system entry instead.
When the system array is empty, fall back to pushing — preserving
current behavior for callers that rely on goal-plugin owning the entire
system prompt.

Behavior on lenient backends (OpenAI, Anthropic) is unchanged: identical
goal content reaches the model, just collapsed into one system message.

Validated locally against opencode 1.15.11 + Qwen3.5-122B-A10B on vLLM:
the exact reproducer from issue willytop8#1 (`/goal draw a ball`) goes from
HTTP 400 to a successful tool-calling step with no other changes.

Tests:
- Existing "system transform is idempotent" still passes.
- New "merges into existing system block instead of adding a second one"
  pins the bug fix.
- New "pushes a new block when system array is empty" pins the fallback.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant