Skip to content

pi-mono Deep Dive 12: Compaction Deep Dive — Strategy, Token Estimation, Branch Summary, Structured Compaction

Aug 31, 20261 min
TL;DRComplete Compaction Mechanism: shouldCompact Trigger Conditions (Token Ratio, Message Count), estimateTokens Calculation (Char/Word Approximation), findCutPoint Finding Cut Point (Retain Recent N Turns), generateSummary Generating Summary (LLM Call), prepareCompaction Preparing Context, Branch Summary Generation, Structured Compaction (Extension Custom via fromHook), CompactionEntry Details, fromHook Mechanism, Compaction Settings.

🌏 中文版

TL;DR

  • Trigger Conditions: shouldCompact Checks Token Ratio (Default 75%), Message Count, Model Context Window
  • Token Estimation: estimateTokens Char Approximation (English 4 Chars/Token, Chinese 1.5 Chars/Token), estimateContextTokens Includes System Prompt, Tools
  • Cut Point Finding: findCutPoint Accumulates from Newest to Oldest, Retains Recent N Turns, Minimum 1 Turn
  • Summary Generation: generateSummary Calls LLM (Optional Smaller Model), Outputs Structured Summary
  • Branch Summary: generateBranchSummary Records Abandoned Branch Key Info
  • Structured Compaction: fromHook=true Allows Extension Custom Compression Logic, Preserves Structured Data
  • CompactionEntry: firstKeptEntryId, tokensBefore, usage, details, fromHook

Why Compaction is Needed?

ProblemSolution
Limited Context Window (4k~200k Tokens)Compress Old Conversation, Free Space for New Messages
Long Conversation Token Cost HighSummary Replaces Original, Drastically Reduces Tokens
Critical Info Must Not Be LostRetain Recent Turns, Generate Structured Summary
Branch Experiments Generate HistoryBranch Summary Records Discarded Paths

Core Flow: prepareNextTurn → shouldCompact → compact

1. shouldCompact: Trigger Decision

// packages/agent/src/harness/compaction/compaction.ts
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
  // Token Ratio Threshold (Default 75%)
  threshold: 0.75,
  // Minimum Turns to Keep
  minTurnsToKeep: 2,
  // Max Summary Length
  maxSummaryTokens: 4096,
  // Summary Generation Model (Optional Smaller Model Saves Cost)
  summaryModel: undefined,  // undefined = Use Current Model
};

export async function shouldCompact(
  context: AgentContext,
  settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS
): Promise<boolean> {
  // 1. Estimate Current Context Tokens
  const currentTokens = await estimateContextTokens(context);
  
  // 2. Get Model Context Window
  const modelConfig = context.model;
  const contextWindow = modelConfig?.contextWindow ?? 128000;
  
  // 3. Calculate Ratio
  const ratio = currentTokens / contextWindow;
  
  // 4. Check Message Count
  const messageCount = context.messages.filter(m => m.role === "user" || m.role === "assistant").length;
  
  return ratio >= settings.threshold || messageCount > settings.minTurnsToKeep * 10;
}

2. estimateTokens: Token Estimation

// packages/agent/src/harness/compaction/compaction.ts
export function estimateTokens(text: string): number {
  if (!text) return 0;
  // English: ~4 Chars/Token, Chinese: ~1.5 Chars/Token
  // Simplified: Total Chars / 3.5
  return Math.ceil(text.length / 3.5);
}

export function estimateContextTokens(context: AgentContext): number {
  let total = 0;
  
  // System Prompt
  total += estimateTokens(context.systemPrompt);
  
  // Tools Schema
  for (const tool of context.tools ?? []) {
    total += estimateTokens(JSON.stringify(tool.parameters));
  }
  
  // Messages
  for (const message of context.messages) {
    if (typeof message.content === "string") {
      total += estimateTokens(message.content);
    } else {
      for (const block of message.content) {
        if (block.type === "text") total += estimateTokens(block.text);
        else if (block.type === "toolCall") total += estimateTokens(JSON.stringify(block.arguments));
        else if (block.type === "toolResult") total += estimateTokens(JSON.stringify(block.content));
        else if (block.type === "thinking") total += estimateTokens(block.thinking);
      }
    }
  }
  
  return total;
}

3. findCutPoint: Finding Cut Point

export interface CutPointResult {
  cutIndex: number;           // 0-based Index, Cut After This
  tokensBefore: number;       // Tokens Before Cut Point
  tokensAfter: number;        // Tokens After Cut Point
}

