From 0ce88dbfa298ed069bc996fc41093d4f0b15b6be Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Sun, 16 Aug 2026 22:46:42 -0700 Subject: [PATCH 1/3] fix(core): avoid Anthropic assistant prefills --- packages/core/src/session/model-request.ts | 5 ++++- packages/core/test/session-runner.test.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index f744983afec8..f1af821779f4 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -197,7 +197,10 @@ export const layer = Layer.effect( .filter((part) => part.length > 0) .map(SystemPart.make) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) - 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. + const messages = stepLimitReached ? [...history, Message.user(MAX_STEPS_PROMPT)] : history const registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) // The definition objects we hand to hooks, mapped back to their tools. Hooks rename a // tool by moving its definition to a new key; recognizing the object recovers the tool. diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e55f1d8e2788..c27e8b885cf4 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -4946,6 +4946,7 @@ describe("SessionRunnerLLM", () => { 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") expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2) }), ) From 86769502794f16278914c5a678e4ac5dc6bd4592 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Wed, 19 Aug 2026 13:57:57 -0700 Subject: [PATCH 2/3] test(core): align max-step guardrail coverage --- packages/core/src/session/model-request.ts | 5 ++--- packages/core/test/session-runner.test.ts | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index f1af821779f4..359ff4624593 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -197,9 +197,8 @@ export const layer = Layer.effect( .filter((part) => part.length > 0) .map(SystemPart.make) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) - // A synthetic assistant message is an assistant prefill. Newer Anthropic - // models reject prefills, so keep the final-step guardrail in the user - // turn instead. + // Anthropic rejects synthetic assistant prefills, so append the final-step + // guardrail as a synthetic user message. const messages = stepLimitReached ? [...history, Message.user(MAX_STEPS_PROMPT)] : history const registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) // The definition objects we hand to hooks, mapped back to their tools. Hooks rename a diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c27e8b885cf4..9ac1f7d1517a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -43,6 +43,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunner } from "@opencode-ai/core/session/runner/index" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics" import { SessionUsage } from "@opencode-ai/core/session/usage" @@ -4031,8 +4032,8 @@ describe("SessionRunnerLLM", () => { // Protocols with native "none" keep these definitions for prompt caching. expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[1]?.messages.at(-1)).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + role: "user", + content: [{ type: "text", text: MAX_STEPS_PROMPT }], }) expect(executions).toEqual(["done"]) expect(yield* session.context(sessionID)).toMatchObject([ @@ -4583,16 +4584,13 @@ describe("SessionRunnerLLM", () => { expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[1]?.toolChoice).toBeUndefined() expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo") - expect(requests[1]?.messages.at(-1)).not.toMatchObject({ - role: "assistant", - content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], - }) + expect(JSON.stringify(requests[1]?.messages)).not.toContain("MAXIMUM STEPS REACHED") expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) // The final step keeps tool definitions to preserve provider prompt caching. expect(requests[2]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[2]?.messages.at(-1)).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + role: "user", + content: [{ type: "text", text: MAX_STEPS_PROMPT }], }) expect(executions).toEqual(["recovered"]) const eventTypes = yield* recordedEventTypes(sessionID) @@ -4946,7 +4944,10 @@ describe("SessionRunnerLLM", () => { 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") + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "user", + content: [{ type: "text", text: MAX_STEPS_PROMPT }], + }) expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2) }), ) From 24e460fea7dd9f53ada5e11880b3569d903f6f3c Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Wed, 19 Aug 2026 14:12:22 -0700 Subject: [PATCH 3/3] no-mistakes(document): update max-steps guardrail doc to user message --- packages/web/src/content/docs/agents.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 0c0a5e228465..62c8f166412f 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -306,7 +306,7 @@ If this is not set, the agent will continue to iterate until the model chooses t } ``` -When the limit is reached, the agent receives a special system prompt instructing it to respond with a summarization of its work and recommended remaining tasks. +When the limit is reached, the agent receives a special user message instructing it to respond with a summarization of its work and recommended remaining tasks. :::caution The legacy `maxSteps` field is deprecated. Use `steps` instead.