fix(core): send max-steps guardrail as a user message to avoid Anthropic prefill rejection - #356
Conversation
| const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history | ||
| // A synthetic assistant message is an assistant prefill. Newer Anthropic | ||
| // models reject prefills, so keep the final-step guardrail in the user | ||
| // turn instead. |
There was a problem hiding this comment.
The change from Message.assistant(MAX_STEPS_PROMPT) to Message.user(MAX_STEPS_PROMPT) fundamentally changes how the model interprets this guardrail:
- Assistant prefill: The model continues/completes the assistant's partial response. This is appropriate if
MAX_STEPS_PROMPTis phrased from the assistant's perspective (e.g., "I have reached...") - User message: The model responds to a user instruction. This is appropriate if
MAX_STEPS_PROMPTis phrased as a directive (e.g., "You have reached...")
Without seeing the MAX_STEPS_PROMPT content, it's unclear if this semantic change is correct. If the prompt was originally written for prefill usage, it may need to be reworded for user-message usage to maintain the intended behavior.
| expect(requests).toHaveLength(2) | ||
| expect(requests[0]?.toolChoice).toBeUndefined() | ||
| expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) | ||
| expect(requests[1]?.messages.at(-1)?.role).toBe("user") |
There was a problem hiding this comment.
The regression test added at line 4793 verifies that requests[1]?.messages.at(-1)?.role is "user", but doesn't verify the message content is actually MAX_STEPS_PROMPT. This test would pass if:
- The implementation accidentally uses a different message constant
- Any other user message happens to be at the end of the history
- The synthetic message is lost but a user message from history remains at the end
Concrete failure: If a developer changes the code to Message.user("Wrong message"), this test passes but users see incorrect step-limit guidance.
Suggested fix: Assert both role and content:
const lastMsg = requests[1]?.messages.at(-1)
expect(lastMsg?.role).toBe("user")
expect(lastMsg?.content).toContain(MAX_STEPS_PROMPT) // or exact match if appropriate
Intent
Refresh PR #356 so Shuvcode avoids Anthropic assistant-prefill rejection by sending the max-step guardrail as a synthetic user message. Preserve tool disabling and prompt-cache behavior on the final step, update all role and content regressions including retry and malformed-tool-input paths, refresh onto current integration-v2, and validate the existing PR branch before it is merged.
What Changed
packages/core/src/session/model-request.tsnow appendsMAX_STEPS_PROMPTas a synthetic user message instead of an assistant message, since Anthropic rejects synthetic assistant prefills. Tool disabling (toolChoice: none) and tool-definition retention for prompt caching on the final step are unchanged.userrole and exactMAX_STEPS_PROMPTcontent across the guardrail, retry, and malformed-tool-input paths, including a new assertion that the malformed-input path carries the guardrail message.packages/web/src/content/docs/agents.mdx) now describes the step-limit guardrail as a user message rather than a system prompt.Risk Assessment
✅ Low: A two-line role swap with coherent test updates; final-step tool disabling and prompt-cache behavior are preserved, the sole MAX_STEPS_PROMPT usage was changed, and the Anthropic protocol path handles the trailing user message safely including after tool results.
Testing
Ran the three targeted session-runner regressions covering the final-step guardrail, retry, and malformed-tool-input paths (all pass after a
bun installsetup fix), and captured the actual final-step model request showing the guardrail delivered as a trailing user-role message with toolChoice none and tool definitions retained for prompt caching; source grep confirms no assistant-prefill usage remains. No UI surface is involved, so evidence is the request payload artifact rather than a screenshot.Evidence: Final-step model request payload (user-role guardrail, toolChoice none, tools retained)
{ "toolChoice": { "type": "none" }, "toolNames": ["defect", "echo", "storefail"], "messageRoles": ["user", "assistant", "tool", "user"], "lastMessage": { "role": "user", "content": [{ "type": "text", "text": "CRITICAL - MAXIMUM STEPS REACHED\n\nThe maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. ..." }] } }Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
⏭️ **Rebase** - skipped
packages/core/test/config/plugin.test.ts- merge conflict rebasing onto origin/fix-prefill✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
bun test test/session-runner.test.ts -t "configured final step|without consuming the logical agent step|malformed tool input past the agent step limit"in packages/core — 3 pass, 0 fail (guardrail, retry, and malformed-tool-input paths)Temporarily instrumented the final-step test to dump the real model request to/tmp/no-mistakes-evidence/01M0DXCX3QNQAK8VW60FVBFS7D/final-step-request.json, then reverted the instrumentation and re-ran the tests on the clean committed code (all pass, worktree clean)grep -rn MAX_STEPS_PROMPT src/— confirmed the only usage isMessage.user(MAX_STEPS_PROMPT)inpackages/core/src/session/model-request.ts:202; no assistant-prefill path remainspackages/web/src/content/docs/ko/agents.mdx:309- Localized doc mirrors (e.g. packages/web/src/content/docs/ko/agents.mdx and other locale copies of agents.mdx) still describe the max-steps guardrail as a 'system prompt'. The English owner doc was corrected to 'user message'; translations are derived copies and should be regenerated through the translation pipeline rather than hand-synchronized across dozens of locales.packages/core/src/session/model-request.ts:66- oxlint reports 8 pre-existing warnings in the changed files (consistent-return and no-misused-spread in packages/core/src/session/model-request.ts and packages/core/test/session-runner.test.ts), all on lines this change did not touch. Fixing them (e.g. spreading Effect Message class instances) is not behavior-preserving-mechanical, so they were left as repo baseline.✅ **Push** - passed
✅ No issues found.