export function findCutPoint(
  messages: AgentMessage[],
  targetTokens: number,       // Target Tokens to Keep
  settings: CompactionSettings
): CutPointResult {
  // Accumulate from Newest to Oldest Until Exceeds Target
  let accumulated = 0;
  let cutIndex = messages.length - 1;
  
  for (let i = messages.length - 1; i >= 0; i--) {
    const msgTokens = estimateMessageTokens(messages[i]);
    if (accumulated + msgTokens > targetTokens) {
      // Found Cut Point: Keep Messages After i
      cutIndex = i;
      break;
    }
    accumulated += msgTokens;
  }
  
  // Ensure Minimum minTurnsToKeep User/Assistant Turns
  const userAssistantIndices = messages
    .map((m, i) => (m.role === "user" || m.role === "assistant") ? i : -1)
    .filter(i => i >= 0);
  
  if (userAssistantIndices.length > settings.minTurnsToKeep * 2) {
    const minKeepIndex = userAssistantIndices[userAssistantIndices.length - settings.minTurnsToKeep * 2];
    cutIndex = Math.min(cutIndex, minKeepIndex - 1);
  }
  
  const tokensBefore = messages.slice(0, cutIndex + 1).reduce((sum, m) => sum + estimateMessageTokens(m), 0);
  const tokensAfter = messages.slice(cutIndex + 1).reduce((sum, m) => sum + estimateMessageTokens(m), 0);
  
  return { cutIndex, tokensBefore, tokensAfter };
}

4. generateSummary: Generating Summary

export async function generateSummary(
  messages: AgentMessage[],
  model: ModelConfig,
  streamFn: StreamFn,
  settings: CompactionSettings,
  signal: AbortSignal
): Promise<{ summary: string; usage?: Usage }> {
  // Build Summary Prompt
  const summaryPrompt = buildSummaryPrompt(messages);
  
  // Call LLM to Generate Summary (Can Use Smaller Model)
  const summaryModel = settings.summaryModel ?? model;
  const summaryContext: Context = {
    systemPrompt: SUMMARY_SYSTEM_PROMPT,
    messages: [{ role: "user", content: summaryPrompt }],
    tools: [],
  };
  
  const stream = streamFn(summaryModel, summaryContext, { signal });
  let summary = "";
  
  for await (const event of stream) {
    if (event.type === "text_delta") {
      summary += event.partial.content[0]?.text ?? "";
    } else if (event.type === "done") {
      const result = await event.result();
      return { summary: result.content[0]?.text ?? "", usage: result.usage };
    }
  }
  
  return { summary };
}

const SUMMARY_SYSTEM_PROMPT = `You are a conversation summarizer. Compress the following conversation into a structured summary containing:
1. Key Decisions & Conclusions
2. Important Code Changes or File Operations
3. Resolved Issues & Outstanding Items
4. Tools & Technologies Used
Answer in Traditional Chinese, Keep Concise but Complete.`;

5. compact: Complete Flow

export interface CompactionResult {
  newContext: AgentContext;
  summary: string;
  tokensBefore: number;
  tokensAfter: number;
  cutIndex: number;
  usage?: Usage;
}

export async function compact(
  context: AgentContext,
  settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS,
  streamFn: StreamFn,
  signal: AbortSignal
): Promise<CompactionResult> {
  // 1. Estimate Tokens
  const tokensBefore = await estimateContextTokens(context);
  const contextWindow = context.model?.contextWindow ?? 128000;
  const targetTokens = Math.floor(contextWindow * (1 - settings.threshold));
  
  // 2. Find Cut Point
  const { cutIndex, tokensBefore: tb, tokensAfter } = findCutPoint(
    context.messages,
    targetTokens,
    settings
  );
  
  // 3. Generate Summary (Discarded Messages)
  const messagesToSummarize = context.messages.slice(0, cutIndex + 1);
  const { summary, usage } = await generateSummary(
    messagesToSummarize,
    context.model,
    streamFn,
    settings,
    signal
  );
  
  // 4. Reconstruct Context: [Summary] + [Kept Messages]
  const keptMessages = context.messages.slice(cutIndex + 1);
  const summaryMessage: AgentMessage = {
    role: "assistant",
    content: [
      { type: "text", text: `[Compaction Summary]\n${summary}` }
    ],
    provider: context.model.provider,
    model: context.model.id,
  };
  
  const newContext: AgentContext = {
    ...context,
    messages: [summaryMessage, ...keptMessages],
  };
  
  return {
    newContext,
    summary,
    tokensBefore,
    tokensAfter: tokensAfter + estimateTokens(summary),
    cutIndex,
    usage,
  };
}

