소스 검색

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

Kit Langton 2 달 전
부모
커밋
4f1a9d7aef

+ 1 - 10
packages/core/src/session/runner/index.ts

@@ -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

+ 26 - 22
packages/core/src/session/runner/llm.ts

@@ -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
- * 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(
 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),
-        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 }))
       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) {
-      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.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) {
-      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.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* 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
       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++) {
-          needsContinuation = yield* runTurn(input.sessionID, promotion)
+        for (let step = 1; needsContinuation; step++) {
+          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
       }
       }

+ 2 - 2
packages/opencode/src/session/prompt/max-steps.txt → 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.
 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.`

+ 45 - 47
packages/core/test/session-runner.test.ts

@@ -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,75 +3189,72 @@ 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" }),
-      ])
+      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" }),
+        ]),
+        [
+          LLMEvent.stepStart({ index: 0 }),
+          LLMEvent.stepFinish({ index: 0, reason: "stop" }),
+          LLMEvent.finish({ reason: "stop" }),
+        ],
+      ]
 
 
-      const failure = yield* session.resume(sessionID).pipe(Effect.flip)
+      yield* session.resume(sessionID)
 
 
-      expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
-      expect(requests).toHaveLength(25)
+      expect(requests).toHaveLength(26)
       expect(executions).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", () =>
+  it.effect("forces a text response on an agent's configured final step", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       yield* setup
       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
       const session = yield* SessionV2.Service
       yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
       yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
 
 
       requests.length = 0
       requests.length = 0
+      executions.length = 0
       responses = [
       responses = [
-        ...Array.from({ length: 24 }, (_, index) => [
+        [
           LLMEvent.stepStart({ index: 0 }),
           LLMEvent.stepStart({ index: 0 }),
-          LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
+          LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
           LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
           LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
           LLMEvent.finish({ reason: "tool-calls" }),
           LLMEvent.finish({ reason: "tool-calls" }),
-        ]),
+        ],
         [
         [
           LLMEvent.stepStart({ index: 0 }),
           LLMEvent.stepStart({ index: 0 }),
-          LLMEvent.stepFinish({ index: 0, reason: "stop" }),
-          LLMEvent.finish({ reason: "stop" }),
+          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)
       yield* session.resume(sessionID)
 
 
-      expect(requests).toHaveLength(25)
+      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" } }] },
+      ])
     }),
     }),
   )
   )
 
 

+ 2 - 2
packages/opencode/src/session/prompt.ts

@@ -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,