Ver código fonte

fix(core): continue after malformed tool input (#37701)

Kit Langton 3 semanas atrás
pai
commit
a288cb5a0c

+ 8 - 33
packages/core/src/session/runner/llm.ts

@@ -80,7 +80,6 @@ const layer = Layer.effect(
       sessionID: SessionSchema.ID,
       promotion: SessionPending.Delivery | undefined,
       step: number,
-      recoverMalformedToolInput: boolean,
       recoverOverflow?: typeof compaction.compact,
       assistantMessageID?: SessionMessage.ID,
     ) {
@@ -144,6 +143,10 @@ const layer = Layer.effect(
               }
             }
             yield* publish(event)
+            if (LLMEvent.is.toolInputError(event)) {
+              if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
+              return
+            }
             if (event.type !== "tool-call" || event.providerExecuted) return
             const tool = prepared.resolveToolCall(event.name)
             if (tool.type === "reject") {
@@ -340,27 +343,15 @@ const layer = Layer.effect(
             )
           }
 
-          const recoveredMalformedToolInput =
-            recoverMalformedToolInput &&
-            publisher.hasMalformedToolInput() &&
-            stream._tag === "Success" &&
-            stepSettlement !== undefined &&
-            !providerFailed &&
-            !streamInterrupted &&
-            !userDeclined &&
-            !toolsInterrupted &&
-            infraError === undefined
-
           if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
           if (userDeclined) return yield* Effect.interrupt
           if ((toolsInterrupted || infraError !== undefined) && settledFailure)
             return yield* Effect.failCause(settledFailure)
           if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
-          if (stepFailure && !recoveredMalformedToolInput) return yield* new StepFailedError({ error: stepFailure })
+          if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
           return {
             _tag: "Completed",
-            needsContinuation: needsContinuation || recoveredMalformedToolInput,
-            malformedToolInput: recoveredMalformedToolInput,
+            needsContinuation,
             step: currentStep,
           } as const
         }),