Integration in Agent Loop

prepareNextTurn Calls Compact

// packages/coding-agent/src/core/agent-session.ts
prepareNextTurn: async (turn: PrepareNextTurnContext) => {
  // 1. Check Compaction
  const shouldCompactResult = await shouldCompact(turn.context);
  if (shouldCompactResult) {
    const compactionResult = await compact(
      turn.context,
      this.compactionSettings,
      this.streamFunction,
      turn.signal
    );
    
    // Write to Session
    const firstKeptEntryId = turn.context.messages[compactionResult.cutIndex + 1]?.id;
    this.sessionManager.appendCompaction(
      compactionResult.summary,
      firstKeptEntryId,
      compactionResult.tokensBefore,
      undefined,  // details
      false,      // fromHook
      compactionResult.usage
    );
    
    return { context: compactionResult.newContext };
  }
  
  // 2. Model Switch, Thinking Level, etc...
  return undefined;
}

SessionManager Writes CompactionEntry

// packages/coding-agent/src/core/session-manager.ts
appendCompaction<T = unknown>(
  summary: string,
  firstKeptEntryId: string,
  tokensBefore: number,
  details?: T,
  fromHook?: boolean,
  usage?: Usage
): string {
  const entry: CompactionEntry<T> = {
    type: "compaction",
    id: generateId(this.byId),
    parentId: this.leafId,
    timestamp: new Date().toISOString(),
    summary,
    firstKeptEntryId,
    tokensBefore,
    details,
    usage,
    fromHook,
  };
  this._appendEntry(entry);
  return entry.id;
}

Branch Summary: Recording Abandoned Paths

Generating Branch Summary

// packages/agent/src/harness/compaction/branch-summarization.ts
export async function generateBranchSummary(
  options: GenerateBranchSummaryOptions,
  model: ModelConfig,
  streamFn: StreamFn,
  signal: AbortSignal
): Promise<BranchSummaryResult> {
  const { fromId, toId, context } = options;
  
  // 1. Get Abandoned Path Messages
  const abandonedMessages = getAbandonedMessages(context, fromId, toId);
  
  // 2. Generate Summary
  const { summary, usage } = await generateSummary(
    abandonedMessages,
    model,
    streamFn,
    { ...DEFAULT_COMPACTION_SETTINGS, maxSummaryTokens: 2048 },
    signal
  );
  
  return { summary, usage };
}

export async function collectEntriesForBranchSummary(
  fromId: string,
  toId: string | null,
  sessionManager: SessionManager
): Promise<SessionEntry[]> {
  // Collect Entries Between fromId and toId
  const entries: SessionEntry[] = [];
  let current = fromId;
  while (current && current !== toId) {
    const entry = sessionManager.getEntry(current);
    if (entry) entries.push(entry);
    current = entry.parentId ?? null;
  }
  return entries.reverse();
}

Branch Summary Entry

// SessionManager.appendCompaction -> branchWithSummary
branchWithSummary(
  branchFromId: string | null,
  summary: string,
  details?: unknown,
  fromHook?: boolean,
  usage?: Usage
): string {
  const fromId = this.leafId ?? "root";
  this.leafId = branchFromId;
  const entry: BranchSummaryEntry = {
    type: "branch_summary",
    id: generateId(this.byId),
    parentId: branchFromId,
    timestamp: new Date().toISOString(),
    fromId,
    summary,
    details,
    usage,
    fromHook,
  };
  this._appendEntry(entry);
  return entry.id;
}

Structured Compaction: Extension Custom Compression

fromHook Mechanism

// Extension Can Implement Custom Compaction
interface Extension {
  // ...
  onCompaction?: (context: AgentContext, signal: AbortSignal) => Promise<{
    summary: string;
    keptMessages: AgentMessage[];
    details?: unknown;
    usage?: Usage;
  }>;
}

AgentSession Integration

