fix(core): recover malformed tool input
This commit is contained in:
parent
33f1b269e9
commit
7840a0d378
8 changed files with 109 additions and 16 deletions
|
|
@ -153,7 +153,21 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
|
||||||
* routes: `Invalid JSON input for <route> tool call <name>`.
|
* routes: `Invalid JSON input for <route> tool call <name>`.
|
||||||
*/
|
*/
|
||||||
export const parseToolInput = (route: string, name: string, raw: string) =>
|
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 IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
||||||
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
route: Schema.optional(Schema.String),
|
route: Schema.optional(Schema.String),
|
||||||
raw: Schema.optional(Schema.String),
|
raw: Schema.optional(Schema.String),
|
||||||
|
source: Schema.optional(Schema.Literal("tool-input")),
|
||||||
|
toolName: Schema.optional(Schema.String),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,25 @@ describe("ToolStream", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("classifies malformed tool input with its raw arguments", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tools = ToolStream.start(ToolStream.empty<number>(), 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", () =>
|
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
|
|
||||||
|
|
@ -1839,6 +1839,7 @@ export type SessionMessageToolStateCompleted = {
|
||||||
export type SessionMessageToolStateError = {
|
export type SessionMessageToolStateError = {
|
||||||
status: "error"
|
status: "error"
|
||||||
input: { [x: string]: JsonValue }
|
input: { [x: string]: JsonValue }
|
||||||
|
raw?: string
|
||||||
content: Array<LLMToolContent>
|
content: Array<LLMToolContent>
|
||||||
structured: { [x: string]: JsonValue }
|
structured: { [x: string]: JsonValue }
|
||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||||
status: "error",
|
status: "error",
|
||||||
error: event.data.error,
|
error: event.data.error,
|
||||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
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 : {},
|
structured: match.state.status === "running" ? match.state.structured : {},
|
||||||
content: match.state.status === "running" ? match.state.content : [],
|
content: match.state.status === "running" ? match.state.content : [],
|
||||||
result: event.data.result,
|
result: event.data.result,
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,16 @@ const layer = Layer.effect(
|
||||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||||
if (overflowFailure) yield* publish(overflowFailure)
|
if (overflowFailure) yield* publish(overflowFailure)
|
||||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
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()) {
|
if (llmFailure && !publisher.hasProviderError()) {
|
||||||
const error = toSessionError(llmFailure)
|
const error = toSessionError(llmFailure)
|
||||||
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
|
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
|
||||||
|
|
@ -327,15 +337,15 @@ const layer = Layer.effect(
|
||||||
if (stepFailure)
|
if (stepFailure)
|
||||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
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 (userDeclined) return yield* Effect.interrupt
|
||||||
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
|
||||||
return yield* Effect.failCause(settledFailure)
|
return yield* Effect.failCause(settledFailure)
|
||||||
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
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 {
|
return {
|
||||||
_tag: "Completed",
|
_tag: "Completed",
|
||||||
needsContinuation,
|
needsContinuation: needsContinuation || recoveredMalformedToolInput,
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
} as const
|
} as const
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Call a malformed tool")
|
yield* admit(session, "Call a malformed tool")
|
||||||
const failure = new LLMError({
|
const failure = new LLMError({
|
||||||
module: "test",
|
module: "test",
|
||||||
method: "stream",
|
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([
|
responseStreams = [
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
Stream.fromIterable([
|
||||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
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))
|
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||||
|
|
||||||
response = reply.stop()
|
expect(requests).toHaveLength(2)
|
||||||
yield* admit(session, "Continue")
|
expect(assistant.content).toMatchObject([
|
||||||
yield* session.resume(sessionID)
|
{
|
||||||
|
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([
|
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||||
{ type: "session.step.started.1" },
|
{ type: "session.step.started.1" },
|
||||||
|
|
@ -4153,7 +4195,10 @@ describe("SessionRunnerLLM", () => {
|
||||||
type: "session.tool.failed.1",
|
type: "session.tool.failed.1",
|
||||||
data: {
|
data: {
|
||||||
callID: "call-malformed",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ export interface ToolStateError extends Schema.Schema.Type<typeof ToolStateError
|
||||||
export const ToolStateError = Schema.Struct({
|
export const ToolStateError = Schema.Struct({
|
||||||
status: Schema.tag("error"),
|
status: Schema.tag("error"),
|
||||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
raw: Schema.String.pipe(optional),
|
||||||
content: ToolContent.pipe(Schema.Array),
|
content: ToolContent.pipe(Schema.Array),
|
||||||
structured: Schema.Record(Schema.String, Schema.Unknown),
|
structured: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
error: SessionError.Error,
|
error: SessionError.Error,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue