From a288cb5a0c9e6f22b13dfd8506edc46a3bb77f3b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Jul 2026 23:09:08 -0400 Subject: [PATCH] fix(core): continue after malformed tool input (#37701) --- packages/core/src/session/runner/llm.ts | 41 +++------------ .../src/session/runner/publish-llm-event.ts | 5 -- packages/core/test/session-runner.test.ts | 51 ++++++++++++++----- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a6c7f967493..7f129ed0459 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/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) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 0a07e92a759..96f4deb3fe1 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -61,7 +61,6 @@ export const createLLMEventPublisher = (events: Pick ${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 providerFailed, - hasMalformedToolInput: () => malformedToolInput, hasRetryEvidence: () => retryEvidence, stepFailure: () => stepFailure, stepSettlement: () => stepSettlement, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index df8134cf2b5..c0361e8722b 100644 --- a/packages/core/test/session-runner.test.ts +++ b/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) }), )