chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
300
third_party/pi/packages/agent/src/harness/compaction/branch-summarization.ts
vendored
Normal file
300
third_party/pi/packages/agent/src/harness/compaction/branch-summarization.ts
vendored
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
type Api,
|
||||
contentText,
|
||||
type Model,
|
||||
type Models,
|
||||
type RetryCallbacks,
|
||||
type RetryPolicy,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
import type { Context } from "../context.ts";
|
||||
import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts";
|
||||
import type { Branch, Entry, Session } from "../session/index.ts";
|
||||
import { BranchSummaryError, err, ok, type Result } from "../types.ts";
|
||||
import {
|
||||
completeSimpleWithRetries,
|
||||
createSummaryRequestOptions,
|
||||
estimateTokens,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
type SummaryRequest,
|
||||
} from "./compaction.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
serializeConversation,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Generated branch summary data ready to be persisted as a branch-summary entry. */
|
||||
export interface BranchSummaryResult {
|
||||
summary: string;
|
||||
usage?: Usage;
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
/** File-operation details stored on generated branch summary entries. */
|
||||
export interface BranchSummaryDetails {
|
||||
/** Files read while exploring the summarized branch. */
|
||||
readFiles: string[];
|
||||
/** Files modified while exploring the summarized branch. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export type { FileOperations } from "./utils.ts";
|
||||
|
||||
/** Prepared branch content for summarization. */
|
||||
export interface BranchPreparation {
|
||||
/** Messages selected for the branch summary. */
|
||||
messages: AgentMessage[];
|
||||
/** File operations extracted from the branch. */
|
||||
fileOps: FileOperations;
|
||||
/** Estimated token count for selected messages. */
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
/** Entries selected for branch summarization. */
|
||||
export interface CollectEntriesResult {
|
||||
/** Entries to summarize in chronological order. */
|
||||
entries: Entry[];
|
||||
/** Deepest common ancestor between the previous tip and target entry. */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
/** Options for generating a branch summary. */
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
/** Provider collection the summarization request goes through; owns auth resolution. */
|
||||
models: Models;
|
||||
/** Model used for summarization. */
|
||||
model: Model<Api>;
|
||||
/** Optional instructions appended to or replacing the default prompt. */
|
||||
customInstructions?: string;
|
||||
/** Replace the default prompt with custom instructions instead of appending them. */
|
||||
replaceInstructions?: boolean;
|
||||
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
||||
reserveTokens?: number;
|
||||
/** Optional retry policy for transient summarization errors. */
|
||||
retry?: RetryPolicy;
|
||||
/** Optional callbacks for retry reporting. */
|
||||
callbacks?: RetryCallbacks;
|
||||
}
|
||||
|
||||
/** Collect entries that should be summarized before navigating to a different session tree entry. */
|
||||
export async function collectEntriesForBranchSummary(
|
||||
branch: Pick<Branch, "findEntries">,
|
||||
session: Pick<Session, "getEntry">,
|
||||
oldTipId: string | null,
|
||||
targetId: string,
|
||||
context: Context,
|
||||
): Promise<CollectEntriesResult> {
|
||||
if (!oldTipId) {
|
||||
return { entries: [], commonAncestorId: null };
|
||||
}
|
||||
const oldPath = new Set((await branch.findEntries({ start: oldTipId }, context)).map((entry) => entry.id));
|
||||
const targetPath = await branch.findEntries({ start: targetId }, context);
|
||||
let commonAncestorId: string | null = null;
|
||||
for (const entry of targetPath) {
|
||||
if (oldPath.has(entry.id)) {
|
||||
commonAncestorId = entry.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const entries: Entry[] = [];
|
||||
let current: string | null = oldTipId;
|
||||
|
||||
while (current && current !== commonAncestorId) {
|
||||
const entry = await session.getEntry(current, context);
|
||||
if (!entry) throw new Error(`Corrupt session: entry ${current} not found`);
|
||||
entries.push(entry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
entries.reverse();
|
||||
|
||||
return { entries, commonAncestorId };
|
||||
}
|
||||
function getMessageFromEntry(entry: Entry): AgentMessage | undefined {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
if (entry.message.role === "toolResult") return undefined;
|
||||
return entry.message;
|
||||
|
||||
case "branch_summary":
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
||||
case "compaction":
|
||||
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
|
||||
case "custom":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare branch entries for summarization within an optional token budget. */
|
||||
export function prepareBranchEntries(entries: Entry[], tokenBudget: number = 0): BranchPreparation {
|
||||
const messages: AgentMessage[] = [];
|
||||
const fileOps = createFileOps();
|
||||
let totalTokens = 0;
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.type !== "branch_summary" ||
|
||||
typeof entry.details !== "object" ||
|
||||
entry.details === null ||
|
||||
Array.isArray(entry.details)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(entry.details.readFiles)) {
|
||||
for (const path of entry.details.readFiles) {
|
||||
if (typeof path === "string") fileOps.read.add(path);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(entry.details.modifiedFiles)) {
|
||||
for (const path of entry.details.modifiedFiles) {
|
||||
if (typeof path === "string") fileOps.edited.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
const message = getMessageFromEntry(entry);
|
||||
if (!message) continue;
|
||||
extractFileOpsFromMessage(message, fileOps);
|
||||
|
||||
const tokens = estimateTokens(message);
|
||||
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
if (totalTokens < tokenBudget * 0.9) {
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return { messages, fileOps, totalTokens };
|
||||
}
|
||||
|
||||
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
|
||||
`;
|
||||
|
||||
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What was the user trying to accomplish in this branch?]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Work that was started but not finished]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next to continue this work]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/** Generate a summary for abandoned branch entries. */
|
||||
export function generateBranchSummary(
|
||||
entries: Entry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
context: Context,
|
||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||
const { models, model, customInstructions, replaceInstructions, reserveTokens = 16384, retry, callbacks } = options;
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const preparation = prepareBranchEntries(entries, contextWindow - reserveTokens);
|
||||
return generateBranchSummaryWithRequest(
|
||||
preparation,
|
||||
{ customInstructions, replaceInstructions },
|
||||
(aiContext, requestOptions, requestContext) =>
|
||||
completeSimpleWithRetries(models, model, aiContext, requestOptions, retry, callbacks, requestContext),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PreparedBranchSummaryOptions {
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
}
|
||||
|
||||
/** Generate a prepared branch summary through a caller-owned one-request boundary. */
|
||||
export async function generateBranchSummaryWithRequest(
|
||||
preparation: BranchPreparation,
|
||||
options: PreparedBranchSummaryOptions,
|
||||
request: SummaryRequest,
|
||||
context: Context,
|
||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||
const { customInstructions, replaceInstructions } = options;
|
||||
const { messages, fileOps } = preparation;
|
||||
if (messages.length === 0) {
|
||||
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
|
||||
}
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
let instructions: string;
|
||||
if (replaceInstructions && customInstructions) {
|
||||
instructions = customInstructions;
|
||||
} else if (customInstructions) {
|
||||
instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`;
|
||||
} else {
|
||||
instructions = BRANCH_SUMMARY_PROMPT;
|
||||
}
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const response = await request(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummaryRequestOptions({ maxTokens: 2048 }, context),
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return err(
|
||||
new BranchSummaryError(
|
||||
"summarization_failed",
|
||||
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let summary = contentText(response.content);
|
||||
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return ok({
|
||||
summary: summary || "No summary generated",
|
||||
usage: response.usage,
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
});
|
||||
}
|
||||
865
third_party/pi/packages/agent/src/harness/compaction/compaction.ts
vendored
Normal file
865
third_party/pi/packages/agent/src/harness/compaction/compaction.ts
vendored
Normal file
@@ -0,0 +1,865 @@
|
||||
import {
|
||||
type Context as AiContext,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
contentText,
|
||||
type Model,
|
||||
type Models,
|
||||
type RetryCallbacks,
|
||||
type RetryPolicy,
|
||||
retryAssistantCall,
|
||||
type SimpleStreamOptions,
|
||||
type Usage,
|
||||
uuidv7,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
|
||||
import { type Context, getTelemetryContext } from "../context.ts";
|
||||
import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts";
|
||||
import { buildContextEntries, sessionEntryToContextMessages } from "../session/context.ts";
|
||||
import type { CompactionEntry, Entry, JsonValue } from "../session/types.ts";
|
||||
import { CompactionError, err, ok, type Result } from "../types.ts";
|
||||
import { addUsage } from "../utils/usage.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
serializeConversation,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** File-operation details stored on generated compaction entries. */
|
||||
export interface CompactionDetails extends Record<string, JsonValue> {
|
||||
/** Files read in the compacted history. */
|
||||
readFiles: string[];
|
||||
/** Files modified in the compacted history. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileOperations(
|
||||
messages: AgentMessage[],
|
||||
entries: Entry[],
|
||||
prevCompactionIndex: number,
|
||||
): FileOperations {
|
||||
const fileOps = createFileOps();
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (
|
||||
typeof prevCompaction.details === "object" &&
|
||||
prevCompaction.details !== null &&
|
||||
!Array.isArray(prevCompaction.details)
|
||||
) {
|
||||
if (Array.isArray(prevCompaction.details.readFiles)) {
|
||||
for (const path of prevCompaction.details.readFiles) {
|
||||
if (typeof path === "string") fileOps.read.add(path);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(prevCompaction.details.modifiedFiles)) {
|
||||
for (const path of prevCompaction.details.modifiedFiles) {
|
||||
if (typeof path === "string") fileOps.edited.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of messages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
|
||||
return fileOps;
|
||||
}
|
||||
function getMessageFromEntry(entry: Entry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message as AgentMessage;
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
}
|
||||
if (entry.type === "compaction") {
|
||||
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMessageFromEntryForCompaction(entry: Entry): AgentMessage | undefined {
|
||||
if (entry.type === "compaction") {
|
||||
return undefined;
|
||||
}
|
||||
return getMessageFromEntry(entry);
|
||||
}
|
||||
|
||||
/** Generated compaction data ready to be persisted as a compaction entry. */
|
||||
export interface CompactResult<T = JsonValue> {
|
||||
/** Summary text that replaces compacted history in future context. */
|
||||
summary: string;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Usage from the LLM call(s) that generated this summary, if available. */
|
||||
usage?: Usage;
|
||||
/** Retained recent messages stored directly on the compaction entry. */
|
||||
retainedTail: AgentMessage[];
|
||||
/** Optional implementation-specific details stored with the compaction entry. */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
export type SummaryRequest = (
|
||||
aiContext: AiContext,
|
||||
options: SimpleStreamOptions,
|
||||
context: Context,
|
||||
) => Promise<AssistantMessage>;
|
||||
|
||||
export function createSummaryRequestOptions(options: SimpleStreamOptions, context: Context): SimpleStreamOptions {
|
||||
return {
|
||||
...options,
|
||||
signal: context.abortSignal,
|
||||
telemetryContext: getTelemetryContext(context),
|
||||
cacheRetention: "none",
|
||||
sessionId: options.sessionId ?? uuidv7(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function completeSimpleWithRetries(
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
aiContext: AiContext,
|
||||
options: SimpleStreamOptions,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<AssistantMessage> {
|
||||
// Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused.
|
||||
const requestOptions = createSummaryRequestOptions(options, context);
|
||||
return retryAssistantCall(
|
||||
() => models.completeSimple(model, aiContext, requestOptions),
|
||||
retry,
|
||||
requestOptions.signal,
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
|
||||
/** Compaction thresholds and retention settings. */
|
||||
export interface CompactionSettings {
|
||||
/** Enable automatic compaction decisions. */
|
||||
enabled: boolean;
|
||||
/** Tokens reserved for summary prompt and output. */
|
||||
reserveTokens: number;
|
||||
/** Approximate recent-context tokens to keep after compaction. */
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
/** Default compaction settings used by the harness. */
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
/** Calculate total context tokens from provider usage. */
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (
|
||||
assistantMsg.stopReason !== "aborted" &&
|
||||
assistantMsg.stopReason !== "error" &&
|
||||
assistantMsg.usage &&
|
||||
calculateContextTokens(assistantMsg.usage) > 0
|
||||
) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return usage from the last valid assistant message in session entries. */
|
||||
export function getLastAssistantUsage(entries: Entry[]): Usage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message as AgentMessage);
|
||||
if (usage) return usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Estimated context-token usage for a message list. */
|
||||
export interface ContextUsageEstimate {
|
||||
/** Estimated total context tokens. */
|
||||
tokens: number;
|
||||
/** Tokens reported by the most recent assistant usage block. */
|
||||
usageTokens: number;
|
||||
/** Estimated tokens after the most recent assistant usage block. */
|
||||
trailingTokens: number;
|
||||
/** Index of the message that provided usage, or null when none exists. */
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const usage = getAssistantUsage(messages[i]);
|
||||
if (usage) return { usage, index: i };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Estimate context tokens for messages using provider usage when available. */
|
||||
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
|
||||
if (!usageInfo) {
|
||||
let estimated = 0;
|
||||
for (const message of messages) {
|
||||
estimated += estimateTokens(message);
|
||||
}
|
||||
return {
|
||||
tokens: estimated,
|
||||
usageTokens: 0,
|
||||
trailingTokens: estimated,
|
||||
lastUsageIndex: null,
|
||||
};
|
||||
}
|
||||
|
||||
const usageTokens = calculateContextTokens(usageInfo.usage);
|
||||
let trailingTokens = 0;
|
||||
for (let i = usageInfo.index + 1; i < messages.length; i++) {
|
||||
trailingTokens += estimateTokens(messages[i]);
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: usageTokens + trailingTokens,
|
||||
usageTokens,
|
||||
trailingTokens,
|
||||
lastUsageIndex: usageInfo.index,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return whether context usage exceeds the configured compaction threshold. */
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
const ESTIMATED_IMAGE_CHARS = 4800;
|
||||
|
||||
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "image") {
|
||||
chars += ESTIMATED_IMAGE_CHARS;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** Estimate token count for one message using a conservative character heuristic. */
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
chars = estimateTextAndImageContentChars(
|
||||
(message as { content: string | Array<{ type: string; text?: string }> }).content,
|
||||
);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "custom":
|
||||
case "toolResult": {
|
||||
chars = estimateTextAndImageContentChars(message.content);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
chars = message.command.length + message.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "branchSummary":
|
||||
case "compactionSummary": {
|
||||
chars = message.summary.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
function findValidCutPoints(entries: Entry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const role = entry.message.role;
|
||||
switch (role) {
|
||||
case "bashExecution":
|
||||
case "custom":
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
case "assistant":
|
||||
cutPoints.push(i);
|
||||
break;
|
||||
case "toolResult":
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
break;
|
||||
}
|
||||
if (entry.type === "branch_summary") cutPoints.push(i);
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/** Find the user-visible message that starts the turn containing an entry. */
|
||||
export function findTurnStartIndex(entries: Entry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "branch_summary") {
|
||||
return i;
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Cut point selected for compaction. */
|
||||
export interface CutPointResult {
|
||||
/** Index of the first entry retained after compaction. */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
|
||||
turnStartIndex: number;
|
||||
/** Whether the selected cut point splits an in-progress turn. */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
|
||||
export function findCutPoint(
|
||||
entries: Entry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = cutPoints[0];
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
const messageTokens = estimateTokens(entry.message as AgentMessage);
|
||||
accumulatedTokens += messageTokens;
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
break;
|
||||
}
|
||||
cutIndex--;
|
||||
}
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
||||
|
||||
Update the existing structured summary with new information. RULES:
|
||||
- PRESERVE all existing information from the previous summary
|
||||
- ADD new progress, decisions, and context from the new messages
|
||||
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
||||
- UPDATE "Next Steps" based on what was accomplished
|
||||
- PRESERVE exact file paths, function names, and error messages
|
||||
- If something is no longer relevant, you may remove it
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals, add new ones if the task expanded]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Preserve existing, add new ones discovered]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Include previously done items AND newly completed items]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work - update based on progress]
|
||||
|
||||
### Blocked
|
||||
- [Current blockers - remove if resolved]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
||||
|
||||
## Next Steps
|
||||
1. [Update based on current state]
|
||||
|
||||
## Critical Context
|
||||
- [Preserve important context, add new if needed]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/** Generate or update a conversation summary for compaction. */
|
||||
export async function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
customInstructions: string | undefined,
|
||||
previousSummary: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const result = await generateSummaryWithUsage(
|
||||
currentMessages,
|
||||
models,
|
||||
model,
|
||||
reserveTokens,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
context,
|
||||
);
|
||||
return result.ok ? ok(result.value.text) : err(result.error);
|
||||
}
|
||||
|
||||
/** Generate or update a conversation summary and return its provider usage. */
|
||||
export function generateSummaryWithUsage(
|
||||
currentMessages: AgentMessage[],
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
customInstructions: string | undefined,
|
||||
previousSummary: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
return generateSummaryWithRequest(
|
||||
currentMessages,
|
||||
{ model, reserveTokens, customInstructions, previousSummary, thinkingLevel },
|
||||
(aiContext, options, requestContext) =>
|
||||
completeSimpleWithRetries(models, model, aiContext, options, retry, callbacks, requestContext),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
export interface SummaryGenerationOptions {
|
||||
model: Model<Api>;
|
||||
reserveTokens: number;
|
||||
customInstructions?: string;
|
||||
previousSummary?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
/** Generate one summary through a caller-owned one-request boundary. */
|
||||
export async function generateSummaryWithRequest(
|
||||
currentMessages: AgentMessage[],
|
||||
options: SummaryGenerationOptions,
|
||||
request: SummaryRequest,
|
||||
context: Context,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const { model, reserveTokens, customInstructions, previousSummary, thinkingLevel } = options;
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
||||
if (customInstructions) {
|
||||
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
|
||||
}
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (previousSummary) {
|
||||
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
|
||||
}
|
||||
promptText += basePrompt;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const completionOptions =
|
||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||
? { maxTokens, reasoning: thinkingLevel }
|
||||
: { maxTokens };
|
||||
|
||||
const response = await request(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummaryRequestOptions(completionOptions, context),
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return err(
|
||||
new CompactionError(
|
||||
"summarization_failed",
|
||||
`Summarization failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = contentText(response.content);
|
||||
|
||||
return ok({ text: textContent, usage: response.usage });
|
||||
}
|
||||
|
||||
/** Prepared inputs for a compaction run. */
|
||||
export interface CompactionPreparation {
|
||||
/** Messages summarized into the history summary. */
|
||||
messagesToSummarize: AgentMessage[];
|
||||
/** Prefix messages summarized separately when compaction splits a turn. */
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
/** Recent messages retained after compaction and stored on the compaction entry. */
|
||||
retainedTail: AgentMessage[];
|
||||
/** Whether compaction splits a turn. */
|
||||
isSplitTurn: boolean;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Previous compaction summary used for iterative updates. */
|
||||
previousSummary?: string;
|
||||
/** File operations extracted from summarized history. */
|
||||
fileOps: FileOperations;
|
||||
/** Settings used to prepare compaction. */
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
|
||||
export function prepareCompaction(
|
||||
pathEntries: Entry[],
|
||||
settings: CompactionSettings,
|
||||
): Result<CompactionPreparation | undefined, CompactionError> {
|
||||
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
||||
if (pathEntries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let previousSummary: string | undefined;
|
||||
let compactableEntries = pathEntries;
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
||||
previousSummary = prevCompaction.summary;
|
||||
const virtualRetainedEntries: Entry[] = prevCompaction.retainedTail.map((message, index) => ({
|
||||
type: "message",
|
||||
id: `${prevCompaction.id}:retained:${index}`,
|
||||
parentId: index === 0 ? prevCompaction.id : `${prevCompaction.id}:retained:${index - 1}`,
|
||||
seq: prevCompaction.seq,
|
||||
timestamp: message.timestamp,
|
||||
message,
|
||||
}));
|
||||
compactableEntries = [...virtualRetainedEntries, ...pathEntries.slice(prevCompactionIndex + 1)];
|
||||
}
|
||||
const boundaryEnd = compactableEntries.length;
|
||||
|
||||
const tokensBefore = estimateContextTokens(
|
||||
buildContextEntries(pathEntries).flatMap(sessionEntryToContextMessages),
|
||||
).tokens;
|
||||
|
||||
const cutPoint = findCutPoint(compactableEntries, 0, boundaryEnd, settings.keepRecentTokens);
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
const messagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = 0; i < historyEnd; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(compactableEntries[i]);
|
||||
if (msg) messagesToSummarize.push(msg);
|
||||
}
|
||||
const turnPrefixMessages: AgentMessage[] = [];
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(compactableEntries[i]);
|
||||
if (msg) turnPrefixMessages.push(msg);
|
||||
}
|
||||
}
|
||||
const retainedTail: AgentMessage[] = [];
|
||||
for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(compactableEntries[i]);
|
||||
if (msg) retainedTail.push(msg);
|
||||
}
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
return ok({
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
retainedTail,
|
||||
isSplitTurn: cutPoint.isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
|
||||
Summarize the prefix to provide context for the retained suffix:
|
||||
|
||||
## Original Request
|
||||
[What did the user ask for in this turn?]
|
||||
|
||||
## Early Progress
|
||||
- [Key decisions and work done in the prefix]
|
||||
|
||||
## Context for Suffix
|
||||
- [Information needed to understand the retained recent work]
|
||||
|
||||
Be concise. Focus on what's needed to understand the kept suffix.`;
|
||||
|
||||
export { serializeConversation } from "./utils.ts";
|
||||
|
||||
/** Generate compaction summary data from prepared session history. */
|
||||
export function compact(
|
||||
preparation: CompactionPreparation,
|
||||
models: Models,
|
||||
model: Model<Api>,
|
||||
customInstructions: string | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
retry: RetryPolicy | undefined,
|
||||
callbacks: RetryCallbacks | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<CompactResult, CompactionError>> {
|
||||
return compactWithRequest(
|
||||
preparation,
|
||||
{ model, customInstructions, thinkingLevel },
|
||||
(aiContext, options, requestContext) =>
|
||||
completeSimpleWithRetries(models, model, aiContext, options, retry, callbacks, requestContext),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
export interface CompactGenerationOptions {
|
||||
model: Model<Api>;
|
||||
customInstructions?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
/** Generate compaction data through a caller-owned boundary for each provider request. */
|
||||
export async function compactWithRequest(
|
||||
preparation: CompactionPreparation,
|
||||
options: CompactGenerationOptions,
|
||||
request: SummaryRequest,
|
||||
context: Context,
|
||||
): Promise<Result<CompactResult, CompactionError>> {
|
||||
const { model, customInstructions, thinkingLevel } = options;
|
||||
const {
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
retainedTail,
|
||||
isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
} = preparation;
|
||||
|
||||
let summary: string;
|
||||
let summaryUsage: Usage;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
let historyText = "No prior history.";
|
||||
let historyUsage: Usage | undefined;
|
||||
if (messagesToSummarize.length > 0) {
|
||||
const historyResult = await generateSummaryWithRequest(
|
||||
messagesToSummarize,
|
||||
{ model, reserveTokens: settings.reserveTokens, customInstructions, previousSummary, thinkingLevel },
|
||||
request,
|
||||
context,
|
||||
);
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
historyText = historyResult.value.text;
|
||||
historyUsage = historyResult.value.usage;
|
||||
}
|
||||
const turnPrefixResult = await generateTurnPrefixSummary(
|
||||
turnPrefixMessages,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
thinkingLevel,
|
||||
request,
|
||||
context,
|
||||
);
|
||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
||||
summaryUsage = historyUsage ? addUsage(historyUsage, turnPrefixResult.value.usage) : turnPrefixResult.value.usage;
|
||||
} else {
|
||||
const summaryResult = await generateSummaryWithRequest(
|
||||
messagesToSummarize,
|
||||
{ model, reserveTokens: settings.reserveTokens, customInstructions, previousSummary, thinkingLevel },
|
||||
request,
|
||||
context,
|
||||
);
|
||||
if (!summaryResult.ok) return err(summaryResult.error);
|
||||
summary = summaryResult.value.text;
|
||||
summaryUsage = summaryResult.value.usage;
|
||||
}
|
||||
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
const details: CompactionDetails = { readFiles, modifiedFiles };
|
||||
|
||||
return ok({ summary, tokensBefore, usage: summaryUsage, retainedTail, details });
|
||||
}
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AgentMessage[],
|
||||
model: Model<Api>,
|
||||
reserveTokens: number,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
request: SummaryRequest,
|
||||
context: Context,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const completionOptions =
|
||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||
? { maxTokens, reasoning: thinkingLevel }
|
||||
: { maxTokens };
|
||||
const response = await request(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummaryRequestOptions(completionOptions, context),
|
||||
context,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return err(
|
||||
new CompactionError(
|
||||
"summarization_failed",
|
||||
`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ok({
|
||||
text: contentText(response.content),
|
||||
usage: response.usage,
|
||||
});
|
||||
}
|
||||
132
third_party/pi/packages/agent/src/harness/compaction/utils.ts
vendored
Normal file
132
third_party/pi/packages/agent/src/harness/compaction/utils.ts
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
import { contentText, type Message } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
|
||||
/** File paths touched by a session branch or compaction range. */
|
||||
export interface FileOperations {
|
||||
/** Files read but not necessarily modified. */
|
||||
read: Set<string>;
|
||||
/** Files written by full-file write operations. */
|
||||
written: Set<string>;
|
||||
/** Files modified by edit operations. */
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
/** Create an empty file-operation accumulator. */
|
||||
export function createFileOps(): FileOperations {
|
||||
return {
|
||||
read: new Set(),
|
||||
written: new Set(),
|
||||
edited: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Add file operations from assistant tool calls to an accumulator. */
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role !== "assistant") return;
|
||||
if (!("content" in message) || !Array.isArray(message.content)) return;
|
||||
|
||||
for (const block of message.content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
if (!("type" in block) || block.type !== "toolCall") continue;
|
||||
if (!("arguments" in block) || !("name" in block)) continue;
|
||||
|
||||
const args = block.arguments as Record<string, unknown> | undefined;
|
||||
if (!args) continue;
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : undefined;
|
||||
if (!path) continue;
|
||||
|
||||
switch (block.name) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute sorted read-only and modified file lists from accumulated operations. */
|
||||
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
const modifiedFiles = [...modified].sort();
|
||||
return { readFiles: readOnly, modifiedFiles };
|
||||
}
|
||||
|
||||
/** Format file lists as summary metadata tags. */
|
||||
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
const TOOL_RESULT_MAX_CHARS = 2000;
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function truncateForSummary(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const truncatedChars = text.length - maxChars;
|
||||
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
|
||||
}
|
||||
|
||||
/** Serialize LLM messages to plain text for summarization prompts. */
|
||||
export function serializeConversation(messages: Message[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content = contentText(msg.content, "");
|
||||
if (content) parts.push(`[User]: ${content}`);
|
||||
} else if (msg.role === "assistant") {
|
||||
const thinkingParts: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking") {
|
||||
thinkingParts.push(block.thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
const args = block.arguments as Record<string, unknown>;
|
||||
const argsStr = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
|
||||
.join(", ");
|
||||
toolCalls.push(`${block.name}(${argsStr})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingParts.length > 0) {
|
||||
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
||||
}
|
||||
if (msg.content.some((block) => block.type === "text")) {
|
||||
parts.push(`[Assistant]: ${contentText(msg.content)}`);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const content = contentText(msg.content, "");
|
||||
if (content) {
|
||||
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
Reference in New Issue
Block a user