|
|
@@ -32,16 +32,105 @@ export const Event = {
|
|
|
|
|
|
export const PRUNE_MINIMUM = 20_000
|
|
|
export const PRUNE_PROTECT = 40_000
|
|
|
+const TOOL_OUTPUT_MAX_CHARS = 2_000
|
|
|
const PRUNE_PROTECTED_TOOLS = ["skill"]
|
|
|
const DEFAULT_TAIL_TURNS = 2
|
|
|
const MIN_PRESERVE_RECENT_TOKENS = 2_000
|
|
|
const MAX_PRESERVE_RECENT_TOKENS = 8_000
|
|
|
+const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged:
|
|
|
+---
|
|
|
+## Goal
|
|
|
+- [single-sentence task summary]
|
|
|
+
|
|
|
+## Constraints & Preferences
|
|
|
+- [user constraints, preferences, specs, or "(none)"]
|
|
|
+
|
|
|
+## Progress
|
|
|
+### Done
|
|
|
+- [completed work or "(none)"]
|
|
|
+
|
|
|
+### In Progress
|
|
|
+- [current work or "(none)"]
|
|
|
+
|
|
|
+### Blocked
|
|
|
+- [blockers or "(none)"]
|
|
|
+
|
|
|
+## Key Decisions
|
|
|
+- [decision and why, or "(none)"]
|
|
|
+
|
|
|
+## Next Steps
|
|
|
+- [ordered next actions or "(none)"]
|
|
|
+
|
|
|
+## Critical Context
|
|
|
+- [important technical facts, errors, open questions, or "(none)"]
|
|
|
+
|
|
|
+## Relevant Files
|
|
|
+- [file or directory path: why it matters, or "(none)"]
|
|
|
+---
|
|
|
+
|
|
|
+Rules:
|
|
|
+- Keep every section, even when empty.
|
|
|
+- Use terse bullets, not prose paragraphs.
|
|
|
+- Preserve exact file paths, commands, error strings, and identifiers when known.
|
|
|
+- Do not mention the summary process or that context was compacted.`
|
|
|
type Turn = {
|
|
|
start: number
|
|
|
end: number
|
|
|
id: MessageID
|
|
|
}
|
|
|
|
|
|
+type Tail = {
|
|
|
+ start: number
|
|
|
+ id: MessageID
|
|
|
+}
|
|
|
+
|
|
|
+type CompletedCompaction = {
|
|
|
+ userIndex: number
|
|
|
+ assistantIndex: number
|
|
|
+ summary: string | undefined
|
|
|
+}
|
|
|
+
|
|
|
+function summaryText(message: MessageV2.WithParts) {
|
|
|
+ const text = message.parts
|
|
|
+ .filter((part): part is MessageV2.TextPart => part.type === "text")
|
|
|
+ .map((part) => part.text.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ .join("\n\n")
|
|
|
+ .trim()
|
|
|
+ return text || undefined
|
|
|
+}
|
|
|
+
|
|
|
+function completedCompactions(messages: MessageV2.WithParts[]) {
|
|
|
+ const users = new Map<MessageID, number>()
|
|
|
+ for (let i = 0; i < messages.length; i++) {
|
|
|
+ const msg = messages[i]
|
|
|
+ if (msg.info.role !== "user") continue
|
|
|
+ if (!msg.parts.some((part) => part.type === "compaction")) continue
|
|
|
+ users.set(msg.info.id, i)
|
|
|
+ }
|
|
|
+
|
|
|
+ return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => {
|
|
|
+ if (msg.info.role !== "assistant") return []
|
|
|
+ if (!msg.info.summary || !msg.info.finish || msg.info.error) return []
|
|
|
+ const userIndex = users.get(msg.info.parentID)
|
|
|
+ if (userIndex === undefined) return []
|
|
|
+ return [{ userIndex, assistantIndex, summary: summaryText(msg) }]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
|
|
+ const anchor = input.previousSummary
|
|
|
+ ? [
|
|
|
+ "Update the anchored summary below using the conversation history above.",
|
|
|
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
|
|
|
+ "<previous-summary>",
|
|
|
+ input.previousSummary,
|
|
|
+ "</previous-summary>",
|
|
|
+ ].join("\n")
|
|
|
+ : "Create a new anchored summary from the conversation history above."
|
|
|
+ return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
|
|
+}
|
|
|
+
|
|
|
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
|
|
return (
|
|
|
input.cfg.compaction?.preserve_recent_tokens ??
|
|
|
@@ -67,6 +156,31 @@ function turns(messages: MessageV2.WithParts[]) {
|
|
|
return result
|
|
|
}
|
|
|
|
|
|
+function splitTurn(input: {
|
|
|
+ messages: MessageV2.WithParts[]
|
|
|
+ turn: Turn
|
|
|
+ model: Provider.Model
|
|
|
+ budget: number
|
|
|
+ estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect<number>
|
|
|
+}) {
|
|
|
+ return Effect.gen(function* () {
|
|
|
+ if (input.budget <= 0) return undefined
|
|
|
+ if (input.turn.end - input.turn.start <= 1) return undefined
|
|
|
+ for (let start = input.turn.start + 1; start < input.turn.end; start++) {
|
|
|
+ const size = yield* input.estimate({
|
|
|
+ messages: input.messages.slice(start, input.turn.end),
|
|
|
+ model: input.model,
|
|
|
+ })
|
|
|
+ if (size > input.budget) continue
|
|
|
+ return {
|
|
|
+ start,
|
|
|
+ id: input.messages[start]!.info.id,
|
|
|
+ } satisfies Tail
|
|
|
+ }
|
|
|
+ return undefined
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
export interface Interface {
|
|
|
readonly isOverflow: (input: {
|
|
|
tokens: MessageV2.Assistant["tokens"]
|
|
|
@@ -147,18 +261,28 @@ export const layer: Layer.Layer<
|
|
|
}),
|
|
|
{ concurrency: 1 },
|
|
|
)
|
|
|
- if (sizes.at(-1)! > budget) {
|
|
|
- log.info("tail fallback", { budget, size: sizes.at(-1) })
|
|
|
- return { head: input.messages, tail_start_id: undefined }
|
|
|
- }
|
|
|
|
|
|
let total = 0
|
|
|
- let keep: Turn | undefined
|
|
|
+ let keep: Tail | undefined
|
|
|
for (let i = recent.length - 1; i >= 0; i--) {
|
|
|
+ const turn = recent[i]!
|
|
|
const size = sizes[i]
|
|
|
- if (total + size > budget) break
|
|
|
- total += size
|
|
|
- keep = recent[i]
|
|
|
+ if (total + size <= budget) {
|
|
|
+ total += size
|
|
|
+ keep = { start: turn.start, id: turn.id }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const remaining = budget - total
|
|
|
+ const split = yield* splitTurn({
|
|
|
+ messages: input.messages,
|
|
|
+ turn,
|
|
|
+ model: input.model,
|
|
|
+ budget: remaining,
|
|
|
+ estimate,
|
|
|
+ })
|
|
|
+ if (split) keep = split
|
|
|
+ else if (!keep) log.info("tail fallback", { budget, size, total })
|
|
|
+ break
|
|
|
}
|
|
|
|
|
|
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
|
|
|
@@ -192,17 +316,15 @@ export const layer: Layer.Layer<
|
|
|
if (msg.info.role === "assistant" && msg.info.summary) break loop
|
|
|
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
|
|
|
const part = msg.parts[partIndex]
|
|
|
- if (part.type === "tool")
|
|
|
- if (part.state.status === "completed") {
|
|
|
- if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
|
|
|
- if (part.state.time.compacted) break loop
|
|
|
- const estimate = Token.estimate(part.state.output)
|
|
|
- total += estimate
|
|
|
- if (total > PRUNE_PROTECT) {
|
|
|
- pruned += estimate
|
|
|
- toPrune.push(part)
|
|
|
- }
|
|
|
- }
|
|
|
+ if (part.type !== "tool") continue
|
|
|
+ if (part.state.status !== "completed") continue
|
|
|
+ if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
|
|
|
+ if (part.state.time.compacted) break loop
|
|
|
+ const estimate = Token.estimate(part.state.output)
|
|
|
+ total += estimate
|
|
|
+ if (total <= PRUNE_PROTECT) continue
|
|
|
+ pruned += estimate
|
|
|
+ toPrune.push(part)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -263,8 +385,11 @@ export const layer: Layer.Layer<
|
|
|
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
|
|
const cfg = yield* config.get()
|
|
|
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
|
|
|
+ const prior = completedCompactions(history)
|
|
|
+ const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
|
|
|
+ const previousSummary = prior.at(-1)?.summary
|
|
|
const selected = yield* select({
|
|
|
- messages: history,
|
|
|
+ messages: history.filter((_, index) => !hidden.has(index)),
|
|
|
cfg,
|
|
|
model,
|
|
|
})
|
|
|
@@ -274,34 +399,13 @@ export const layer: Layer.Layer<
|
|
|
{ sessionID: input.sessionID },
|
|
|
{ context: [], prompt: undefined },
|
|
|
)
|
|
|
- const defaultPrompt = `When constructing the summary, try to stick to this template:
|
|
|
----
|
|
|
-## Goal
|
|
|
-
|
|
|
-[What goal(s) is the user trying to accomplish?]
|
|
|
-
|
|
|
-## Instructions
|
|
|
-
|
|
|
-- [What important instructions did the user give you that are relevant]
|
|
|
-- [If there is a plan or spec, include information about it so next agent can continue using it]
|
|
|
-
|
|
|
-## Discoveries
|
|
|
-
|
|
|
-[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
|
|
|
-
|
|
|
-## Accomplished
|
|
|
-
|
|
|
-[What work has been completed, what work is still in progress, and what work is left?]
|
|
|
-
|
|
|
-## Relevant files / directories
|
|
|
-
|
|
|
-[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
|
|
|
----`
|
|
|
-
|
|
|
- const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
|
|
|
+ const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
|
|
const msgs = structuredClone(selected.head)
|
|
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
|
|
- const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
|
|
|
+ const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
|
|
+ stripMedia: true,
|
|
|
+ toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
|
|
+ })
|
|
|
const ctx = yield* InstanceState.context
|
|
|
const msg: MessageV2.Assistant = {
|
|
|
id: MessageID.ascending(),
|
|
|
@@ -345,7 +449,7 @@ export const layer: Layer.Layer<
|
|
|
...modelMessages,
|
|
|
{
|
|
|
role: "user",
|
|
|
- content: [{ type: "text", text: prompt }],
|
|
|
+ content: [{ type: "text", text: nextPrompt }],
|
|
|
},
|
|
|
],
|
|
|
model,
|