@@ -371,7 +362,6 @@ const layer = Layer.effect(
       sessionID: SessionSchema.ID,
       promotion: SessionPending.Delivery | undefined,
       step: number,
-      recoverMalformedToolInput: boolean,
     ) {
       // Compaction restarts rebuild the request from compacted history without re-promoting.
       // Overflow recovery is one-shot: a post-compaction attempt must not recover another
@@ -382,14 +372,7 @@ const layer = Layer.effect(
       let assistantMessageID: SessionMessage.ID | undefined
       while (true) {
         const attempt = yield* Effect.suspend(() =>
-          attemptStep(
-            sessionID,
-            currentPromotion,
-            currentStep,
-            recoverMalformedToolInput,
-            recoverOverflow,
-            assistantMessageID,
-          ),
+          attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
         ).pipe(
           Effect.tapError((error) =>
             error instanceof SessionRunnerRetry.RetryableFailure
@@ -414,7 +397,6 @@ const layer = Layer.effect(
         if (attempt._tag === "Completed")
           return {
             needsContinuation: attempt.needsContinuation,
-            malformedToolInput: attempt.malformedToolInput,
             step: attempt.step,
           }
         if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
@@ -474,18 +456,12 @@ const layer = Layer.effect(
       while (shouldRun) {
         let needsContinuation = true
         let step = 1
-        let canRecoverMalformedToolInput = true
         // Repeat steps while continuation is needed. A step needs continuation only
         // when it recorded local tool calls whose results the model has not yet seen;
         // a provider error suppresses it. Pending steers also continue the loop so
         // interjections are answered before the session goes idle.
         while (needsContinuation) {
-          const result = yield* runStep(
-            input.sessionID,
-            promotion,
-            step,
-            canRecoverMalformedToolInput,
-          )
+          const result = yield* runStep(input.sessionID, promotion, step)
           // Steer/queue promotion inside runStep has already made the pending input a visible
           // user message by this point, so the first-user-message check below is reliable.
           if (!titleAttempted.has(input.sessionID)) {
@@ -493,7 +469,6 @@ const layer = Layer.effect(
             forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
           }
           needsContinuation = result.needsContinuation
-          if (result.malformedToolInput) canRecoverMalformedToolInput = false
           step = result.step + 1
           if (needsContinuation) {
             yield* runPendingCompaction(input.sessionID)

+ 0 - 5
packages/core/src/session/runner/publish-llm-event.ts

@@ -61,7 +61,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
   let stepFailed = false
   let providerFailed = false
   let retryEvidence = false
-  let malformedToolInput = false
   let stepFailure: SessionError.Error | undefined
   let stepSettlement:
     | {
@@ -216,7 +215,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
     readonly id: string
     readonly name: string
     readonly raw: string
-    readonly message: string
   }) {
     if (!tools.has(event.id)) yield* startToolInput(event)
     const tool = tools.get(event.id)
@@ -226,7 +224,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
       return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
     if (toolInput.has(event.id)) yield* endToolInput(event, event.raw)
     tool.settled = true
-    malformedToolInput = true
     yield* events.publish(SessionEvent.Tool.Failed, {
       sessionID: input.sessionID,
       assistantMessageID: tool.assistantMessageID,
@@ -237,7 +234,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
       },
       executed: false,
     })
-    if (stepFailure === undefined) stepFailure = { type: "provider.invalid-output", message: event.message }
   })
 
   const flush = Effect.fn("SessionRunner.flush")(function* () {
@@ -482,7 +478,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
     publishStepFailure,
     failUnsettledTools,
     hasProviderError: () => providerFailed,
-    hasMalformedToolInput: () => malformedToolInput,
     hasRetryEvidence: () => retryEvidence,
     stepFailure: () => stepFailure,
     stepSettlement: () => stepSettlement,

+ 39 - 12
packages/core/test/session-runner.test.ts

@@ -4164,7 +4164,7 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("continues once after malformed local tool input without exposing raw arguments", () =>
+  it.effect("continues after malformed local tool input without exposing raw arguments", () =>
     Effect.gen(function* () {
       const session = yield* setup
       yield* admit(session, "Recover malformed tool input")
@@ -4226,7 +4226,6 @@ describe("SessionRunnerLLM", () => {
           message.type === "assistant" && message.content.some((item) => item.type === "tool"),
       )
       expect(failed).toMatchObject({
-        error: { type: "provider.invalid-output", message: "Invalid JSON input for test tool call echo" },
         content: [
           {
             type: "tool",
@@ -4244,10 +4243,11 @@ describe("SessionRunnerLLM", () => {
         ],
       })
       if (!failed) throw new Error("Malformed tool assistant missing")
+      expect(failed.error).toBeUndefined()
       expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
         "session.step.started.1",
         "session.tool.failed.1",
-        "session.step.failed.1",
+        "session.step.ended.1",
       ])
       const database = (yield* Database.Service).db
       const durable = yield* database
@@ -4422,7 +4422,7 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
-  it.effect("does not reset the malformed recovery budget after a valid tool step", () =>
+  it.effect("continues after repeated malformed tool input", () =>
     Effect.gen(function* () {
       const session = yield* setup
       yield* admit(session, "Keep producing malformed tools")
@@ -4444,16 +4444,43 @@ describe("SessionRunnerLLM", () => {
         reply.stop(),
       ]
 
-      expect(yield* session.resume(sessionID).pipe(Effect.flip)).toMatchObject({
-        error: {
-          type: "provider.invalid-output",
-          message: "Invalid JSON input for test tool call echo",
-        },
-      })
+      yield* session.resume(sessionID)
 
-      expect(requests).toHaveLength(3)
+      expect(requests).toHaveLength(4)
       expect(executions).toEqual(["valid"])
-      expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.failed.1")).toHaveLength(2)
+      expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.failed.1")).toHaveLength(0)
+    }),
+  )
+
+  it.effect("does not continue malformed tool input past the agent step limit", () =>
+    Effect.gen(function* () {
+      const session = yield* setup
+      const agents = yield* AgentV2.Service
+      yield* agents.transform((editor) =>
+        editor.update(AgentV2.ID.make("build"), (agent) => {
+          agent.steps = 2
+        }),
+      )
+      yield* admit(session, "Stop malformed tools at the step limit")
+      const malformed = (id: string) => [
+        LLMEvent.stepStart({ index: 0 }),
+        LLMEvent.toolInputError({
+          id,
+          name: "echo",
+          raw: '{"text":"partial',
+          message: "Invalid JSON input for test tool call echo",
+        }),
+        LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
+        LLMEvent.finish({ reason: "tool-calls" }),
+      ]
+      responses = [malformed("call-first"), malformed("call-at-limit")]
+
+      yield* session.resume(sessionID)
+
+      expect(requests).toHaveLength(2)
+      expect(requests[0]?.toolChoice).toBeUndefined()
+      expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
+      expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2)
     }),
   )