mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 09:24:38 +00:00
fix(core): preserve session context during compaction (#46751)
This commit is contained in:
parent
6e87cd66bf
commit
8fc93e6ee4
10 changed files with 518 additions and 285 deletions
|
|
@ -23,16 +23,6 @@ Guidelines:
|
|||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
|
||||
|
||||
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
|
||||
|
||||
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
|
||||
|
||||
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
|
||||
|
||||
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
|
||||
|
||||
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
|
||||
|
||||
<task>
|
||||
|
|
@ -144,8 +134,6 @@ export const Plugin = define({
|
|||
item.name = Agent.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LLMRequest, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
|
|
@ -18,6 +18,8 @@ import { Token } from "../util/token.js"
|
|||
import { SessionUsage } from "./usage.js"
|
||||
import { State } from "../state.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import type { Instructions } from "../instructions/index.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
|
|
@ -25,13 +27,16 @@ const OUTPUT_TOKEN_MAX = 32_000
|
|||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const IMAGE_TOKEN_ESTIMATE = 1_500
|
||||
const PDF_TOKEN_ESTIMATE = 2_000
|
||||
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 = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
- [one or two brief sentences describing what the user is trying to accomplish]
|
||||
|
||||
## Important Details
|
||||
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
||||
## Requirements
|
||||
- [constraints, preferences, requirements, and scope boundaries, or "(none)"]
|
||||
|
||||
## Decisions
|
||||
- [decisions already made and why, or "(none)"]
|
||||
|
||||
## Work State
|
||||
### Completed
|
||||
|
|
@ -44,19 +49,26 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
|
|||
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
|
||||
## Next Move
|
||||
1. [immediate concrete action, or "(none)"]
|
||||
2. [next action if known, or "(none)"]
|
||||
1. [ordered list of next actions, or "(none)"]
|
||||
|
||||
## Relevant Files
|
||||
- [file or directory path: why it matters, or "(none)"]
|
||||
</template>
|
||||
List files and directories that are important to the conversation. Include paths outside the current working directory when relevant. If none are relevant, write "(none)".
|
||||
- \`[exact path]\`: [why it matters]
|
||||
|
||||
Rules:
|
||||
- Keep every section, even when empty.
|
||||
## Additional Context
|
||||
- [important facts, assumptions, unresolved questions, exact references, or other context needed to continue that does not fit above; when uncertain, preserve it here, or "(none)"]
|
||||
</template>`
|
||||
|
||||
const SUMMARY_RULES = `Rules:
|
||||
- Use terse bullets, not prose paragraphs.
|
||||
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
||||
- Carry forward only user questions or requests that remain unanswered or require further action. Do not repeat ones that newer history has answered or resolved. Preserve exact wording when carrying one forward.
|
||||
- Preserve consequential workflow state, including whether changes are uncommitted, committed, pushed, under review, or merged.
|
||||
- Do not include ambient environment metadata such as the session ID, current working directory, repository root, current branch, or worktree path. The next agent receives current environment information separately. Include these details only when they directly affect the task.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
const SUMMARY_HEADINGS = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##"))
|
||||
|
||||
export type Settings = {
|
||||
auto: boolean
|
||||
buffer: number
|
||||
|
|
@ -68,13 +80,13 @@ export type Draft = {
|
|||
}
|
||||
|
||||
export type AutoInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: SessionContext.Loaded
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
|
||||
type RequiredInput = {
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
readonly context: SessionContext.Loaded
|
||||
}
|
||||
|
||||
|
|
@ -83,20 +95,21 @@ export type ManualInput = {
|
|||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
/** Invoked after content planning, not when the caller captures the operation. */
|
||||
readonly resolveModel: SessionContext.Interface["resolveModel"]
|
||||
/** Empty compaction controls do not preflight model or instruction availability. */
|
||||
readonly resolveContext: (
|
||||
session: SessionSchema.Info,
|
||||
) => Effect.Effect<
|
||||
SessionContext.Loaded & { readonly instructionUpdate: string },
|
||||
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
|
||||
>
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
type ExecuteInput = AutoInput & {
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly prompt: string
|
||||
readonly recent: string
|
||||
readonly inputID?: SessionMessage.ID
|
||||
readonly started?: boolean
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
readonly instructionUpdate?: string
|
||||
}
|
||||
|
||||
export type Outcome =
|
||||
|
|
@ -194,7 +207,9 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
|||
)
|
||||
.join("\n")
|
||||
|
||||
const serialize = (message: SessionMessage.Info) => {
|
||||
const serializeRecentMessage = (message: SessionMessage.Info) => {
|
||||
// Checkpoints and instruction updates are handled outside the serialized tail.
|
||||
if (message.type === "compaction" || message.type === "system") return ""
|
||||
if (message.type === "user") {
|
||||
const files =
|
||||
message.files?.map(
|
||||
|
|
@ -226,7 +241,6 @@ const serialize = (message: SessionMessage.Info) => {
|
|||
})
|
||||
.join("\n")
|
||||
}
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell")
|
||||
|
|
@ -236,70 +250,74 @@ const serialize = (message: SessionMessage.Info) => {
|
|||
return ""
|
||||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = messages
|
||||
.filter((message) => message.type !== "compaction" && message.type !== "system")
|
||||
.flatMap((message) => {
|
||||
const text = serialize(message)
|
||||
return text ? [{ message, text }] : []
|
||||
})
|
||||
if (conversation.length === 0) return undefined
|
||||
let total = 0
|
||||
let split = conversation.length
|
||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||
const next = total + Token.estimate(conversation[index].text)
|
||||
if (split < conversation.length && next > tokens) break
|
||||
total = next
|
||||
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
|
||||
}
|
||||
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
|
||||
const tailStart = findTailStart(messages, keepTokens)
|
||||
if (tailStart === undefined) return
|
||||
return {
|
||||
head: conversation
|
||||
.slice(0, split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
recent: conversation
|
||||
.slice(split)
|
||||
.map((item) => item.text)
|
||||
.join("\n\n"),
|
||||
messages: messages.slice(0, tailStart),
|
||||
recent: messages.slice(tailStart).map(serializeRecentMessage).filter(Boolean).join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
|
||||
[
|
||||
input.previousSummary
|
||||
? `Update the anchored summary below using the conversation history below.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
|
||||
: "Create a new anchored summary from the conversation history.",
|
||||
SUMMARY_TEMPLATE,
|
||||
"The following is the conversation history:",
|
||||
...input.context,
|
||||
].join("\n\n")
|
||||
const findTailStart = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
|
||||
const conversation = messages.flatMap((message, index) => {
|
||||
const text = serializeRecentMessage(message)
|
||||
return text ? [{ message, text, index }] : []
|
||||
})
|
||||
if (conversation.length === 0) return undefined
|
||||
|
||||
// Keep at least the newest entry, even if it exceeds the allowance.
|
||||
let total = 0
|
||||
let start = conversation.length
|
||||
for (let index = conversation.length - 1; index >= 0; index--) {
|
||||
const next = total + Token.estimate(conversation[index].text)
|
||||
if (start < conversation.length && next > keepTokens) break
|
||||
total = next
|
||||
start = index
|
||||
}
|
||||
|
||||
// Start at a user boundary so an assistant's tool calls and results stay together.
|
||||
while (start > 0 && conversation[start].message.type !== "user") start--
|
||||
if (start > 0) return conversation[start].index
|
||||
|
||||
// If everything fits, retain only the latest exchange to leave an older prefix to summarize.
|
||||
const latestUser = conversation.findLastIndex((item) => item.message.type === "user")
|
||||
if (latestUser > 0) return conversation[latestUser].index
|
||||
|
||||
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
|
||||
const selected = select(messages, tokens)
|
||||
if (!selected) return
|
||||
const previousSummary = messages.findLast(
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed",
|
||||
)
|
||||
const previousRecent = previousSummary?.recent ?? ""
|
||||
const summarizeRecent = !previousRecent && !selected.head
|
||||
return {
|
||||
prompt: buildPrompt({
|
||||
previousSummary: previousSummary?.summary,
|
||||
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
|
||||
}),
|
||||
recent: summarizeRecent ? "" : selected.recent,
|
||||
}
|
||||
// Without an older retained tail to summarize, summarize everything and retain nothing.
|
||||
return previousSummary?.recent ? conversation[0].index : messages.length
|
||||
}
|
||||
|
||||
export const buildPrompt = (update: boolean) => {
|
||||
const shared = [
|
||||
"Summarize only the history shown. More recent context may be retained and presented after this summary.",
|
||||
SUMMARY_TEMPLATE,
|
||||
SUMMARY_RULES,
|
||||
"Do not continue the task or call tools.",
|
||||
"Return only the structured summary in the requested format. Do not include a preamble, explanation, or other commentary.",
|
||||
]
|
||||
if (update) {
|
||||
return [
|
||||
"Update the existing checkpoint in the conversation above into one consolidated summary.",
|
||||
"Newer history always takes precedence over the existing checkpoint. Preserve previous information unless newer history clearly contradicts, supersedes, resolves, or makes it stale. When uncertain and there is no conflict, retain it under Additional Context.",
|
||||
"Incorporate newer requirements, decisions, progress, and context. Reconcile Work State and Next Move: move completed work out of Active, remove resolved blockers and answered questions, and preserve unresolved or pending work.",
|
||||
"Return only the updated Markdown sections. Do not reproduce the `<conversation-checkpoint>`, `<summary>`, or `<recent-context>` wrapper tags from the previous checkpoint.",
|
||||
...shared,
|
||||
].join("\n\n")
|
||||
}
|
||||
return [
|
||||
"You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.",
|
||||
...shared,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
const hasSummarySection = (summary: string) =>
|
||||
summary.split("\n").some((line) => SUMMARY_HEADINGS.includes(line.trim()))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -326,13 +344,22 @@ export const layer = Layer.effect(
|
|||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
|
||||
if (!plan.started)
|
||||
const execute = Effect.fn("SessionCompaction.execute")(function* (input: ExecuteInput) {
|
||||
const context = input.context
|
||||
const history = splitHistory(context.messages, state.get().tokens)
|
||||
if (!history)
|
||||
return yield* failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
if (!input.started)
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
recent: plan.recent,
|
||||
inputID: plan.inputID,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
recent: history.recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
|
|
@ -341,92 +368,124 @@ export const layer = Layer.effect(
|
|||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: plan.session.id,
|
||||
sessionID: context.session.id,
|
||||
source: "compaction",
|
||||
...usage,
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* plan.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: context.agent.info,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
initial: context.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
const prepared = yield* input.prepare({
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
? prepared.request
|
||||
: LLMRequest.update(prepared.request, {
|
||||
messages: [
|
||||
...prepared.request.messages,
|
||||
Message.user(
|
||||
"The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.",
|
||||
),
|
||||
],
|
||||
}),
|
||||
prepared.options,
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: context.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
input.reason === "auto"
|
||||
? failed({
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (failure || hasSummarySection(chunks.join(""))) break
|
||||
}
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
|
||||
if (failure || !hasSummarySection(summary)) {
|
||||
const error = failure ?? {
|
||||
type: "compaction.failed" as const,
|
||||
message: summary.trim()
|
||||
? "Compaction summary did not match the required template"
|
||||
: "Compaction produced no summary",
|
||||
}
|
||||
return yield* failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
error,
|
||||
inputID: plan.inputID,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
text: summary,
|
||||
recent: plan.recent,
|
||||
recent: history.recent,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (content)
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved: input.resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "auto",
|
||||
...content,
|
||||
})
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
})
|
||||
const compact = (input: AutoInput) => execute({ ...input, reason: "auto" })
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
|
|
@ -444,15 +503,14 @@ export const layer = Layer.effect(
|
|||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
if (!content)
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
return yield* failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
return yield* input.resolveModel(input.session).pipe(
|
||||
return yield* input.resolveContext(input.session).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (cause) =>
|
||||
failed({
|
||||
|
|
@ -461,15 +519,14 @@ export const layer = Layer.effect(
|
|||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
onSuccess: (resolved) =>
|
||||
onSuccess: (context) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
context,
|
||||
instructionUpdate: context.instructionUpdate,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -60,8 +60,10 @@ interface PrepareInput {
|
|||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
|
||||
readonly contextAgentID?: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tools (title, compaction). */
|
||||
/** Omitted for requests that carry no tool definitions, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
|
|
@ -70,9 +72,8 @@ interface PrepareInput {
|
|||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Requests that are not
|
||||
* part of the conversation (title, compaction) opt out: their transcripts
|
||||
* pass through unchanged.
|
||||
* Session context hooks shape the agent conversation. Standalone requests
|
||||
* such as titles opt out; compaction uses the selected Session context.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
|
|
@ -300,7 +301,7 @@ export const layer = Layer.effect(
|
|||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
agent: input.scope.contextAgentID ?? input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SessionCompaction } from "../compaction.js"
|
|||
import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
|
|
@ -104,7 +105,22 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: context.resolveModel,
|
||||
resolveContext: (session) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
tools: selected.tools,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
instructionUpdate: history.instructionUpdate,
|
||||
}
|
||||
}),
|
||||
prepare: context.prepare,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
|
|
@ -180,12 +196,10 @@ const layer = Layer.effect(
|
|||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
session: loaded.session,
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
context: loaded,
|
||||
prepare: context.prepare,
|
||||
}
|
||||
if (compaction.required({ ...compactionInput, context: loaded })) {
|
||||
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
|
|
|
|||
|
|
@ -190,6 +190,13 @@ describe("Agent", () => {
|
|||
])
|
||||
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
|
||||
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
|
||||
const compaction = yield* agent.get(Agent.ID.make("compaction"))
|
||||
expect(compaction?.mode).toBe("primary")
|
||||
expect(compaction?.hidden).toBe(true)
|
||||
expect(compaction?.system).toBeUndefined()
|
||||
expect(compaction?.model).toBeUndefined()
|
||||
expect(compaction?.request).toEqual(Agent.Info.default(Agent.ID.make("compaction")).request)
|
||||
expect(compaction?.permissions).toEqual(permissions.filter((rule) => rule.action !== "question"))
|
||||
expect(
|
||||
Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), permissions).effect,
|
||||
).toBe("allow")
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const it = testEffect(
|
|||
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
|
||||
llmClient.replace(
|
||||
Layer.mock(LLMClient.Service)({
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
|
||||
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "## Objective\n- summary" })),
|
||||
}),
|
||||
),
|
||||
Config.node.replace(config),
|
||||
|
|
@ -77,25 +77,26 @@ describe("ConfigCompactionPlugin.Plugin", () => {
|
|||
const started = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Started)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Older context",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Recent context",
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
},
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const client = Layer.mock(LLMClient.Service)({
|
|||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(
|
||||
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
|
||||
LLMEvent.textDelta({ id: "summary", text: "## Objective\n- manual summary" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
|
|
@ -91,7 +91,7 @@ const it = testEffect(
|
|||
)
|
||||
|
||||
test("compaction prompt preserves detailed work state and relevant files", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt(false)
|
||||
|
||||
expect(prompt).toContain("## Work State\n### Completed")
|
||||
expect(prompt).toContain("### Active")
|
||||
|
|
@ -123,29 +123,24 @@ test("compaction truncation does not split surrogate pairs", () => {
|
|||
})
|
||||
|
||||
test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
|
||||
const prompt = SessionCompaction.buildPrompt(false)
|
||||
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
|
||||
"## Objective",
|
||||
"## Important Details",
|
||||
"## Requirements",
|
||||
"## Decisions",
|
||||
"## Work State",
|
||||
"### Completed",
|
||||
"### Active",
|
||||
"### Blocked",
|
||||
"## Next Move",
|
||||
"## Relevant Files",
|
||||
"## Additional Context",
|
||||
])
|
||||
expect(prompt).toContain("one or two brief sentences")
|
||||
expect(prompt).toContain("constraints/preferences, decisions and why")
|
||||
expect(prompt).toContain("immediate concrete action")
|
||||
expect(prompt).toContain("next action if known")
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
test("compaction points an existing summary to the following history", () => {
|
||||
const prompt = SessionCompaction.buildPrompt({ previousSummary: "Previous summary", context: ["Recent history"] })
|
||||
|
||||
expect(prompt.split("\n", 1)[0]).toBe("Update the anchored summary below using the conversation history below.")
|
||||
expect(prompt).not.toContain("conversation history above")
|
||||
test("compaction prompts prohibit task execution", () => {
|
||||
for (const update of [false, true])
|
||||
expect(SessionCompaction.buildPrompt(update)).toContain("Do not continue the task or call tools")
|
||||
})
|
||||
|
||||
it.effect("auto compaction estimates current content against the buffered prompt ceiling", () =>
|
||||
|
|
@ -305,6 +300,16 @@ const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$
|
|||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
|
||||
})
|
||||
|
||||
const loaded = (session: Session.Info, messages: readonly SessionMessage.Info[]) => ({
|
||||
session,
|
||||
messages,
|
||||
model: resolved,
|
||||
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
|
||||
initial: "Session instructions",
|
||||
instructionUpdate: "",
|
||||
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
|
@ -329,6 +334,25 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_background"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
metadata: { background: true },
|
||||
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
|
||||
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "synthetic",
|
||||
text: "User shell pwd completed: /project",
|
||||
time: { created: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
]
|
||||
|
||||
const delta = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
|
|
@ -337,31 +361,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
userMessage,
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "shell",
|
||||
shellID: Shell.ID.make("sh_background"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
metadata: { background: true },
|
||||
output: { output: "display-only-output", cursor: 19, size: 19, truncated: false },
|
||||
time: { created: DateTime.makeUnsafe(0), completed: DateTime.makeUnsafe(1) },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "synthetic",
|
||||
text: "User shell pwd completed: /project",
|
||||
time: { created: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
).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([
|
||||
"## Objective\n- manual summary",
|
||||
])
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
|
|
@ -380,7 +388,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
|||
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
|
||||
{ type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" },
|
||||
])
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
cost: 0.0000233,
|
||||
|
|
@ -415,7 +423,7 @@ it.effect("manual compaction records model resolution failures without calling t
|
|||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () =>
|
||||
resolveContext: () =>
|
||||
Effect.fail(
|
||||
new SessionRunnerModel.ModelUnavailableError({
|
||||
providerID: Provider.ID.make("test"),
|
||||
|
|
@ -461,19 +469,20 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
|||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
]
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveModel: () => Effect.succeed(resolved),
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
SystemPart,
|
||||
LanguageModel,
|
||||
ToolFailure,
|
||||
TransportError,
|
||||
|
|
@ -13,6 +14,8 @@ import {
|
|||
UnknownProviderError,
|
||||
} from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { AnthropicMessages, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
|
@ -70,7 +73,7 @@ import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
|
|||
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { ID, Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
|
|
@ -1392,7 +1395,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.admit("Echo before moving")
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-entry", "echo", { text: "moving" }),
|
||||
TestLLM.text("Entry summary", "entry-summary"),
|
||||
TestLLM.text("## Objective\n- Entry summary", "entry-summary"),
|
||||
TestLLM.text("Continued", "entry-continuation"),
|
||||
)
|
||||
const stream = yield* s.llm.gate
|
||||
|
|
@ -1427,8 +1430,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Entry summary\n</summary>")
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
|
|
@ -1895,28 +1897,29 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
scenario("moves the epoch at compaction and narrates later changes", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
})
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "",
|
||||
})
|
||||
s.systemBaseline = "Changed before compaction"
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- summary", "epoch-summary"))
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
expect(systemTexts(s.requests[1])).toEqual(["Changed before compaction"])
|
||||
expect((yield* s.context).some((message) => message.type === "system")).toBe(false)
|
||||
s.systemBaseline = "Replacement context"
|
||||
yield* s.runPrompt("Second")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(messageRoles(s.requests[1])).toEqual(["user", "system", "user"])
|
||||
expect(s.requests[1]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
|
||||
expect(messageRoles(s.requests[2])).toEqual(["user", "system", "user"])
|
||||
expect(s.requests[2]?.messages.at(1)?.content).toEqual([Expected.text("Replacement context")])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
yield* s.runPrompt("Third")
|
||||
const latest = yield* s.runPrompt("Third")
|
||||
expect(systemTexts(s.requests[3])).toEqual(["Replacement context"])
|
||||
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: latest.id } })
|
||||
expect(
|
||||
(yield* s.session.context(fork.id)).flatMap((message) => (message.type === "system" ? [message.text] : [])),
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
|
|
@ -1924,7 +1927,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("Steer complete", "text-steer"),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Queue complete", "text-queue"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
|
|
@ -1951,13 +1954,12 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(s.requests).toHaveLength(4)
|
||||
expect(userTexts(s.requests[1])).toContain("Steer after compaction")
|
||||
expect(userTexts(s.requests[1])).toContain("Completion after compaction")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[3])).toContain("Queue after compaction")
|
||||
expect(yield* SessionInbox.find(s.db, first.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === first.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1966,6 +1968,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "text-active-failure"),
|
||||
[],
|
||||
[],
|
||||
TestLLM.text("Continued", "text-after-failure"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
|
|
@ -1980,8 +1983,8 @@ describe("SessionRunnerLLM", () => {
|
|||
})
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2])).toContain("Continue after failure")
|
||||
expect(s.requests).toHaveLength(4)
|
||||
expect(userTexts(s.requests[3])).toContain("Continue after failure")
|
||||
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
|
|
@ -2020,7 +2023,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.runPrompt("Earlier question")
|
||||
|
||||
s.requests.length = 0
|
||||
yield* s.llm.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Manual summary", "text-manual-unknown-summary"))
|
||||
const compaction = yield* s.session.compact({ sessionID, delivery: "steer" })
|
||||
yield* s.resume
|
||||
|
||||
|
|
@ -2029,7 +2032,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "Manual summary",
|
||||
summary: "## Objective\n- Manual summary",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -2037,7 +2040,7 @@ describe("SessionRunnerLLM", () => {
|
|||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "text-active-steer-compact"),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Queue complete", "text-queue-after-compact"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
|
|
@ -2050,13 +2053,12 @@ describe("SessionRunnerLLM", () => {
|
|||
// Steer-delivered compaction runs at the boundary after the active step, ahead of
|
||||
// the queued prompt, and consuming it does not trigger an input-free model call.
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(s.requests[2])).toContain("Queued prompt")
|
||||
expect(yield* SessionInbox.find(s.db, compaction.id)).toBeUndefined()
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -2064,7 +2066,7 @@ describe("SessionRunnerLLM", () => {
|
|||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- durable summary" })],
|
||||
TestLLM.text("Continued", "text-continued-after-compact"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
|
|
@ -2075,11 +2077,10 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
// The compaction summary is requested before the tool turn's continuation step.
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "durable summary",
|
||||
summary: "## Objective\n- durable summary",
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -2098,6 +2099,148 @@ describe("SessionRunnerLLM", () => {
|
|||
})
|
||||
})
|
||||
|
||||
for (const route of [OpenAIChat.route, OpenAIResponses.route, AnthropicMessages.route]) {
|
||||
for (const reason of ["manual", "auto"] as const) {
|
||||
scenario(`preserves the session request prefix during ${reason} compaction (${route.id})`, function* (s) {
|
||||
const agents = yield* Agent.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const agentID = Agent.ID.make("reviewer")
|
||||
const variant = Model.VariantID.make("test-variant")
|
||||
s.currentModel = LanguageModel.make({
|
||||
id: route === AnthropicMessages.route ? "claude-sonnet-4-6" : "gpt-5",
|
||||
provider: route === AnthropicMessages.route ? "anthropic" : "openai",
|
||||
route,
|
||||
})
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(agentID, (agent) => {
|
||||
agent.system = "Review the project carefully."
|
||||
}),
|
||||
)
|
||||
yield* s.bus.publish(SessionEvent.AgentSelected, { sessionID, agent: agentID })
|
||||
yield* s.bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
|
||||
})
|
||||
const requestAgents: Agent.ID[] = []
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.agent).toBe(agentID)
|
||||
expect(event.model.variant).toBe(variant)
|
||||
event.system.push(SystemPart.make("Hook-provided instructions"))
|
||||
event.tools.echo.description = "Hook-provided tool description"
|
||||
event.generation.maxTokens = 4_000
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
requestAgents.push(event.agent)
|
||||
}),
|
||||
)
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
|
||||
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
|
||||
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
|
||||
)
|
||||
yield* s.runPrompt("Review these changes")
|
||||
if (reason === "manual") {
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
}
|
||||
if (reason === "auto") {
|
||||
yield* s.llm.push(TestLLM.text("Continued", "prefix-continued"))
|
||||
yield* s.runPrompt("Retained recent request")
|
||||
}
|
||||
|
||||
const normal = s.requests[1]
|
||||
const compact = s.requests[2]
|
||||
expect(compact.messages.slice(0, normal.messages.length)).toEqual([...normal.messages])
|
||||
expect(compact.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: SessionCompaction.buildPrompt(false) }],
|
||||
})
|
||||
expect(userTexts(compact)).not.toContain("Retained recent request")
|
||||
for (const field of [
|
||||
"model",
|
||||
"system",
|
||||
"tools",
|
||||
"generation",
|
||||
"providerOptions",
|
||||
"toolChoice",
|
||||
"cache",
|
||||
"promptCacheKey",
|
||||
"http",
|
||||
] as const)
|
||||
expect(compact[field]).toEqual(normal[field])
|
||||
expect(compact.toolChoice).toBeUndefined()
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
|
||||
// Compare wire content without the cache breakpoints that move to the new final message.
|
||||
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
|
||||
const after = yield* compileRequest(LLMRequest.update(compact, { cache: "none" }))
|
||||
const key = route === OpenAIResponses.route ? "input" : "messages"
|
||||
const input = Schema.decodeUnknownSync(Schema.Array(Schema.Unknown))
|
||||
const prefix = input(before.body[key])
|
||||
expect(input(after.body[key]).slice(0, prefix.length)).toEqual([...prefix])
|
||||
expect(after.body).toMatchObject(
|
||||
Object.fromEntries(Object.entries(before.body).filter(([name]) => name !== key)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const response of ["tools", "reasoning", "invalid text"] as const) {
|
||||
for (const summary of [true, false]) {
|
||||
scenario(
|
||||
`compaction ${summary ? "recovers" : "stops"} after one template reminder for ${response}`,
|
||||
function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "summary-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
s.requests.length = 0
|
||||
const invalid =
|
||||
response === "tools"
|
||||
? TestLLM.tool("call-summary", "echo", { text: "must not execute" })
|
||||
: response === "reasoning"
|
||||
? TestLLM.stop(
|
||||
LLMEvent.reasoningDelta({ id: "summary-reasoning", text: "## Objective\n- Not a summary" }),
|
||||
)
|
||||
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
|
||||
yield* s.llm.push(
|
||||
invalid,
|
||||
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
|
||||
)
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(s.requests[1].messages.slice(0, -1)).toEqual([...s.requests[0].messages])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
|
||||
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
|
||||
expect(s.executions).toEqual([])
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
|
||||
summary
|
||||
? { status: "completed", summary: "### Active\n- Recovered summary" }
|
||||
: {
|
||||
status: "failed",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message:
|
||||
response === "invalid text"
|
||||
? "Compaction summary did not match the required template"
|
||||
: "Compaction produced no summary",
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!summary)
|
||||
expect(
|
||||
(yield* s.context).some((message) => message.type === "user" && message.text === "Earlier question"),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
scenario("preserves typed provider failures from manual compaction", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-failure-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
|
|
@ -2176,7 +2319,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.runPrompt("Recent exact request ".repeat(180))
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(userTexts(s.requests[0])[0]).toContain("## Objective")
|
||||
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
|
||||
expect(userTexts(s.requests[1])).toHaveLength(1)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(s.requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
|
||||
|
|
@ -2186,6 +2329,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(context[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the task",
|
||||
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
|
||||
})
|
||||
|
||||
s.requests.length = 0
|
||||
|
|
@ -2197,13 +2341,13 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.runPrompt("Newest exact request ".repeat(180))
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(userTexts(s.requests[0])[0]).toContain(
|
||||
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
|
||||
)
|
||||
expect(userTexts(s.requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
|
||||
expect(userTexts(s.requests[0])[0]).toContain("Recent exact request")
|
||||
expect(userTexts(s.requests[0]).at(-1)).toBe(SessionCompaction.buildPrompt(true))
|
||||
expect((yield* store.context(sessionID))[0]).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "## Objective\n- Preserve the updated task",
|
||||
recent: `[User]: ${"Newest exact request ".repeat(180)}`,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -2259,7 +2403,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.runPrompt("Continue")
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])[0]).toContain("## Objective")
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("## Objective")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
|
||||
expect(yield* s.context).toMatchObject([
|
||||
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
|
||||
|
|
@ -2283,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* s.admit("Continue")
|
||||
yield* s.llm.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("## Objective\n- Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("Recovered", "overflow-recovered"),
|
||||
TestLLM.stop(),
|
||||
TestLLM.stop(),
|
||||
|
|
@ -2320,7 +2464,7 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(s.requests[2]?.model).toBe(replacementModel)
|
||||
expect(s.requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(s.requests[2])).toContain("Changed during compaction")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Overflow summary\n</summary>")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
expect(userTexts(s.requests[2]).join("\n")).not.toContain("Steered during compaction")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([queued.id, steered.id])
|
||||
|
|
|
|||
|
|
@ -1088,7 +1088,10 @@ await ctx.session.hook("context", (event) => {
|
|||
|
||||
Context changes affect only the outgoing model call, not persisted history or
|
||||
configuration. The hook runs again for subsequent calls such as tool-driven
|
||||
continuations, but not for title or compaction requests.
|
||||
continuations, transient session generation, and compaction, but not for title requests.
|
||||
|
||||
Compaction context hooks receive the selected session agent. Its model-request
|
||||
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
|
||||
|
||||
Request overrides follow these rules:
|
||||
|
||||
|
|
|
|||
|
|
@ -77,10 +77,19 @@ preserves more recent detail but leaves less room for future work. Larger
|
|||
|
||||
## Checkpoint contents
|
||||
|
||||
V2 uses the session's selected or default model to generate the summary, with
|
||||
tools disabled and at most 4096 output tokens. The summary records the
|
||||
objective, important details, completed and active work, blockers, next moves,
|
||||
and relevant files.
|
||||
V2 uses the session's selected agent, model, and variant to generate the summary.
|
||||
The request reuses the normal instructions, tool definitions, and structured
|
||||
history prefix, then appends a user message requesting a checkpoint. Context
|
||||
hooks run as they do for normal session requests.
|
||||
|
||||
Compaction does not dispatch local tool calls or override tool choice. The
|
||||
summary must contain at least one heading from the requested template, such as
|
||||
`## Objective`. If it does not, V2 makes one additional request asking the model
|
||||
to fill in the template correctly. A second invalid response fails compaction.
|
||||
Provider-hosted tools remain subject to the selected provider's behavior.
|
||||
|
||||
The summary records the objective, requirements, decisions, completed and active
|
||||
work, blockers, next moves, relevant files, and additional context.
|
||||
|
||||
The newest serialized context up to `keep.tokens` is retained separately. This
|
||||
is not a byte-for-byte transcript: tool output is limited to 2000 characters,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue