fix(core): simplify compaction semantics (#36267)
This commit is contained in:
parent
41d0a9f010
commit
cf5e0adf02
4 changed files with 192 additions and 196 deletions
|
|
@ -17,7 +17,6 @@ import { Token } from "../util/token"
|
||||||
const DEFAULT_BUFFER = 20_000
|
const DEFAULT_BUFFER = 20_000
|
||||||
const DEFAULT_KEEP_TOKENS = 8_000
|
const DEFAULT_KEEP_TOKENS = 8_000
|
||||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||||
const SUMMARY_OUTPUT_TOKENS = 4_096
|
|
||||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||||
<template>
|
<template>
|
||||||
## Objective
|
## Objective
|
||||||
|
|
@ -61,20 +60,14 @@ type Dependencies = {
|
||||||
readonly llm: {
|
readonly llm: {
|
||||||
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
||||||
}
|
}
|
||||||
|
readonly models: SessionRunnerModel.Interface
|
||||||
readonly config: Settings
|
readonly config: Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutoInput = {
|
export type AutoInput = {
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
|
||||||
readonly request: LLMRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompactInput = {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly messages: readonly SessionMessage.Info[]
|
readonly messages: readonly SessionMessage.Info[]
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
readonly inputID?: SessionMessage.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManualInput = {
|
export type ManualInput = {
|
||||||
|
|
@ -83,16 +76,27 @@ export type ManualInput = {
|
||||||
readonly inputID: SessionMessage.ID
|
readonly inputID: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Plan = {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly model: Model
|
||||||
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
|
readonly prompt: string
|
||||||
|
readonly recent: string
|
||||||
|
readonly inputID?: SessionMessage.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Outcome =
|
||||||
|
| Pick<SessionMessage.CompactionCompleted, "status">
|
||||||
|
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
|
readonly required: (input: AutoInput) => boolean
|
||||||
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
|
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||||
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
|
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
|
||||||
|
|
||||||
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
|
|
||||||
|
|
||||||
const truncate = (value: string) =>
|
const truncate = (value: string) =>
|
||||||
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
||||||
|
|
||||||
|
|
@ -156,30 +160,33 @@ const select = (
|
||||||
): { readonly head: string; readonly recent: string } | undefined => {
|
): { readonly head: string; readonly recent: string } | undefined => {
|
||||||
const conversation = messages
|
const conversation = messages
|
||||||
.filter((message) => message.type !== "compaction")
|
.filter((message) => message.type !== "compaction")
|
||||||
.map(serialize)
|
.flatMap((message) => {
|
||||||
.filter(Boolean)
|
const text = serialize(message)
|
||||||
|
return text ? [{ message, text }] : []
|
||||||
|
})
|
||||||
if (conversation.length === 0) return undefined
|
if (conversation.length === 0) return undefined
|
||||||
let total = 0
|
let total = 0
|
||||||
let split = conversation.length
|
let split = conversation.length
|
||||||
let splitPrefix = ""
|
|
||||||
let splitSuffix = ""
|
|
||||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||||
const next = total + Token.estimate(conversation[index])
|
const next = total + Token.estimate(conversation[index].text)
|
||||||
if (next > tokens) {
|
if (split < conversation.length && next > tokens) break
|
||||||
const remaining = Math.max(0, tokens - total) * 4
|
|
||||||
if (remaining > 0) {
|
|
||||||
splitPrefix = conversation[index].slice(0, -remaining)
|
|
||||||
splitSuffix = conversation[index].slice(-remaining)
|
|
||||||
split = index + 1
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
total = next
|
total = next
|
||||||
split = index
|
split = index
|
||||||
}
|
}
|
||||||
|
while (split > 0 && conversation[split].message.type !== "user") split--
|
||||||
|
if (split === 0) {
|
||||||
|
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
|
||||||
|
if (latestUser > 0) split = latestUser
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
|
head: conversation
|
||||||
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
|
.slice(0, split)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n\n"),
|
||||||
|
recent: conversation
|
||||||
|
.slice(split)
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n\n"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,37 +199,50 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
|
||||||
...input.context,
|
...input.context,
|
||||||
].join("\n\n")
|
].join("\n\n")
|
||||||
|
|
||||||
|
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||||
|
const selected = select(messages, tokens)
|
||||||
|
if (!selected) return
|
||||||
|
const previousSummary = messages.findLast(
|
||||||
|
(message) => message.type === "compaction" && message.status === "completed",
|
||||||
|
)
|
||||||
|
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
||||||
|
const summarizeRecent = !previousRecent && !selected.head
|
||||||
|
return {
|
||||||
|
prompt: buildPrompt({
|
||||||
|
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
||||||
|
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||||
|
}),
|
||||||
|
recent: summarizeRecent ? "" : selected.recent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const make = (dependencies: Dependencies) => {
|
const make = (dependencies: Dependencies) => {
|
||||||
const config = dependencies.config
|
const config = dependencies.config
|
||||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
|
const failed = Effect.fnUntraced(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly model: Model
|
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
readonly reason: SessionMessage.Compaction["reason"]
|
||||||
readonly previousSummary?: string
|
readonly error: SessionError.Error
|
||||||
readonly context: readonly string[]
|
|
||||||
readonly recent: string
|
|
||||||
readonly output?: number
|
|
||||||
readonly inputID?: SessionMessage.ID
|
readonly inputID?: SessionMessage.ID
|
||||||
}) {
|
}) {
|
||||||
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
|
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, input)
|
||||||
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
|
return { status: "failed" as const, error: input.error }
|
||||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
})
|
||||||
|
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
recent: input.recent,
|
recent: plan.recent,
|
||||||
inputID: input.inputID,
|
inputID: plan.inputID,
|
||||||
})
|
})
|
||||||
|
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failure: SessionError.Error | undefined
|
let failure: SessionError.Error | undefined
|
||||||
const summarized = yield* dependencies.llm
|
yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: input.model,
|
model: plan.model,
|
||||||
messages: [Message.user(summaryPrompt)],
|
messages: [Message.user(plan.prompt)],
|
||||||
tools: [],
|
tools: [],
|
||||||
generation: { maxTokens: summaryOutput },
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|
@ -235,130 +255,110 @@ const make = (dependencies: Dependencies) => {
|
||||||
if (LLMEvent.is.textDelta(event)) {
|
if (LLMEvent.is.textDelta(event)) {
|
||||||
chunks.push(event.text)
|
chunks.push(event.text)
|
||||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
text: event.text,
|
text: event.text,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
Effect.as(true),
|
|
||||||
Effect.catchTag("LLM.Error", (error) =>
|
Effect.catchTag("LLM.Error", (error) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
failure = toSessionError(error)
|
failure = toSessionError(error)
|
||||||
return false
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.onInterrupt(() =>
|
Effect.onInterrupt(() =>
|
||||||
input.reason === "auto"
|
plan.reason === "auto"
|
||||||
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
? failed({
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||||
inputID: input.inputID,
|
inputID: plan.inputID,
|
||||||
})
|
}).pipe(Effect.asVoid)
|
||||||
: Effect.void,
|
: Effect.void,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const summary = chunks.join("")
|
const summary = chunks.join("")
|
||||||
if (!summarized || failure || !summary.trim()) {
|
if (failure || !summary.trim()) {
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||||
sessionID: input.sessionID,
|
return yield* failed({
|
||||||
reason: input.reason,
|
sessionID: plan.sessionID,
|
||||||
error: failure ?? { type: "compaction.failed", message: "Compaction produced no summary" },
|
reason: plan.reason,
|
||||||
inputID: input.inputID,
|
error,
|
||||||
|
inputID: plan.inputID,
|
||||||
})
|
})
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||||
sessionID: input.sessionID,
|
sessionID: plan.sessionID,
|
||||||
reason: input.reason,
|
reason: plan.reason,
|
||||||
text: summary,
|
text: summary,
|
||||||
recent: input.recent,
|
recent: plan.recent,
|
||||||
})
|
})
|
||||||
return true
|
return { status: "completed" as const }
|
||||||
})
|
})
|
||||||
const compactAvailable = Effect.fn("SessionCompaction.compactAvailable")(function* (
|
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||||
input: CompactInput & {
|
const content = planContent(input.messages, config.tokens)
|
||||||
readonly reason: SessionMessage.Compaction["reason"]
|
if (content)
|
||||||
readonly output?: number
|
return yield* execute({
|
||||||
},
|
sessionID: input.sessionID,
|
||||||
) {
|
model: input.model,
|
||||||
const selected = select(input.messages, config.tokens)
|
reason: "auto",
|
||||||
if (!selected) return false
|
...content,
|
||||||
const previousSummary = input.messages.find(
|
})
|
||||||
(message) => message.type === "compaction" && message.status === "completed",
|
const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" }
|
||||||
)
|
return yield* failed({
|
||||||
const summarizeRecent = selected.head.length === 0
|
|
||||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
|
||||||
return yield* compact({
|
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
model: input.model,
|
|
||||||
reason: input.reason,
|
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
|
||||||
context: (summarizeRecent ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
|
|
||||||
Boolean,
|
|
||||||
),
|
|
||||||
recent: summarizeRecent ? "" : selected.recent,
|
|
||||||
output: input.output,
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
|
|
||||||
return yield* compactAvailable({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messages: input.messages,
|
|
||||||
model: input.request.model,
|
|
||||||
reason: "auto",
|
reason: "auto",
|
||||||
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
|
error,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
const required = (input: AutoInput) => {
|
||||||
return yield* compactAvailable({ ...input, reason: "manual" })
|
|
||||||
})
|
|
||||||
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
|
|
||||||
if (!config.auto) return false
|
if (!config.auto) return false
|
||||||
const context = input.request.model.route.defaults.limits?.context
|
const context = input.model.route.defaults.limits?.context
|
||||||
if (context === undefined || context <= 0) return false
|
if (context === undefined || context <= 0) return false
|
||||||
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
|
const last = input.messages.findLast(
|
||||||
if (
|
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||||
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
|
message.type === "assistant" && message.tokens !== undefined,
|
||||||
context - Math.max(output, config.buffer)
|
|
||||||
)
|
)
|
||||||
return false
|
if (!last) return false
|
||||||
const selected = select(input.messages, config.tokens)
|
const output = input.model.route.defaults.limits?.output ?? 0
|
||||||
if (!selected) return false
|
const used =
|
||||||
const previousSummary = input.messages.find(
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||||
(message) => message.type === "compaction" && message.status === "completed",
|
if (used <= 0) return false
|
||||||
)
|
return used >= context - (output || config.buffer)
|
||||||
if (!selected.head && previousSummary?.type !== "compaction") return false
|
}
|
||||||
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
|
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||||
const summaryContext = [previousRecent, selected.head].filter(Boolean)
|
const content = planContent(input.messages, config.tokens)
|
||||||
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
|
if (!content)
|
||||||
if (
|
return yield* failed({
|
||||||
Token.estimate(
|
sessionID: input.session.id,
|
||||||
buildPrompt({
|
reason: "manual",
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||||
context: summaryContext,
|
inputID: input.inputID,
|
||||||
|
})
|
||||||
|
const resolved = yield* dependencies.models.resolve(input.session).pipe(
|
||||||
|
Effect.catch((cause) =>
|
||||||
|
failed({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
reason: "manual",
|
||||||
|
error: toSessionError(cause),
|
||||||
|
inputID: input.inputID,
|
||||||
}),
|
}),
|
||||||
) >
|
),
|
||||||
context - summaryOutput
|
|
||||||
)
|
)
|
||||||
return false
|
if ("status" in resolved) return resolved
|
||||||
return yield* compact({
|
return yield* execute({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.session.id,
|
||||||
model: input.request.model,
|
model: resolved.model,
|
||||||
reason: "auto",
|
reason: "manual",
|
||||||
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
|
inputID: input.inputID,
|
||||||
context: summaryContext,
|
...content,
|
||||||
recent: selected.recent,
|
|
||||||
output,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return {
|
return Service.of({
|
||||||
compactIfNeeded,
|
required,
|
||||||
compactAfterOverflow,
|
compact,
|
||||||
compactManual,
|
compactManual,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
|
|
@ -368,43 +368,7 @@ export const layer = Layer.effect(
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const configured = settings(yield* config.entries())
|
return make({ events, llm, models, config: settings(yield* config.entries()) })
|
||||||
const compaction = make({ events, llm, config: configured })
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
compactIfNeeded: compaction.compactIfNeeded,
|
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
|
||||||
if (!select(input.messages, configured.tokens)) {
|
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
|
||||||
sessionID: input.session.id,
|
|
||||||
reason: "manual",
|
|
||||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const resolved = yield* models.resolve(input.session).pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
events
|
|
||||||
.publish(SessionEvent.Compaction.Failed, {
|
|
||||||
sessionID: input.session.id,
|
|
||||||
reason: "manual",
|
|
||||||
error: toSessionError(error),
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
.pipe(Effect.as(undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!resolved) return false
|
|
||||||
return yield* compaction.compactManual({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
messages: input.messages,
|
|
||||||
model: resolved.model,
|
|
||||||
inputID: input.inputID,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ const layer = Layer.effect(
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: SessionPending.Delivery | undefined,
|
promotion: SessionPending.Delivery | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
recoverOverflow?: typeof compaction.compact,
|
||||||
assistantMessageID?: SessionMessage.ID,
|
assistantMessageID?: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
|
|
@ -189,6 +189,12 @@ const layer = Layer.effect(
|
||||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
|
const compactionInput = { sessionID: session.id, messages: context, model }
|
||||||
|
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
|
||||||
|
const compacted = yield* compaction.compact(compactionInput)
|
||||||
|
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||||
|
return yield* new StepFailedError({ error: compacted.error })
|
||||||
|
}
|
||||||
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
|
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
|
||||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||||
|
|
@ -208,12 +214,6 @@ const layer = Layer.effect(
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
// Automatic compaction completed; rebuild the request from compacted history.
|
|
||||||
if (
|
|
||||||
!(yield* SessionPending.compaction(db, session.id)) &&
|
|
||||||
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
|
|
||||||
)
|
|
||||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
|
||||||
const startSnapshot = yield* snapshots.capture()
|
const startSnapshot = yield* snapshots.capture()
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
|
|
@ -326,7 +326,8 @@ const layer = Layer.effect(
|
||||||
recoverOverflow &&
|
recoverOverflow &&
|
||||||
!publisher.hasRetryEvidence() &&
|
!publisher.hasRetryEvidence() &&
|
||||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||||
|
"completed"
|
||||||
)
|
)
|
||||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||||
|
|
||||||
|
|
@ -446,7 +447,7 @@ const layer = Layer.effect(
|
||||||
// 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
|
||||||
// overflow, so the recovery hook is dropped after it fires.
|
// overflow, so the recovery hook is dropped after it fires.
|
||||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact
|
||||||
let currentPromotion = promotion
|
let currentPromotion = promotion
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
let assistantMessageID: SessionMessage.ID | undefined
|
let assistantMessageID: SessionMessage.ID | undefined
|
||||||
|
|
@ -486,7 +487,7 @@ const layer = Layer.effect(
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
const pending = yield* SessionPending.compaction(db, sessionID)
|
const pending = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (!pending) return false
|
if (!pending) return
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -499,7 +500,7 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.exit)
|
).pipe(Effect.exit)
|
||||||
if (Exit.isSuccess(compacted) && compacted.value) return true
|
if (Exit.isSuccess(compacted)) return
|
||||||
if (Exit.isFailure(compacted)) {
|
if (Exit.isFailure(compacted)) {
|
||||||
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
||||||
if (unsettled)
|
if (unsettled)
|
||||||
|
|
@ -511,15 +512,6 @@ const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
return yield* Effect.failCause(compacted.cause)
|
return yield* Effect.failCause(compacted.cause)
|
||||||
}
|
}
|
||||||
const unsettled = yield* SessionPending.compaction(db, sessionID)
|
|
||||||
if (unsettled)
|
|
||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
|
||||||
sessionID,
|
|
||||||
reason: "manual",
|
|
||||||
error: { type: "compaction.failed", message: "Compaction could not start" },
|
|
||||||
inputID: unsettled.id,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -147,10 +147,11 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||||
messages: [userMessage],
|
messages: [userMessage],
|
||||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||||
}),
|
}),
|
||||||
).toBe(true)
|
).toEqual({ status: "completed" })
|
||||||
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
|
||||||
|
|
||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.generation).toBeUndefined()
|
||||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
|
||||||
expect(yield* store.context(sessionID)).toMatchObject([
|
expect(yield* store.context(sessionID)).toMatchObject([
|
||||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,16 @@ const reply = {
|
||||||
LLMEvent.finish({ reason: "stop" }),
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
],
|
],
|
||||||
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents,
|
||||||
|
textWithUsage: (text: string, id: string, inputTokens: number) =>
|
||||||
|
fragmentFixture("text", id, [text]).completeEvents.map((event) =>
|
||||||
|
LLMEvent.is.stepFinish(event)
|
||||||
|
? LLMEvent.stepFinish({
|
||||||
|
index: event.index,
|
||||||
|
reason: event.reason,
|
||||||
|
usage: { inputTokens, nonCachedInputTokens: inputTokens },
|
||||||
|
})
|
||||||
|
: event,
|
||||||
|
),
|
||||||
tool: (id: string, name: string, input: unknown) => [
|
tool: (id: string, name: string, input: unknown) => [
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.toolCall({ id, name, input }),
|
LLMEvent.toolCall({ id, name, input }),
|
||||||
|
|
@ -1696,7 +1706,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
|
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
response = reply.text("Earlier answer", "text-first")
|
response = reply.textWithUsage("Earlier answer", "text-first", 3_950)
|
||||||
yield* admit(session, "Earlier question ".repeat(180))
|
yield* admit(session, "Earlier question ".repeat(180))
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
|
@ -1704,7 +1714,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
responses = [
|
responses = [
|
||||||
reply.text("## Objective\n- Preserve the task", "text-summary"),
|
reply.text("## Objective\n- Preserve the task", "text-summary"),
|
||||||
reply.text("Continued", "text-final"),
|
reply.textWithUsage("Continued", "text-final", 3_950),
|
||||||
]
|
]
|
||||||
yield* admit(session, "Recent exact request ".repeat(180))
|
yield* admit(session, "Recent exact request ".repeat(180))
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
@ -1743,6 +1753,35 @@ describe("SessionRunnerLLM", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("stops after required automatic compaction fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
response = reply.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950)
|
||||||
|
yield* admit(session, "Earlier question ".repeat(180))
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
currentModel = compactModel
|
||||||
|
requests.length = 0
|
||||||
|
responses = [
|
||||||
|
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||||
|
reply.text("Must not run", "text-after-failed-compaction"),
|
||||||
|
]
|
||||||
|
yield* admit(session, "Recent exact request ".repeat(180))
|
||||||
|
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.generation).toBeUndefined()
|
||||||
|
expect(yield* session.context(sessionID)).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "compaction",
|
||||||
|
status: "failed",
|
||||||
|
reason: "auto",
|
||||||
|
error: expect.objectContaining({ message: "Unsupported parameter: max_output_tokens" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("forces one compaction and retries after provider context overflow", () =>
|
it.effect("forces one compaction and retries after provider context overflow", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setupOverflowRecovery
|
const session = yield* setupOverflowRecovery
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue