diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index f744983afec8..359ff4624593 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -197,7 +197,9 @@ 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 + // 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 // 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..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,6 +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)).toMatchObject({ + role: "user", + content: [{ type: "text", text: MAX_STEPS_PROMPT }], + }) expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2) }), ) 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.