fix(core): safely recover malformed tool input (#37698)
This commit is contained in:
parent
584fdefe6f
commit
57ff57595a
22 changed files with 876 additions and 93 deletions
|
|
@ -30,6 +30,7 @@ import {
|
|||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
|
@ -605,6 +606,7 @@ function streamPartEvents(
|
|||
LLMEvent.toolInputStart({
|
||||
id: event.id,
|
||||
name: event.toolName,
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
|
|
@ -622,15 +624,30 @@ function streamPartEvents(
|
|||
])
|
||||
case "tool-call":
|
||||
state.toolNames[event.toolCallId] = event.toolName
|
||||
return Effect.succeed([
|
||||
LLMEvent.toolCall({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
input: parseToolInput(event.input),
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
return ProviderShared.parseToolInput("aisdk", event.toolName, event.input).pipe(
|
||||
Effect.map((input) => [
|
||||
LLMEvent.toolCall({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
input,
|
||||
providerExecuted: event.providerExecuted,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
]),
|
||||
Effect.catch((error) =>
|
||||
event.providerExecuted
|
||||
? Effect.fail(error)
|
||||
: Effect.succeed([
|
||||
LLMEvent.toolInputError({
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
raw: event.input,
|
||||
message: error.reason.message,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
case "tool-result":
|
||||
delete state.toolNames[event.toolCallId]
|
||||
return Effect.succeed([
|
||||
|
|
@ -685,14 +702,6 @@ function providerMetadata(value: unknown) {
|
|||
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
||||
}
|
||||
|
||||
function parseToolInput(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function jsonObject(input: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,6 +297,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
draft.cost = event.data.cost
|
||||
draft.tokens = castDraft(event.data.tokens)
|
||||
}
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
end: event.data.snapshot,
|
||||
files: event.data.files ? Array.from(event.data.files) : undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.text.started": (event) => {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ const layer = Layer.effect(
|
|||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionPending.Delivery | undefined,
|
||||
step: number,
|
||||
recoverMalformedToolInput: boolean,
|
||||
recoverOverflow?: typeof compaction.compact,
|
||||
assistantMessageID?: SessionMessage.ID,
|
||||
) {
|
||||
|
|
@ -195,25 +196,27 @@ const layer = Layer.effect(
|
|||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
// Captures the end snapshot, diffs it against the step's start, and durably ends the
|
||||
// assistant step.
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
return { snapshot, files }
|
||||
})
|
||||
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
Effect.gen(function* () {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
? yield* snapshots
|
||||
.files({ from: startSnapshot, to: endSnapshot })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
...stepUsage(settlement),
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -253,7 +256,7 @@ const layer = Layer.effect(
|
|||
step: currentStep,
|
||||
})
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
yield* serialized(publisher.failAssistant(error, true))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
|
@ -274,7 +277,7 @@ const layer = Layer.effect(
|
|||
if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers)
|
||||
if (userDeclined || streamInterrupted || toolsInterrupted) {
|
||||
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
|
||||
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }, true))
|
||||
}
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the step still
|
||||
|
|
@ -287,7 +290,7 @@ const layer = Layer.effect(
|
|||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const error = toSessionError(failure)
|
||||
yield* serialized(publisher.failUnsettledTools(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
|
||||
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error, true))
|
||||
}
|
||||
|
||||
// Fail unresolved calls before the terminal step event. Local calls have joined, so
|
||||
|
|
@ -315,27 +318,49 @@ const layer = Layer.effect(
|
|||
: false
|
||||
if (hostedResultMissing && !publisher.stepSettlement())
|
||||
yield* serialized(
|
||||
publisher.failAssistant({
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
}),
|
||||
publisher.failAssistant(
|
||||
{
|
||||
type: "tool.result-missing",
|
||||
message: "Provider did not return a tool result",
|
||||
},
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
const stepFailure = publisher.stepFailure()
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure)
|
||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
||||
if (stepFailure) {
|
||||
const end = yield* captureStepEnd()
|
||||
yield* serialized(
|
||||
publisher.publishStepFailure({
|
||||
...(stepSettlement ? stepUsage(stepSettlement) : {}),
|
||||
...end,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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) return yield* new StepFailedError({ error: stepFailure })
|
||||
if (stepFailure && !recoveredMalformedToolInput) return yield* new StepFailedError({ error: stepFailure })
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation,
|
||||
needsContinuation: needsContinuation || recoveredMalformedToolInput,
|
||||
malformedToolInput: recoveredMalformedToolInput,
|
||||
step: currentStep,
|
||||
} as const
|
||||
}),
|
||||
|
|
@ -346,6 +371,7 @@ 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
|
||||
|
|
@ -356,7 +382,14 @@ const layer = Layer.effect(
|
|||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
while (true) {
|
||||
const attempt = yield* Effect.suspend(() =>
|
||||
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||
attemptStep(
|
||||
sessionID,
|
||||
currentPromotion,
|
||||
currentStep,
|
||||
recoverMalformedToolInput,
|
||||
recoverOverflow,
|
||||
assistantMessageID,
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapError((error) =>
|
||||
error instanceof SessionRunnerRetry.RetryableFailure
|
||||
|
|
@ -378,7 +411,12 @@ const layer = Layer.effect(
|
|||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||
}),
|
||||
)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "Completed")
|
||||
return {
|
||||
needsContinuation: attempt.needsContinuation,
|
||||
malformedToolInput: attempt.malformedToolInput,
|
||||
step: attempt.step,
|
||||
}
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
|
|
@ -436,12 +474,18 @@ 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)
|
||||
const result = yield* runStep(
|
||||
input.sessionID,
|
||||
promotion,
|
||||
step,
|
||||
canRecoverMalformedToolInput,
|
||||
)
|
||||
// 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)) {
|
||||
|
|
@ -449,6 +493,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SessionError } from "@opencode-ai/schema/session-error"
|
|||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
|
||||
type Input = {
|
||||
|
|
@ -60,6 +61,7 @@ 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:
|
||||
| {
|
||||
|
|
@ -112,12 +114,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||
return Effect.succeed(current.ordinal)
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* ended(
|
||||
id,
|
||||
current.values.join(""),
|
||||
value ?? current.values.join(""),
|
||||
current.ordinal,
|
||||
state === undefined ? current.state : { ...current.state, ...state },
|
||||
)
|
||||
|
|
@ -175,7 +177,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
yield* toolInput.flush()
|
||||
})
|
||||
|
||||
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
const startToolInput = Effect.fnUntraced(function* (event: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerExecuted?: boolean
|
||||
}) {
|
||||
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
tools.set(event.id, {
|
||||
|
|
@ -183,7 +189,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
name: event.name,
|
||||
called: false,
|
||||
settled: false,
|
||||
providerExecuted: false,
|
||||
providerExecuted: event.providerExecuted === true,
|
||||
})
|
||||
yield* toolInput.start(event.id)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
|
|
@ -194,13 +200,44 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
})
|
||||
})
|
||||
|
||||
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
||||
const endToolInput = Effect.fnUntraced(function* (
|
||||
event: { readonly id: string; readonly name: string },
|
||||
value?: string,
|
||||
) {
|
||||
const tool = tools.get(event.id)
|
||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
||||
yield* toolInput.end(event.id)
|
||||
yield* toolInput.end(event.id, undefined, value)
|
||||
})
|
||||
|
||||
const failMalformedToolInput = Effect.fnUntraced(function* (event: {
|
||||
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)
|
||||
if (!tool || tool.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Malformed tool input after call settlement: ${event.id}`))
|
||||
if (tool.name !== event.name)
|
||||
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,
|
||||
callID: event.id,
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
},
|
||||
executed: false,
|
||||
})
|
||||
if (stepFailure === undefined) stepFailure = { type: "provider.invalid-output", message: event.message }
|
||||
})
|
||||
|
||||
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||
|
|
@ -236,9 +273,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
|
||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||
readonly cost: Money.USD
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
const publishStepFailure = Effect.fnUntraced(function* (details?: {
|
||||
readonly cost?: Money.USD
|
||||
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
|
|
@ -247,7 +286,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: stepFailure,
|
||||
...usage,
|
||||
...details,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -337,6 +376,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
case "tool-input-end":
|
||||
yield* endToolInput(event)
|
||||
return
|
||||
case "tool-input-error":
|
||||
retryEvidence = true
|
||||
yield* failMalformedToolInput(event)
|
||||
return
|
||||
case "tool-call": {
|
||||
retryEvidence = true
|
||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||
|
|
@ -439,6 +482,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
publishStepFailure,
|
||||
failUnsettledTools,
|
||||
hasProviderError: () => providerFailed,
|
||||
hasMalformedToolInput: () => malformedToolInput,
|
||||
hasRetryEvidence: () => retryEvidence,
|
||||
stepFailure: () => stepFailure,
|
||||
stepSettlement: () => stepSettlement,
|
||||
|
|
|
|||
|
|
@ -136,14 +136,20 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
|||
: item.text.length > 0
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
const reuseToolProviderMetadata =
|
||||
sameModel &&
|
||||
(message.error === undefined ||
|
||||
(item.executed === true &&
|
||||
(item.state.status === "completed" ||
|
||||
(item.state.status === "error" && item.state.result !== undefined))))
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
reuseToolProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
|
@ -19,6 +19,37 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
|||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () =>
|
||||
Promise.resolve({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
events.forEach((event) => controller.enqueue(event))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const usage = {
|
||||
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
outputTokens: { total: 1, text: 0, reasoning: 0 },
|
||||
} as const
|
||||
|
||||
const client = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
|
@ -238,3 +269,69 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
|||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const raw = '{"query":"partial'
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
{ type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
|
||||
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
|
||||
Effect.provide(client),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw,
|
||||
message: "Invalid JSON input for aisdk tool call lookup",
|
||||
})
|
||||
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const raw = '{"query":"partial'
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = {
|
||||
languageModel: () =>
|
||||
streamModel([
|
||||
{ type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
|
||||
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||
{ type: "tool-input-end", id: "call_1" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "web_search",
|
||||
input: raw,
|
||||
providerExecuted: true,
|
||||
},
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
|
||||
const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -565,6 +565,22 @@ Recent work
|
|||
text: "Partial thought",
|
||||
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
executed: true,
|
||||
providerState: { itemId: "call_completed" },
|
||||
providerResultState: { itemId: "result_completed" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { found: true } },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
|
|
@ -592,6 +608,22 @@ Recent work
|
|||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Partial thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { provider: { itemId: "call_completed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-completed",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { found: true } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { provider: { itemId: "result_completed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||
|
|
@ -229,6 +231,8 @@ test("content-filter finish retains failure evidence until step closeout", async
|
|||
publisher.publishStepFailure({
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens: settlement.tokens,
|
||||
snapshot: Snapshot.ID.make("tree-end"),
|
||||
files: [RelativePath.make("src/changed.ts")],
|
||||
}),
|
||||
)
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
|
|
@ -236,6 +240,8 @@ test("content-filter finish retains failure evidence until step closeout", async
|
|||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
cost: 1.25,
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
snapshot: "tree-end",
|
||||
files: ["src/changed.ts"],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4164,6 +4164,299 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("continues once after malformed local tool input without exposing raw arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover malformed tool input")
|
||||
const marker = "raw-malformed-marker"
|
||||
const raw = `{"text":"${marker}`
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: raw }),
|
||||
LLMEvent.toolInputEnd({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw,
|
||||
message: "Invalid JSON input for test tool call echo",
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual([])
|
||||
expect(JSON.stringify(requests[1])).not.toContain(marker)
|
||||
expect(requests[1]?.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "tool-call", id: "call-malformed", name: "echo", input: {} }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "tool-result",
|
||||
id: "call-malformed",
|
||||
result: expect.objectContaining({
|
||||
type: "error",
|
||||
value: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const context = yield* session.context(sessionID)
|
||||
const failed = context.find(
|
||||
(message): message is SessionMessage.Assistant =>
|
||||
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",
|
||||
id: "call-malformed",
|
||||
executed: false,
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: {
|
||||
type: "tool.input-json",
|
||||
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
if (!failed) throw new Error("Malformed tool assistant missing")
|
||||
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
||||
"session.step.started.1",
|
||||
"session.tool.failed.1",
|
||||
"session.step.failed.1",
|
||||
])
|
||||
const database = (yield* Database.Service).db
|
||||
const durable = yield* database
|
||||
.select({ type: EventTable.type, data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(durable.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
|
||||
callID: "call-malformed",
|
||||
text: raw,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles a valid sibling before recovering malformed tool input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Run parallel tools")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "valid" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
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" }),
|
||||
],
|
||||
reply.stop(),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual(["valid"])
|
||||
const request = requests[1]
|
||||
if (!request) throw new Error("Malformed recovery request missing")
|
||||
expect(request.messages.flatMap((message) => (message.role === "tool" ? message.content : []))).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "call-valid", type: "tool-result" }),
|
||||
expect.objectContaining({ id: "call-malformed", type: "tool-result" }),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover malformed input after sibling execution is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Interrupt malformed recovery")
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-valid", name: "echo", input: { text: "blocked" } }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
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" }),
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
while (
|
||||
!(yield* session.context(sessionID)).some(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
message.content.some((item) => item.type === "tool" && item.id === "call-malformed"),
|
||||
)
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* session.interrupt(sessionID)
|
||||
toolExecutionGate = undefined
|
||||
toolExecutionsStarted = undefined
|
||||
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt malformed recovery" },
|
||||
{
|
||||
type: "assistant",
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
content: [
|
||||
{ type: "tool", id: "call-valid", state: { status: "error", error: { type: "aborted" } } },
|
||||
{ type: "tool", id: "call-malformed", state: { status: "error" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records malformed provider-executed input as executed", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail malformed hosted input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid hosted tool input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-hosted", name: "web_search", providerExecuted: true }),
|
||||
LLMEvent.toolInputDelta({ id: "call-hosted", name: "web_search", text: '{"query":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Invalid hosted tool input" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-hosted",
|
||||
executed: true,
|
||||
state: { status: "error", error: { type: "provider.invalid-output" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces malformed input diagnosis with a later provider failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after malformed input")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Provider failed after malformed input" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call-malformed",
|
||||
name: "echo",
|
||||
raw: '{"text":"partial',
|
||||
message: "Invalid JSON input for test tool call echo",
|
||||
}),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
error: { type: "provider.invalid-output", message: "Provider failed after malformed input" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-malformed",
|
||||
executed: false,
|
||||
state: { status: "error", error: { type: "tool.input-json" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reset the malformed recovery budget after a valid tool step", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Keep producing malformed tools")
|
||||
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"),
|
||||
reply.tool("call-valid-between", "echo", { text: "valid" }),
|
||||
malformed("call-second"),
|
||||
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",
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(executions).toEqual(["valid"])
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.failed.1")).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue