mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #12156 from Kilo-Org/investigate-hy3-compaction-freeze
fix(cli): preserve provider errors from chunked compaction
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user