diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index b220bd7ef87d..d88df65b7f83 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -35,6 +35,10 @@ const AgentSchema = Schema.StructWithRest( description: "Maximum number of agentic iterations before forcing text-only response", }), maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }), + maxStepsMessage: Schema.optional(Schema.Boolean).annotate({ + description: + "Append the 'maximum steps reached' assistant message on the final step (default: true). Set to false for models that reject a trailing assistant message, such as Claude 4.6+.", + }), permission: Schema.optional(ConfigPermissionV1.Info), }), [Schema.Record(Schema.String, Schema.Any)], @@ -53,6 +57,7 @@ const KNOWN_KEYS = new Set([ "color", "steps", "maxSteps", + "maxStepsMessage", "options", "permission", "disable", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b1430314fffe..07b99ae4ea68 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -52,6 +52,7 @@ export const Info = Schema.Struct({ prompt: Schema.optional(Schema.String), options: Schema.Record(Schema.String, Schema.Unknown), steps: Schema.optional(Schema.Finite), + maxStepsMessage: Schema.optional(Schema.Boolean), }).annotate({ identifier: "Agent" }) export type Info = DeepMutable> @@ -275,6 +276,7 @@ export const layer = Layer.effect( permission: Permission.merge(defaults, user), options: {}, native: false, + maxStepsMessage: true, } if (value.model) item.model = Provider.parseModel(value.model) item.variant = value.variant ?? item.variant @@ -287,10 +289,12 @@ export const layer = Layer.effect( item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.maxStepsMessage = value.maxStepsMessage ?? item.maxStepsMessage item.options = mergeDeep(item.options, value.options ?? {}) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } + for (const a of Object.values(agents)) a.maxStepsMessage ??= true // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3f85c813f20..6d4f4d854ece 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1340,7 +1340,7 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...modelMsgs, ...(isLastStep && agent.maxStepsMessage !== false ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1df95b5c0f87..818e3f1c11d2 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -322,6 +322,24 @@ it.instance( }, ) +it.instance( + "agent maxStepsMessage config sets maxStepsMessage property", + () => + Effect.gen(function* () { + const build = yield* load((svc) => svc.get("build")) + const plan = yield* load((svc) => svc.get("plan")) + expect(build?.maxStepsMessage).toBe(false) + expect(plan?.maxStepsMessage).toBe(true) + }), + { + config: { + agent: { + build: { maxStepsMessage: false }, + }, + }, + }, +) + it.instance( "agent mode can be overridden", () => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 08828018a4a3..ff8dd1019d81 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -791,6 +791,56 @@ it.instance("loop continues when finish is stop but assistant has tool parts", ( }), ) +it.instance("appends MAXIMUM STEPS REACHED message on the final step by default", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + agent: { build: { steps: 1 } }, + })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.text("done") + yield* prompt.loop({ sessionID: session.id }) + const inputs = yield* llm.inputs + expect(JSON.stringify(inputs)).toContain("MAXIMUM STEPS REACHED") + }), +) + +it.instance("omits MAXIMUM STEPS REACHED message when maxStepsMessage is false", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + agent: { build: { steps: 1, maxStepsMessage: false } }, + })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.text("done") + yield* prompt.loop({ sessionID: session.id }) + const inputs = yield* llm.inputs + expect(JSON.stringify(inputs)).not.toContain("MAXIMUM STEPS REACHED") + }), +) + it.instance("failed subtask preserves metadata on error tool state", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({