// packages/coding-agent/src/core/agent-session.ts
private async runCompaction(context: AgentContext, signal: AbortSignal): Promise<CompactionResult> {
  // 1. Try Extension Compaction
  for (const ext of this.extensions) {
    if (ext.onCompaction) {
      const result = await ext.onCompaction(context, signal);
      if (result) {
        // Extension Returns Custom Result
        const firstKeptEntryId = result.keptMessages[0]?.id;
        this.sessionManager.appendCompaction(
          result.summary,
          firstKeptEntryId,
          estimateContextTokens(context),
          result.details,
          true,  // fromHook = true
          result.usage
        );
        return {
          newContext: { ...context, messages: [createCompactionMessage(result.summary), ...result.keptMessages] },
          summary: result.summary,
          tokensBefore: estimateContextTokens(context),
          tokensAfter: estimateTokens(result.summary) + estimateContextTokens({ ...context, messages: result.keptMessages }),
          cutIndex: context.messages.length - result.keptMessages.length,
          usage: result.usage,
        };
      }
    }
  }
  
  // 2. Fallback to Built-in Compact
  return compact(context, this.compactionSettings, this.streamFunction, signal);
}

Example: Git-aware Compaction Extension

// Preserve Git-related Messages, Compress Others
const gitAwareCompactionExtension: Extension = {
  name: "git-aware-compaction",
  version: "1.0.0",
  onCompaction: async (context, signal) => {
    const messages = context.messages;
    const gitMessages = messages.filter(m => 
      m.content.some(c => c.type === "text" && c.text.includes("git "))
    );
    const otherMessages = messages.filter(m => !gitMessages.includes(m));
    
    if (otherMessages.length > 10) {
      const { summary } = await generateSummary(otherMessages, /* ... */);
      return {
        summary: `[Git-Aware Compaction]\n${summary}\n\n[Git Operations Preserved]\n${gitMessages.map(m => m.content).join("\n")}`,
        keptMessages: [...gitMessages, ...otherMessages.slice(-5)],
        details: { preservedGitOps: gitMessages.length },
      };
    }
  },
};

CompactionEntry Complete Fields

export interface CompactionEntry<T = unknown> extends SessionEntryBase {
  type: "compaction";
  summary: string;              // Summary Text
  firstKeptEntryId: string;     // Kept Segment Start Entry ID
  tokensBefore: number;         // Tokens Before Compression
  details?: T;                  // Extension-specific Data (Structured Compaction)
  usage?: Usage;                // LLM Usage for Summary Generation
  fromHook?: boolean;           // true = Extension Generated, false = Built-in
}

buildContextEntries Handles Compaction

// packages/coding-agent/src/core/session-manager.ts
export function buildContextEntries(
  entries: SessionEntry[],
  leafId?: string | null,
  byId?: Map<string, SessionEntry>
): SessionEntry[] {
  const path = buildSessionPath(entries, leafId, byId);
  let compaction: CompactionEntry | null = null;

  // Find Latest Compaction
  for (const entry of path) {
    if (entry.type === "compaction") compaction = entry;
  }

  if (!compaction) return path;

  const compactionIdx = path.findIndex(e => e.id === compaction.id);
  if (compactionIdx < 0) return path;

  // Reconstruct: [Compaction] + [After FirstKept] + [After Compaction]
  const contextEntries: SessionEntry[] = [compaction];
  let foundFirstKept = false;
  for (let i = 0; i < compactionIdx; i++) {
    if (path[i].id === compaction.firstKeptEntryId) foundFirstKept = true;
    if (foundFirstKept) contextEntries.push(path[i]);
  }
  contextEntries.push(...path.slice(compactionIdx + 1));
  return contextEntries;
}

Compaction Settings Configuration

export interface CompactionSettings {
  threshold: number;           // Token Ratio Threshold (0-1), Default 0.75
  minTurnsToKeep: number;      // Minimum Turns to Keep, Default 2
  maxSummaryTokens: number;    // Max Summary Tokens, Default 4096
  summaryModel?: ModelConfig;  // Summary Model, undefined = Current Model
  // Advanced
  preserveSystemPrompt: boolean;  // Default true
  preserveTools: boolean;         // Default true
  preserveRecentFiles: number;    // Preserve Recent N File Operations, Default 5
}

Default Values

export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
  threshold: 0.75,
  minTurnsToKeep: 2,
  maxSummaryTokens: 4096,
  summaryModel: undefined,
  preserveSystemPrompt: true,
  preserveTools: true,
  preserveRecentFiles: 5,
};

References


Next Up

Part 13: Agent Harness, Skills, System Prompt Assembly

AgentHarness Class, System Prompt Assembly Flow, Skills Loading & Formatting, Prompt Templates, How Harness Decides Tool Availability, Result Handling, Telemetry Schema Registration, Default Harness Construction.