From 4f1a9d7aef56163d8fe265f7f9cbe295cc1df95a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 20 Jun 2026 23:04:30 +0200 Subject: [PATCH] fix(core): honor configured agent step limits (#33142) --- packages/core/src/session/runner/index.ts | 11 +- packages/core/src/session/runner/llm.ts | 48 ++++---- .../src/session/runner/max-steps.ts} | 4 +- packages/core/test/session-runner.test.ts | 110 +++++++++--------- packages/opencode/src/session/prompt.ts | 4 +- 5 files changed, 85 insertions(+), 92 deletions(-) rename packages/{opencode/src/session/prompt/max-steps.txt => core/src/session/runner/max-steps.ts} (90%) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 67a110413..4060cc6b0 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -1,7 +1,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" -import { Context, Effect, Schema } from "effect" +import { Context, Effect } from "effect" import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" @@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index" import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" -export class StepLimitExceededError extends Schema.TaggedErrorClass()( - "SessionRunner.StepLimitExceededError", - { - sessionID: SessionSchema.ID, - limit: Schema.Int, - }, -) {} - export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError | ContextSnapshotDecodeError - | StepLimitExceededError | SystemContext.InitializationBlocked | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02a1eb3fe..233a4aa4d 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { LLMClient, LLMError, LLMEvent, + Message, SystemPart, isContextOverflowFailure, type ProviderErrorEvent, @@ -29,10 +30,11 @@ import { SessionHistory } from "../history" import { SessionInput } from "../input" import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { type RunError, Service, StepLimitExceededError } from "./index" +import { type RunError, Service } from "./index" import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" +import { MAX_STEPS_PROMPT } from "./max-steps" /** * Runs one durable coding-agent Session until it settles. @@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message" * - [ ] Replace local ownership with durable multi-node ownership when clustered. * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. - * - [x] Bound model steps. + * - [x] Honor optional agent step limits. * - [ ] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly @@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message" * Durable activity recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and a - * bounded explicit loop starts the next provider turn after local settlement. + * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an + * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -// QUESTION: Did this exist previously, or did we add this limit? Does it make sense? -const MAX_STEPS = 25 - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -175,6 +174,7 @@ export const layer = Layer.effect( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, ) { const session = yield* getSession(sessionID) @@ -214,7 +214,8 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const toolMaterialization = yield* tools.materialize(agent.info?.permissions) + const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -222,8 +223,9 @@ export const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: toLLMMessages(context, model), - tools: toolMaterialization.definitions, + messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + tools: toolMaterialization?.definitions ?? [], + toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(rebuildPreparedTurn()) @@ -254,6 +256,10 @@ export const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return + if (!toolMaterialization) { + yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + return + } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) yield* Effect.uninterruptibleMask((restore) => @@ -340,31 +346,32 @@ export const layer = Layer.effect( type RunTurn = ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, ) => Effect.Effect - const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion).pipe( + const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) }), ), ) }) - const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe( + const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined) - return yield* runTurn(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runTurn(sessionID, defect.transition.promotion, step) }), ), ) @@ -382,14 +389,11 @@ export const layer = Layer.effect( let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true - for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion) + for (let step = 1; needsContinuation; step++) { + needsContinuation = yield* runTurn(input.sessionID, promotion, step) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") - if (!needsContinuation) break } - if (needsContinuation) - return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/core/src/session/runner/max-steps.ts similarity index 90% rename from packages/opencode/src/session/prompt/max-steps.txt rename to packages/core/src/session/runner/max-steps.ts index 3aefa7377..040584ab1 100644 --- a/packages/opencode/src/session/prompt/max-steps.txt +++ b/packages/core/src/session/runner/max-steps.ts @@ -1,4 +1,4 @@ -CRITICAL - MAXIMUM STEPS REACHED +export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. @@ -13,4 +13,4 @@ Response must include: - List of any remaining tasks that were not completed - Recommendations for what should be done next -Any attempt to use tools is a critical violation. Respond with text ONLY. \ No newline at end of file +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index af17de175..c3089da0d 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => { }) requests.length = 0 + executions.length = 0 responses = [ fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents, fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, @@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails after the bounded number of local tool continuation steps", () => + it.effect("continues past 25 local tool steps when the agent has no step limit", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => { executions.length = 0 streamGate = undefined streamStarted = undefined - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - - const failure = yield* session.resume(sessionID).pipe(Effect.flip) - - expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 }) - expect(requests).toHaveLength(25) - expect(executions).toHaveLength(25) - }), - ) - - it.effect("does not restart a capped tool loop for a coalesced stale wake", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const coordinator = yield* SessionRunCoordinator.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - - const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) - yield* coordinator.wake(sessionID) - yield* Deferred.succeed(streamGate, undefined) - expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" }) - streamGate = undefined - streamStarted = undefined - yield* Effect.yieldNow - - expect(requests).toHaveLength(25) - }), - ) - - it.effect("accepts a terminal response on the final bounded provider turn", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) - - requests.length = 0 responses = [ - ...Array.from({ length: 24 }, (_, index) => [ + ...Array.from({ length: 25 }, (_, index) => [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }), + LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]), @@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(25) + expect(requests).toHaveLength(26) + expect(executions).toHaveLength(25) + }), + ) + + it.effect("forces a text response on an agent's configured final step", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[1]?.tools).toEqual([]) + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["done"]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish at the limit" }, + { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, + { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, + ]) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b616df6e5..299a0b6b1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -16,7 +16,7 @@ import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" -import MAX_STEPS from "../session/prompt/max-steps.txt" +import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" @@ -1322,7 +1322,7 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined,