Merge pull request #12156 from Kilo-Org/investigate-hy3-compaction-freeze

fix(cli): preserve provider errors from chunked compaction
This commit is contained in:
Marius
2026-07-13 18:47:35 +02:00
committed by GitHub
4 changed files with 170 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Preserve gateway and provider errors when chunked compaction fails instead of reporting every failure as a context overflow.
@@ -33,6 +33,7 @@ export namespace KiloCompactionChunks {
type Output = {
result: SessionProcessor.Result
output: string | undefined
error: MessageV2.Assistant["error"]
}
type Deps = {
@@ -272,7 +273,11 @@ export namespace KiloCompactionChunks {
model: mdl,
})
const parts = yield* MessageV2.parts(worker.message.id)
return { result, output: text(worker.message, parts) }
return {
result,
output: text(worker.message, parts),
error: worker.message.error ?? worker.compactError?.(),
}
}).pipe(
Effect.ensuring(
input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore),
@@ -280,9 +285,37 @@ export namespace KiloCompactionChunks {
)
const result = out.result
const output = out.output
if (result !== "continue") return { result, output: undefined }
if (!output) return { result: "stop" as const, output: undefined }
return { result, output }
if (result !== "continue") return { result, output: undefined, error: out.error }
if (!output)
return {
result: "stop" as const,
output: undefined,
error:
out.error ??
new MessageV2.APIError({
message: "Compaction worker returned an empty response",
isRetryable: true,
}).toObject(),
}
return { result, output, error: undefined }
})
}
function fatal(output: Output | undefined) {
return output?.result === "stop" && !!output.error && output.error.name !== "ContextOverflowError"
}
function fail(input: Input, output: Output | undefined) {
return Effect.gen(function* () {
if (output?.result !== "stop") return false
const error = output.error
if (!error || error.name === "ContextOverflowError") return false
input.target.error = error
input.target.finish = "error"
input.target.time.completed = Date.now()
yield* input.updateMessage(input.target)
return true
})
}
@@ -332,7 +365,8 @@ export namespace KiloCompactionChunks {
(group) => reduce({ ...input, summaries: group, depth: input.depth + 1 }),
{ concurrency: 1 },
)
if (next.some((item) => item.result !== "continue" || !item.output)) return result
const failed = next.find(fatal) ?? next.find((item) => item.result !== "continue" || !item.output)
if (failed) return fatal(failed) ? failed : result
return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 2 })
})
}
@@ -346,13 +380,20 @@ export namespace KiloCompactionChunks {
const partial = yield* Effect.forEach(chunks, (chunk) => summarize({ ...input, chunk, total: chunks.length }), {
concurrency: Math.min(CONCURRENCY, chunks.length),
})
if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const
const failed = partial.find(fatal) ?? partial.find((item) => item.result !== "continue" || !item.output)
if (failed) {
if (yield* fail(input, failed)) return "stop" as const
return "compact" as const
}
const final =
chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size }))
? partial[0]
: yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 })
if (!final || final.result !== "continue" || !final.output) return "compact" as const
if (!final || final.result !== "continue" || !final.output) {
if (yield* fail(input, final)) return "stop" as const
return "compact" as const
}
yield* input.updatePart({
id: PartID.ascending(),
@@ -168,7 +168,7 @@ function reply(text: string, capture?: (input: LLM.StreamInput) => void) {
}
}
function fakeRuntime(outputTokenMax?: number) {
function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error"], empty = false) {
const calls: string[] = []
const outputs: number[] = []
const bus = Bus.layer
@@ -189,6 +189,12 @@ function fakeRuntime(outputTokenMax?: number) {
Effect.gen(function* () {
outputs.push(input.model.limit.output)
calls.push(JSON.stringify(stream.messages))
if (error) {
input.assistantMessage.error = error
input.assistantMessage.finish = "error"
yield* sessions.updateMessage(input.assistantMessage)
return "stop" as const
}
const text = stream.messages.some((msg) =>
JSON.stringify(msg).includes("Create a new anchored summary"),
)
@@ -196,13 +202,14 @@ function fakeRuntime(outputTokenMax?: number) {
: calls.length === 1
? "chunk one"
: "chunk two"
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "text",
text,
})
if (!empty)
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "text",
text,
})
input.assistantMessage.finish = "stop"
return "continue" as const
}),
@@ -238,6 +245,48 @@ function fakeRuntime(outputTokenMax?: number) {
}
}
async function failure(error?: MessageV2.Assistant["error"], empty = false) {
await using tmp = await tmpdir()
return provideTestInstance({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
await user(session.id, "oversized " + "x".repeat(80_000))
await Effect.runPromise(
KiloSessionCompaction.create({
session: store,
sessionID: session.id,
agent: "build",
model: ref,
auto: false,
}),
)
const { rt } = fakeRuntime(undefined, error, empty)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summary = all.find((msg) => msg.info.role === "assistant" && msg.info.summary)
return { result, summary }
} finally {
await rt.dispose()
}
},
})
}
function liveRuntime(layer: Layer.Layer<LLM.Service>, context = 10_000) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus), Layer.provide(EventV2Bridge.defaultLayer))
@@ -317,6 +366,53 @@ describe("KiloCompactionChunks", () => {
expect(KiloCompactionChunks.budget({ cfg, model, outputTokenMax })).toBe(5_692)
})
test("preserves gateway errors from chunk workers", async () => {
const error = new MessageV2.APIError({
message: "The operation was aborted",
statusCode: 504,
isRetryable: true,
responseBody: '{"error_type":"timeout"}',
}).toObject()
const result = await failure(error)
expect(result.result).toBe("stop")
expect(result.summary?.info.role).toBe("assistant")
if (result.summary?.info.role !== "assistant") return
expect(result.summary.info.finish).toBe("error")
expect(result.summary.info.error).toEqual(error)
})
test("keeps context overflow on the terminal compaction path", async () => {
const result = await failure(
new MessageV2.ContextOverflowError({
message: "worker context overflow",
}).toObject(),
)
expect(result.result).toBe("stop")
expect(result.summary?.info.role).toBe("assistant")
if (result.summary?.info.role !== "assistant") return
expect(result.summary.info.error?.name).toBe("ContextOverflowError")
if (result.summary.info.error?.name !== "ContextOverflowError") return
expect(result.summary.info.error.data.message).toBe(
"Session too large to compact - context exceeds model limit even after stripping media",
)
})
test("reports empty chunk worker responses as API errors", async () => {
const result = await failure(undefined, true)
expect(result.result).toBe("stop")
expect(result.summary?.info.role).toBe("assistant")
if (result.summary?.info.role !== "assistant") return
expect(result.summary.info.finish).toBe("error")
expect(result.summary.info.error?.name).toBe("APIError")
if (result.summary.info.error?.name !== "APIError") return
expect(result.summary.info.error.data.message).toBe("Compaction worker returned an empty response")
expect(result.summary.info.error.data.isRetryable).toBe(true)
})
test("falls back to chunk workers after the first compaction overflows", async () => {
await using tmp = await tmpdir()
await provideTestInstance({
@@ -123,6 +123,19 @@ describe("Kilo auto-compaction threshold", () => {
expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(0), total: 150_000 } })).toBe(true)
})
test("uses the output cap as the reserve for single-window gateway models", () => {
const mdl = model({ context: 262_144, output: 262_144 })
expect(usable({ cfg: cfg(), model: mdl })).toBe(230_144)
expect(usable({ cfg: cfg({ reserved: 20_000 }), model: mdl })).toBe(230_144)
})
test("keeps usable context for small single-window models with large output limits", () => {
const mdl = model({ context: 40_000, output: 262_144 })
expect(usable({ cfg: cfg(), model: mdl })).toBe(8_000)
})
})
describe("Kilo request estimation", () => {