From 7840a0d378a41995ef0b2bbe84663c4050bac4da Mon Sep 17 00:00:00 2001 From: Dustin Deus <1764424+StarpTech@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:13:52 +0000 Subject: [PATCH] fix(core): recover malformed tool input --- packages/ai/src/protocols/shared.ts | 16 ++++- packages/ai/src/schema/errors.ts | 2 + packages/ai/test/tool-stream.test.ts | 19 +++++ .../client/src/promise/generated/types.ts | 1 + packages/core/src/session/message-updater.ts | 1 + packages/core/src/session/runner/llm.ts | 16 ++++- packages/core/test/session-runner.test.ts | 69 +++++++++++++++---- packages/schema/src/session-message.ts | 1 + 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 173dc511bb..3072af836f 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -153,7 +153,21 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate * routes: `Invalid JSON input for tool call `. */ export const parseToolInput = (route: string, name: string, raw: string) => - parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) + Effect.try({ + try: () => decodeJson(raw || "{}"), + catch: () => + new LLMError({ + module: "ProviderShared", + method: "stream", + reason: new InvalidProviderOutputReason({ + route, + message: `Invalid JSON input for ${route} tool call ${name}`, + raw, + source: "tool-input", + toolName: name, + }), + }), + }) export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 82acb7cb78..d4bb693a32 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -106,6 +106,8 @@ export class InvalidProviderOutputReason extends Schema.Class { }), ) + it.effect("classifies malformed tool input with its raw arguments", () => + Effect.gen(function* () { + const tools = ToolStream.start(ToolStream.empty(), 0, { + id: "call_1", + name: "lookup", + input: '{"query":"partial', + }) + const error = yield* ToolStream.finish(ADAPTER, tools, 0).pipe(Effect.flip) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ + _tag: "InvalidProviderOutput", + source: "tool-input", + toolName: "lookup", + raw: '{"query":"partial', + }) + }), + ) + it.effect("preserves providerExecuted and clears all tools", () => Effect.gen(function* () { const first: ToolStream.State = ToolStream.start(ToolStream.empty(), 0, { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a22956f74b..fc8720d212 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1839,6 +1839,7 @@ export type SessionMessageToolStateCompleted = { export type SessionMessageToolStateError = { status: "error" input: { [x: string]: JsonValue } + raw?: string content: Array structured: { [x: string]: JsonValue } error: SessionStructuredError diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index e4c895284c..4b463e872e 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -396,6 +396,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, + raw: typeof match.state.input === "string" ? match.state.input : undefined, structured: match.state.status === "running" ? match.state.structured : {}, content: match.state.status === "running" ? match.state.content : [], result: event.data.result, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6ed210fde1..96155ef06e 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -243,6 +243,16 @@ const layer = Layer.effect( // already recorded from the stream. Terminal publication waits for owned tools. if (overflowFailure) yield* publish(overflowFailure) const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined + const malformedToolInput = + llmFailure?.reason._tag === "InvalidProviderOutput" && llmFailure.reason.source === "tool-input" + const recoveredMalformedToolInput = malformedToolInput + ? yield* serialized( + publisher.failUnsettledTools({ + type: "provider.invalid-output", + message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", + }), + ) + : false if (llmFailure && !publisher.hasProviderError()) { const error = toSessionError(llmFailure) if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) { @@ -327,15 +337,15 @@ const layer = Layer.effect( if (stepFailure) yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined)) - if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) + if (stream._tag === "Failure" && !recoveredMalformedToolInput) 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) return yield* new StepFailedError({ error: stepFailure }) + if (stepFailure && !recoveredMalformedToolInput) return yield* new StepFailedError({ error: stepFailure }) return { _tag: "Completed", - needsContinuation, + needsContinuation: needsContinuation || recoveredMalformedToolInput, step: currentStep, } as const }), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6bd8b23be1..da67e9e0c1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -4125,27 +4125,69 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("settles malformed streamed tool input before the provider failure", () => + it.effect("continues after malformed streamed tool input without executing it", () => Effect.gen(function* () { const session = yield* setup yield* admit(session, "Call a malformed tool") const failure = new LLMError({ module: "test", method: "stream", - reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }), + reason: new InvalidProviderOutputReason({ + message: "Invalid JSON input for tool call echo", + raw: '{"text":"partial', + source: "tool-input", + toolName: "echo", + }), }) - responseStream = Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), - LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }), - ]).pipe(Stream.concat(Stream.fail(failure))) + responseStreams = [ + Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }), + LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }), + ]).pipe(Stream.concat(Stream.fail(failure))), + Stream.fromIterable(reply.stop()), + ] - expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) + yield* session.resume(sessionID) const assistant = requireAssistant(yield* session.context(sessionID)) - response = reply.stop() - yield* admit(session, "Continue") - yield* session.resume(sessionID) + expect(requests).toHaveLength(2) + expect(assistant.content).toMatchObject([ + { + type: "tool", + id: "call-malformed", + executed: false, + state: { + status: "error", + input: {}, + raw: '{"text":"partial', + error: { + message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", + }, + }, + }, + ]) + expect(requests[1].messages).toMatchObject([ + { role: "user" }, + { role: "assistant", content: [{ type: "tool-call", id: "call-malformed", input: {} }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + id: "call-malformed", + result: { + type: "error", + value: { + error: { + message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", + }, + }, + }, + }, + ], + }, + ]) expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ { type: "session.step.started.1" }, @@ -4153,7 +4195,10 @@ describe("SessionRunnerLLM", () => { type: "session.tool.failed.1", data: { callID: "call-malformed", - error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, + error: { + type: "provider.invalid-output", + message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.", + }, }, }, { diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 3d45e283e6..e0d3c32551 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -127,6 +127,7 @@ export interface ToolStateError extends Schema.Schema.Type