Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
7.4 kB · 200 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201/** * Shared utilities for compaction and branch summarization. */
import type { AgentMessage } from "@earendil-works/pi-agent-core";import type { Message } from "@earendil-works/pi-ai";export interface FileOperations { read: Set<string>; written: Set<string>; edited: Set<string>;}
export function createFileOps(): FileOperations { return { read: new Set(), written: new Set(), edited: new Set(), };}
/** * Extract file operations from tool calls in an assistant message and from * structured tool results. */export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { if (message.role === "toolResult") { extractFileOpsFromToolResult(message, fileOps); return; } 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 "edit": fileOps.edited.add(path); break; } }}
/** * Extract file operations from a tool result message. * * The default toolset routes file edits through the ipython kernel: the * kernel's edit skill reports structured diff displays (path, oldStr, newStr) * that ride on the tool result's details, and no assistant-side tool call * ever carries the path. Without this branch, compaction summaries never * learn about kernel-performed edits and <modified-files> stays empty in the * default configuration. */function extractFileOpsFromToolResult(message: AgentMessage, fileOps: FileOperations): void { if (message.role !== "toolResult" || message.toolName !== "ipython") return; const details = typeof message.details === "object" && message.details !== null && !Array.isArray(message.details) ? (message.details as Record<string, unknown>) : {}; const diffs = Array.isArray(details.diffs) ? details.diffs : []; for (const diff of diffs) { if (typeof diff !== "object" || diff === null || Array.isArray(diff)) continue; const path = (diff as Record<string, unknown>).path; if (typeof path === "string" && path) fileOps.edited.add(path); }}
/** * Maximum files kept per summary block, so a single oversized kernel * result cannot produce a file list larger than the model context limit. */const FILE_LIST_MAX_ENTRIES = 200;
/** * Compute final file lists from file operations. * Returns readFiles (files only read, not modified) and modifiedFiles. * Both lists are capped at FILE_LIST_MAX_ENTRIES (sorted, then truncated). */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() .slice(0, FILE_LIST_MAX_ENTRIES); const modifiedFiles = [...modified].sort().slice(0, FILE_LIST_MAX_ENTRIES); return { readFiles: readOnly, modifiedFiles };}
/** * Format file operations as XML tags for summary. */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")}`;}/** Maximum characters for a tool result in serialized summaries. */const TOOL_RESULT_MAX_CHARS = 2000;/** * Characters kept from the end of a truncated tool result. Tool output is * tail-heavy: exit errors, stack traces, and log tails appear at the end. */const TOOL_RESULT_TAIL_CHARS = 500;
/** * Truncate text to a maximum character length for summarization. * Keeps the beginning and the end within the same total budget, marking * the elided middle. */function truncateForSummary(text: string, maxChars: number): string { if (text.length <= maxChars) return text; // The marker's digit counts are largest when the elided and kept sizes hit // the text and budget maxima, so reserve space for that worst case to keep // the result within maxChars. const markerMaxLength = `[... ${text.length} characters truncated; first ${maxChars} and last ${TOOL_RESULT_TAIL_CHARS} kept ...]`.length; const headChars = maxChars - TOOL_RESULT_TAIL_CHARS - markerMaxLength - 4; const elided = text.length - headChars - TOOL_RESULT_TAIL_CHARS; return `${text.slice(0, headChars)}\n\n[... ${elided} characters truncated; first ${headChars} and last ${TOOL_RESULT_TAIL_CHARS} kept ...]\n\n${text.slice(text.length - TOOL_RESULT_TAIL_CHARS)}`;}
/** * Serialize LLM messages to text for summarization. * This prevents the model from treating it as a conversation to continue. * Call convertToLlm() first to handle custom message types. * * Tool results are truncated to keep the summarization request within * reasonable token budgets. Full content is not needed for summarization. */export function serializeConversation(messages: Message[]): string { const parts: string[] = [];
for (const msg of messages) { if (msg.role === "user") { const content = typeof msg.content === "string" ? msg.content : msg.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { const textParts: string[] = []; const thinkingParts: string[] = []; const toolCalls: string[] = [];
for (const block of msg.content) { if (block.type === "text") { textParts.push(block.text); } else 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}=${JSON.stringify(v)}`) .join(", "); toolCalls.push(`${block.name}(${argsStr})`); } }
if (thinkingParts.length > 0) { parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); } if (textParts.length > 0) { parts.push(`[Assistant]: ${textParts.join("\n")}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { const content = msg.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join(""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } } }
return parts.join("\n\n");}export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding 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.`;