fix(core): continue after malformed tool input (#37701)
This commit is contained in:
parent
57ff57595a
commit
a288cb5a0c
3 changed files with 47 additions and 50 deletions
|
|
@ -80,7 +80,6 @@ const layer = Layer.effect(
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionPending.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverMalformedToolInput: boolean,
|
|
||||||
recoverOverflow?: typeof compaction.compact,
|
recoverOverflow?: typeof compaction.compact,
|
||||||
assistantMessageID?: SessionMessage.ID,
|
assistantMessageID?: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
|
|
@ -144,6 +143,10 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield* publish(event)
|
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
|
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||||
const tool = prepared.resolveToolCall(event.name)
|
const tool = prepared.resolveToolCall(event.name)
|
||||||
if (tool.type === "reject") {
|
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 (stream._tag === "Failure") 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 && !recoveredMalformedToolInput) return yield* new StepFailedError({ error: stepFailure })
|
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
|
||||||
return {
|
return {
|
||||||
_tag: "Completed",
|
_tag: "Completed",
|
||||||
needsContinuation: needsContinuation || recoveredMalformedToolInput,
|
needsContinuation,
|
||||||
malformedToolInput: recoveredMalformedToolInput,
|
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
} as const
|
} as const
|
||||||
}),
|
}),
|
||||||
|
|
@ -371,7 +362,6 @@ const layer = Layer.effect(
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionPending.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverMalformedToolInput: boolean,
|
|
||||||
) {
|
) {
|
||||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
// 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
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
while (true) {
|
while (true) {
|
||||||
const attempt = yield* Effect.suspend(() =>
|
const attempt = yield* Effect.suspend(() =>
|
||||||
attemptStep(
|
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
|
||||||
sessionID,
|
|
||||||
currentPromotion,
|
|
||||||
currentStep,
|
|
||||||
recoverMalformedToolInput,
|
|
||||||
recoverOverflow,
|
|
||||||
assistantMessageID,
|
|
||||||
),
|
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.tapError((error) =>
|
Effect.tapError((error) =>
|
||||||
error instanceof SessionRunnerRetry.RetryableFailure
|
error instanceof SessionRunnerRetry.RetryableFailure
|
||||||
|
|
@ -414,7 +397,6 @@ const layer = Layer.effect(
|
||||||
if (attempt._tag === "Completed")
|
if (attempt._tag === "Completed")
|
||||||
return {
|
return {
|
||||||
needsContinuation: attempt.needsContinuation,
|
needsContinuation: attempt.needsContinuation,
|
||||||
malformedToolInput: attempt.malformedToolInput,
|
|
||||||
step: attempt.step,
|
step: attempt.step,
|
||||||
}
|
}
|
||||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||||
|
|
@ -474,18 +456,12 @@ const layer = Layer.effect(
|
||||||
while (shouldRun) {
|
while (shouldRun) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
let step = 1
|
let step = 1
|
||||||
let canRecoverMalformedToolInput = true
|
|
||||||
// Repeat steps while continuation is needed. A step needs continuation only
|
// 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;
|
// 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
|
// a provider error suppresses it. Pending steers also continue the loop so
|
||||||
// interjections are answered before the session goes idle.
|
// interjections are answered before the session goes idle.
|
||||||
while (needsContinuation) {
|
while (needsContinuation) {
|
||||||
const result = yield* runStep(
|
const result = yield* runStep(input.sessionID, promotion, step)
|
||||||
input.sessionID,
|
|
||||||
promotion,
|
|
||||||
step,
|
|
||||||
canRecoverMalformedToolInput,
|
|
||||||
)
|
|
||||||
// Steer/queue promotion inside runStep has already made the pending input a visible
|
// 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.
|
// user message by this point, so the first-user-message check below is reliable.
|
||||||
if (!titleAttempted.has(input.sessionID)) {
|
if (!titleAttempted.has(input.sessionID)) {
|
||||||
|
|
@ -493,7 +469,6 @@ const layer = Layer.effect(
|
||||||
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
|
||||||
}
|
}
|
||||||
needsContinuation = result.needsContinuation
|
needsContinuation = result.needsContinuation
|
||||||
if (result.malformedToolInput) canRecoverMalformedToolInput = false
|
|
||||||
step = result.step + 1
|
step = result.step + 1
|
||||||
if (needsContinuation) {
|
if (needsContinuation) {
|
||||||
yield* runPendingCompaction(input.sessionID)
|
yield* runPendingCompaction(input.sessionID)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||||
let stepFailed = false
|
let stepFailed = false
|
||||||
let providerFailed = false
|
let providerFailed = false
|
||||||
let retryEvidence = false
|
let retryEvidence = false
|
||||||
let malformedToolInput = false
|
|
||||||
let stepFailure: SessionError.Error | undefined
|
let stepFailure: SessionError.Error | undefined
|
||||||
let stepSettlement:
|
let stepSettlement:
|
||||||
| {
|
| {
|
||||||
|
|
@ -216,7 +215,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly raw: string
|
readonly raw: string
|
||||||
readonly message: string
|
|
||||||
}) {
|
}) {
|
||||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
const tool = tools.get(event.id)
|
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}`))
|
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)
|
if (toolInput.has(event.id)) yield* endToolInput(event, event.raw)
|
||||||
tool.settled = true
|
tool.settled = true
|
||||||
malformedToolInput = true
|
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
|
|
@ -237,7 +234,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||||
},
|
},
|
||||||
executed: false,
|
executed: false,
|
||||||
})
|
})
|
||||||
if (stepFailure === undefined) stepFailure = { type: "provider.invalid-output", message: event.message }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||||
|
|
@ -482,7 +478,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||||
publishStepFailure,
|
publishStepFailure,
|
||||||
failUnsettledTools,
|
failUnsettledTools,
|
||||||
hasProviderError: () => providerFailed,
|
hasProviderError: () => providerFailed,
|
||||||
hasMalformedToolInput: () => malformedToolInput,
|
|
||||||
hasRetryEvidence: () => retryEvidence,
|
hasRetryEvidence: () => retryEvidence,
|
||||||
stepFailure: () => stepFailure,
|
stepFailure: () => stepFailure,
|
||||||
stepSettlement: () => stepSettlement,
|
stepSettlement: () => stepSettlement,
|
||||||
|
|
|
||||||
|
|
@ -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* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Recover malformed tool input")
|
yield* admit(session, "Recover malformed tool input")
|
||||||
|
|
@ -4226,7 +4226,6 @@ describe("SessionRunnerLLM", () => {
|
||||||
message.type === "assistant" && message.content.some((item) => item.type === "tool"),
|
message.type === "assistant" && message.content.some((item) => item.type === "tool"),
|
||||||
)
|
)
|
||||||
expect(failed).toMatchObject({
|
expect(failed).toMatchObject({
|
||||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for test tool call echo" },
|
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "tool",
|
type: "tool",
|
||||||
|
|
@ -4244,10 +4243,11 @@ describe("SessionRunnerLLM", () => {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (!failed) throw new Error("Malformed tool assistant missing")
|
if (!failed) throw new Error("Malformed tool assistant missing")
|
||||||
|
expect(failed.error).toBeUndefined()
|
||||||
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([
|
||||||
"session.step.started.1",
|
"session.step.started.1",
|
||||||
"session.tool.failed.1",
|
"session.tool.failed.1",
|
||||||
"session.step.failed.1",
|
"session.step.ended.1",
|
||||||
])
|
])
|
||||||
const database = (yield* Database.Service).db
|
const database = (yield* Database.Service).db
|
||||||
const durable = yield* database
|
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* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* admit(session, "Keep producing malformed tools")
|
yield* admit(session, "Keep producing malformed tools")
|
||||||
|
|
@ -4444,16 +4444,43 @@ describe("SessionRunnerLLM", () => {
|
||||||
reply.stop(),
|
reply.stop(),
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toMatchObject({
|
yield* session.resume(sessionID)
|
||||||
error: {
|
|
||||||
type: "provider.invalid-output",
|
|
||||||
message: "Invalid JSON input for test tool call echo",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(requests).toHaveLength(3)
|
expect(requests).toHaveLength(4)
|
||||||
expect(executions).toEqual(["valid"])
|
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)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue