Use AgentMessage in BranchPreparation and add BranchSummarySettings

- BranchPreparation now uses AgentMessage[] instead of custom type
- Reuse getMessageFromEntry pattern from compaction.ts
- Add BranchSummarySettings with reserveFraction to settings.json
- Add getBranchSummarySettings() to SettingsManager
- Use settings for reserveFraction instead of hardcoded value
This commit is contained in:
Mario Zechner 2025-12-29 21:33:04 +01:00
parent 08fab16e2d
commit 839a46e6fe
3 changed files with 110 additions and 107 deletions

View file

@ -1650,11 +1650,13 @@ export class AgentSession {
if (!apiKey) { if (!apiKey) {
throw new Error(`No API key for ${model.provider}`); throw new Error(`No API key for ${model.provider}`);
} }
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
const result = await generateBranchSummary(entriesToSummarize, { const result = await generateBranchSummary(entriesToSummarize, {
model, model,
apiKey, apiKey,
signal: this._branchSummaryAbortController.signal, signal: this._branchSummaryAbortController.signal,
customInstructions: options.customInstructions, customInstructions: options.customInstructions,
reserveFraction: branchSummarySettings.reserveFraction,
}); });
this._branchSummaryAbortController = undefined; this._branchSummaryAbortController = undefined;
if (result.aborted) { if (result.aborted) {

View file

@ -5,9 +5,12 @@
* a summary of the branch being left so context isn't lost. * a summary of the branch being left so context isn't lost.
*/ */
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai"; import type { Model } from "@mariozechner/pi-ai";
import { complete } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage } from "../messages.js";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager.js";
import { estimateTokens } from "./compaction.js";
// ============================================================================ // ============================================================================
// Types // Types
@ -27,10 +30,10 @@ export interface FileOperations {
export interface BranchPreparation { export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */ /** Messages extracted for summarization, in chronological order */
messages: Array<{ role: string; content: string; tokens: number }>; messages: AgentMessage[];
/** File operations extracted from tool calls */ /** File operations extracted from tool calls */
fileOps: FileOperations; fileOps: FileOperations;
/** Total tokens in messages */ /** Total estimated tokens in messages */
totalTokens: number; totalTokens: number;
} }
@ -110,40 +113,49 @@ export function collectEntriesForBranchSummary(
} }
// ============================================================================ // ============================================================================
// Entry Parsing // Entry to Message Conversion
// ============================================================================ // ============================================================================
/** /**
* Estimate token count for a string using chars/4 heuristic. * Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/ */
function estimateStringTokens(text: string): number { function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
return Math.ceil(text.length / 4); switch (entry.type) {
} case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message;
/** case "custom_message":
* Extract text content from any message type. return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
*/
function extractMessageText(message: any): string { case "branch_summary":
if (!message.content) return ""; return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) { case "compaction":
return message.content return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
.filter((c: any) => c.type === "text")
.map((c: any) => c.text) // These don't contribute to conversation content
.join(""); case "thinking_level_change":
case "model_change":
case "custom":
case "label":
return undefined;
} }
return "";
} }
/** /**
* Extract file operations from tool calls in an assistant message. * Extract file operations from tool calls in an assistant message.
*/ */
function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): void { function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (!message.content || !Array.isArray(message.content)) return; if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
for (const block of message.content) { for (const block of message.content) {
if (typeof block !== "object" || block === null) continue; if (typeof block !== "object" || block === null) continue;
if (block.type !== "toolCall") 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; const args = block.arguments as Record<string, unknown> | undefined;
if (!args) continue; if (!args) continue;
@ -171,21 +183,11 @@ function extractFileOpsFromToolCalls(message: any, fileOps: FileOperations): voi
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long. * This ensures we keep the most recent context when the branch is too long.
* *
* Handles:
* - message (user, assistant) - extracts text, counts tokens
* - custom_message - treated as user message
* - branch_summary - included as context
* - compaction - includes summary as context
*
* Skips:
* - toolResult messages (context already in assistant's tool call)
* - thinking_level_change, model_change, custom, label entries
*
* @param entries - Entries in chronological order * @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit) * @param tokenBudget - Maximum tokens to include (0 = no limit)
*/ */
export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: Array<{ role: string; content: string; tokens: number }> = []; const messages: AgentMessage[] = [];
const fileOps: FileOperations = { const fileOps: FileOperations = {
read: new Set(), read: new Set(),
written: new Set(), written: new Set(),
@ -196,85 +198,29 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
// Walk from newest to oldest to prioritize recent context // Walk from newest to oldest to prioritize recent context
for (let i = entries.length - 1; i >= 0; i--) { for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]; const entry = entries[i];
let role: string | undefined; const message = getMessageFromEntry(entry);
let content: string | undefined; if (!message) continue;
switch (entry.type) { // Extract file ops from assistant messages
case "message": { extractFileOpsFromMessage(message, fileOps);
const msgRole = entry.message.role;
// Skip tool results - context is in assistant's tool call const tokens = estimateTokens(message);
if (msgRole === "toolResult") continue;
// Extract file ops from assistant tool calls // Check budget before adding
if (msgRole === "assistant") { if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
extractFileOpsFromToolCalls(entry.message, fileOps); // If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
} }
const text = extractMessageText(entry.message);
if (text) {
role = msgRole;
content = text;
}
break;
} }
// Stop - we've hit the budget
case "custom_message": { break;
const text =
typeof entry.content === "string"
? entry.content
: entry.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (text) {
role = "user";
content = text;
}
break;
}
case "branch_summary": {
role = "context";
content = `[Branch summary: ${entry.summary}]`;
break;
}
case "compaction": {
role = "context";
content = `[Session summary: ${entry.summary}]`;
break;
}
// Skip these - don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
continue;
} }
if (role && content) { messages.unshift(message);
const tokens = estimateStringTokens(content); totalTokens += tokens;
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
// Add truncated version or skip
if (totalTokens < tokenBudget * 0.9) {
// Still have some room, add it
messages.unshift({ role, content, tokens });
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
messages.unshift({ role, content, tokens });
totalTokens += tokens;
}
} }
return { messages, fileOps, totalTokens }; return { messages, fileOps, totalTokens };
@ -321,6 +267,50 @@ function formatFileOperations(fileOps: FileOperations): string {
return `\n\n---\n**Files:**\n${sections.join("\n")}`; return `\n\n---\n**Files:**\n${sections.join("\n")}`;
} }
/**
* Convert messages to text for the summarization prompt.
*/
function messagesToText(messages: AgentMessage[]): string {
const parts: string[] = [];
for (const msg of messages) {
let text = "";
if (msg.role === "user" && typeof msg.content === "string") {
text = msg.content;
} else if (msg.role === "user" && Array.isArray(msg.content)) {
text = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
} else if (msg.role === "assistant" && "content" in msg && Array.isArray(msg.content)) {
text = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
} else if (msg.role === "branchSummary" && "summary" in msg) {
text = `[Branch summary: ${msg.summary}]`;
} else if (msg.role === "compactionSummary" && "summary" in msg) {
text = `[Session summary: ${msg.summary}]`;
} else if (msg.role === "hookMessage" && "content" in msg) {
if (typeof msg.content === "string") {
text = msg.content;
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
}
if (text) {
parts.push(`${msg.role}: ${text}`);
}
}
return parts.join("\n\n");
}
/** /**
* Generate a summary of abandoned branch entries. * Generate a summary of abandoned branch entries.
* *
@ -343,8 +333,8 @@ export async function generateBranchSummary(
return { summary: "No content to summarize" }; return { summary: "No content to summarize" };
} }
// Build conversation text // Build prompt
const conversationText = messages.map((m) => `${m.role}: ${m.content}`).join("\n\n"); const conversationText = messagesToText(messages);
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT; const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
const prompt = `${instructions}\n\nConversation:\n${conversationText}`; const prompt = `${instructions}\n\nConversation:\n${conversationText}`;

View file

@ -8,6 +8,10 @@ export interface CompactionSettings {
keepRecentTokens?: number; // default: 20000 keepRecentTokens?: number; // default: 20000
} }
export interface BranchSummarySettings {
reserveFraction?: number; // default: 0.2 (fraction of context window reserved for summary)
}
export interface RetrySettings { export interface RetrySettings {
enabled?: boolean; // default: true enabled?: boolean; // default: true
maxRetries?: number; // default: 3 maxRetries?: number; // default: 3
@ -38,6 +42,7 @@ export interface Settings {
queueMode?: "all" | "one-at-a-time"; queueMode?: "all" | "one-at-a-time";
theme?: string; theme?: string;
compaction?: CompactionSettings; compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
retry?: RetrySettings; retry?: RetrySettings;
hideThinkingBlock?: boolean; hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
@ -255,6 +260,12 @@ export class SettingsManager {
}; };
} }
getBranchSummarySettings(): { reserveFraction: number } {
return {
reserveFraction: this.settings.branchSummary?.reserveFraction ?? 0.2,
};
}
getRetryEnabled(): boolean { getRetryEnabled(): boolean {
return this.settings.retry?.enabled ?? true; return this.settings.retry?.enabled ?? true;
} }