fix(core): honor configured agent step limits (#33142)

This commit is contained in:
Kit Langton 2026-06-20 23:04:30 +02:00 committed by GitHub
commit 4f1a9d7aef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 76 additions and 83 deletions

View file

@ -1,7 +1,7 @@
export * as SessionRunner from "./index" export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm" import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect, Schema } from "effect" import { Context, Effect } from "effect"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch" import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store" import type { ToolOutputStore } from "../../tool-output-store"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
{
sessionID: SessionSchema.ID,
limit: Schema.Int,
},
) {}
export type RunError = export type RunError =
| LLMError | LLMError
| SessionRunnerModel.Error | SessionRunnerModel.Error
| MessageDecodeError | MessageDecodeError
| ContextSnapshotDecodeError | ContextSnapshotDecodeError
| StepLimitExceededError
| SystemContext.InitializationBlocked | SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked | SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error | ToolOutputStore.Error

View file

@ -3,6 +3,7 @@ import {
LLMClient, LLMClient,
LLMError, LLMError,
LLMEvent, LLMEvent,
Message,
SystemPart, SystemPart,
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
import { SessionInput } from "../input" import { SessionInput } from "../input"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { type RunError, Service, StepLimitExceededError } from "./index" import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event" import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message" import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
/** /**
* Runs one durable coding-agent Session until it settles. * 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. * - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement. * - [ ] 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. * - [ ] Bound provider retries and repeated identical tool calls.
* *
* - Runtime context assembly * - 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. * 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 * 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 * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
* bounded explicit loop starts the next provider turn after local settlement. * 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( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -175,6 +174,7 @@ export const layer = Layer.effect(
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined, promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow, recoverOverflow?: typeof compaction.compactAfterOverflow,
) { ) {
const session = yield* getSession(sessionID) const session = yield* getSession(sessionID)
@ -214,7 +214,8 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session) const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message) 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 promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({ const request = LLM.request({
model, model,
@ -222,8 +223,9 @@ export const layer = Layer.effect(
system: [agent.info?.system, system.baseline] system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0) .filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make), .map(SystemPart.make),
messages: toLLMMessages(context, model), messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization.definitions, tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
}) })
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn()) return yield* Effect.die(rebuildPreparedTurn())
@ -254,6 +256,10 @@ export const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return 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 needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id) const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) => yield* Effect.uninterruptibleMask((restore) =>
@ -340,31 +346,32 @@ export const layer = Layer.effect(
type RunTurn = ( type RunTurn = (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined, promotion: SessionInput.Delivery | undefined,
step: number,
) => Effect.Effect<boolean, RunError> ) => Effect.Effect<boolean, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion).pipe( return yield* runTurnAttempt(sessionID, promotion, step).pipe(
Effect.catchDefect( Effect.catchDefect(
Effect.fnUntraced(function* (defect) { Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow 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) { const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe( return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
Effect.catchDefect( Effect.catchDefect(
Effect.fnUntraced(function* (defect) { Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction") if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined) return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, defect.transition.promotion) return yield* runTurn(sessionID, defect.transition.promotion, step)
}), }),
), ),
) )
@ -382,14 +389,11 @@ export const layer = Layer.effect(
let openActivity = input.force === true || hasSteer || hasQueue let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) { while (openActivity) {
let needsContinuation = true let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) { for (let step = 1; needsContinuation; step++) {
needsContinuation = yield* runTurn(input.sessionID, promotion) needsContinuation = yield* runTurn(input.sessionID, promotion, step)
promotion = "steer" promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "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") openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = openActivity ? "queue" : undefined promotion = openActivity ? "queue" : undefined
} }

View file

@ -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. 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 - List of any remaining tasks that were not completed
- Recommendations for what should be done next - Recommendations for what should be done next
Any attempt to use tools is a critical violation. Respond with text ONLY. Any attempt to use tools is a critical violation. Respond with text ONLY.`

View file

@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
}) })
requests.length = 0 requests.length = 0
executions.length = 0
responses = [ responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents, fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).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* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
executions.length = 0 executions.length = 0
streamGate = undefined streamGate = undefined
streamStarted = 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<void>()
streamStarted = yield* Deferred.make<void>()
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 = [ responses = [
...Array.from({ length: 24 }, (_, index) => [ ...Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }), 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.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }),
]), ]),
@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID) 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" } }] },
])
}), }),
) )

View file

@ -16,7 +16,7 @@ import { SessionCompaction } from "./compaction"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Instruction } from "./instruction" import { Instruction } from "./instruction"
import { Plugin } from "../plugin" 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 { ToolRegistry } from "@/tool/registry"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
@ -1322,7 +1322,7 @@ export const layer = Layer.effect(
sessionID, sessionID,
parentSessionID: session.parentID, parentSessionID: session.parentID,
system, system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])],
tools, tools,
model, model,
toolChoice: format.type === "json_schema" ? "required" : undefined, toolChoice: format.type === "json_schema" ? "required" : undefined,