Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton f57f68e1c8 fix(session): reject empty assistant responses 2026-07-15 16:38:17 -04:00
2 changed files with 71 additions and 1 deletions
+29 -1
View File
@@ -1083,6 +1083,7 @@ const layer = Layer.effect(
const ctx = yield* InstanceState.context
let structured: unknown
let step = 0
let retriedEmptyResponse = false
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
while (true) {
@@ -1107,12 +1108,17 @@ const layer = Layer.effect(
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
const hasVisibleOutput =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" || (part.type === "text" && part.text.trim().length > 0),
) ?? false
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
lastUser.id < lastAssistant.id &&
!(retriedEmptyResponse && !hasVisibleOutput)
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
@@ -1314,6 +1320,28 @@ const layer = Layer.effect(
yield* sessions.updateMessage(handle.message)
return "break" as const
}
const parts = yield* MessageV2.parts(handle.message.id).pipe(
Effect.provideService(Database.Service, database),
)
const hasVisibleOutput = parts.some(
(part) => part.type === "tool" || (part.type === "text" && part.text.trim().length > 0),
)
if (!hasVisibleOutput) {
if (!retriedEmptyResponse) {
retriedEmptyResponse = true
yield* Effect.logWarning("retrying empty assistant response", {
"session.id": sessionID,
messageID: handle.message.id,
})
return "continue" as const
}
const error = new NamedError.Unknown({ message: "Model returned an empty response after retry" })
handle.message.error = error.toObject()
yield* sessions.updateMessage(handle.message)
yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error })
throw error
}
}
if (result === "stop") return "break" as const
@@ -2201,6 +2201,48 @@ it.instance("does not loop empty assistant turns for a simple reply", () =>
}),
)
it.instance("retries a reasoning-only assistant response once", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Empty response retry" })
yield* llm.push(reply().reason("thinking").stop(), reply().text("done").stop())
const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Answer me" }],
})
expect(yield* llm.calls).toBe(2)
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
}),
)
it.instance("fails after two reasoning-only assistant responses", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Empty response failure" })
yield* llm.push(reply().reason("thinking").stop(), reply().reason("still thinking").stop())
const exit = yield* prompt
.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Answer me" }],
})
.pipe(Effect.exit)
expect(yield* llm.calls).toBe(2)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
it.instance("records aborted errors when prompt is cancelled mid-stream", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)