Prevent context window overflow by compacting conversation history when estimated token count exceeds the safe threshold.
Backpressure & Flow Control
Managing context window limits and adaptive compaction triggers
As streaming conversations grow, accumulated tokens can approach the model's context window ceiling. This module detects impending overflow, triggers adaptive compaction to summarize older messages, truncates oversized tool results, and prunes stale images — all while maintaining conversation coherence.
📚 What is this & why does it matter?
Every LLM has a finite context window. When a long-running agent session accumulates enough messages and tool results, it risks exceeding that limit and crashing mid-response. Backpressure and flow control mechanisms detect when token counts approach the ceiling and proactively compact the conversation — summarizing old messages, truncating tool results, and pruning images. Without this, multi-turn agent sessions would fail unpredictably after a few dozen interactions.
💡 Real-world analogy
Think of a court reporter who must keep a running transcript for the judge. The transcript binder has a maximum number of pages. When it starts getting full, the reporter summarizes earlier testimony into condensed notes, archives the detailed pages, and continues writing. If a witness submits a 500-page evidence document, the reporter makes an abstract instead of pasting the whole thing. Old exhibit photos are removed to save space. The judge can still follow the trial because the summaries preserve the essential narrative.
Contents
Overview
Current message history, token estimates, model context window size, tool result payloads, and image attachments.
When to trigger compaction; how aggressively to split messages (BASE_CHUNK_RATIO vs MIN_CHUNK_RATIO); whether to truncate tool results or prune images first.
Compacted message array with summarized older turns; truncated tool results; pruned image references; updated token estimates.
flowchart TD
CHECK["estimateTokens()"]
DECIDE{"tokens > context_window - reserve?"}
NORMAL["Continue normally"]
SPLIT["splitMessagesByTokenShare()"]
SUMMARIZE["summarizeInStages()"]
MERGE["Merge summaries + recent messages"]
VALIDATE{"compacted tokens < limit?"}
TRUNC["tool-result-truncation.ts"]
PRUNE["history-image-prune.ts"]
GUARD["tool-result-context-guard.ts"]
DONE["Compacted history ready"]
RETRY["Retry with reduced budget"]
CHECK --> DECIDE
DECIDE -->|No| NORMAL
DECIDE -->|Yes| TRUNC
TRUNC --> PRUNE
PRUNE --> GUARD
GUARD --> SPLIT
SPLIT --> SUMMARIZE
SUMMARIZE --> MERGE
MERGE --> VALIDATE
VALIDATE -->|Yes| DONE
VALIDATE -->|No| RETRY
RETRY --> SPLIT
The overview flowchart shows the full compaction pipeline. Token estimation runs before every LLM call. When the estimate exceeds the safe threshold (context window minus a reserve buffer), the system first tries lightweight mitigations (truncating tool results, pruning old images). If those are insufficient, full compaction kicks in: split the history, summarize each chunk via LLM, merge, and validate. If the merged result still exceeds the limit, the system retries with a more aggressive split ratio.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Compaction trigger | estimateTokens() > context_window - reserve | The reserve must account for the next LLM response plus system prompt; too small a reserve triggers compaction too late |
| Split ratio selection | BASE_CHUNK_RATIO = 0.4, falling to MIN_CHUNK_RATIO = 0.15 on retry | The ratio determines what fraction of history is kept verbatim vs summarized; aggressive ratios lose more detail |
| Safety margin multiplier | SAFETY_MARGIN = 1.2 | Multiplied against the token estimate to add headroom; prevents borderline cases from still overflowing |
| Tool result truncation | Per-result byte limit in tool-result-truncation.ts | Large tool outputs (file reads, search results) are the most common cause of unexpected context blow-up |
| Image pruning | Age threshold and count limit in history-image-prune.ts | Images consume disproportionate tokens; pruning old ones is the cheapest way to free context space |
| Compaction timeout | compaction-safety-timeout.ts timer | Prevents compaction from running indefinitely if the summarize LLM call hangs |
| Idle connection handling | llm-idle-timeout.ts | Terminates stale LLM connections that are consuming resources but producing no chunks |
How It Works
Backpressure management operates at three layers: pre-compaction mitigations, adaptive compaction, and session truncation as a last resort. Each layer activates only when the previous layer fails to bring token counts below the safe threshold.
Layer 1 — Pre-Compaction Mitigations
Before triggering full compaction, the system tries two lightweight measures that preserve full conversation detail:
Tool result truncation (tool-result-truncation.ts) scans all tool results in the history and truncates any that exceed a configurable byte limit. It preserves the first and last N characters with an [...truncated...] marker in between, ensuring the LLM still sees the beginning and end of large outputs.
Image pruning (history-image-prune.ts) removes image attachments from messages older than a configurable number of turns. Images are expensive in tokens (a single screenshot can cost 1000+ tokens) and become less relevant as the conversation progresses.
tool-result-context-guard.ts module wraps new incoming tool results before they enter the history, preventing a single oversized tool output from immediately triggering compaction. It enforces the truncation policy proactively, not just reactively.
Layer 2 — Adaptive Compaction
When pre-compaction mitigations are insufficient, the full compaction algorithm runs. It is implemented primarily in compact.ts and orchestrated by compaction-runtime-context.ts.
// compact.ts — core compaction algorithm
const BASE_CHUNK_RATIO = 0.4;
const MIN_CHUNK_RATIO = 0.15;
const SAFETY_MARGIN = 1.2;
async function compactHistory(
messages: Message[],
contextWindow: number,
reserve: number,
attempt: number = 0,
): Promise<Message[]> {
const targetTokens = contextWindow - reserve;
const ratio = Math.max(
MIN_CHUNK_RATIO,
BASE_CHUNK_RATIO - attempt * 0.1,
);
// Split: keep recent messages verbatim, compact older ones
const { toCompact, toKeep } = splitMessagesByTokenShare(
messages,
ratio,
targetTokens,
);
// Summarize older messages in stages
const summaries = await summarizeInStages(toCompact, targetTokens * ratio);
// Merge: summary + recent verbatim messages
const compacted = [...summaries, ...toKeep];
// Validate: did we actually reduce enough?
const estimate = estimateTokens(compacted) * SAFETY_MARGIN;
if (estimate > targetTokens && attempt < 3) {
return compactHistory(messages, contextWindow, reserve, attempt + 1);
}
return compacted;
}
SAFETY_MARGIN of 1.2 means the system targets 83% of the actual limit. This is intentional — token estimation is approximate, and the margin protects against underestimates. However, it also means compaction triggers ~20% earlier than strictly necessary, trading context for reliability.
Summarization Strategy
The summarizeInStages() function breaks the to-compact portion into smaller chunks (each fitting within the LLM's context window) and summarizes them sequentially. Each stage's output becomes input for the next, progressively distilling the conversation. The function respects message boundaries — it never splits a single message across summary stages.
Layer 3 — Session Truncation
If compaction fails (e.g., the summary itself is too long, or the timeout fires), session-truncation.ts performs hard truncation: it drops the oldest messages entirely until the history fits. This is the last resort — it loses information but guarantees the session can continue.
Behavior Diagram
stateDiagram-v2 [*] --> Normal Normal --> CheckingTokens : before LLM call CheckingTokens --> Normal : under limit CheckingTokens --> PreCompaction : over limit PreCompaction --> TruncatingToolResults : truncate large results TruncatingToolResults --> PruningImages : prune old images PruningImages --> Recheck : re-estimate tokens Recheck --> Normal : under limit Recheck --> Compacting : still over limit Compacting --> Splitting : splitMessagesByTokenShare() Splitting --> Summarizing : summarizeInStages() Summarizing --> Merging : combine summaries + recent Merging --> Validating : estimateTokens() * SAFETY_MARGIN Validating --> Normal : under limit Validating --> RetryCompaction : still over, attempt < 3 RetryCompaction --> Splitting : reduced ratio Validating --> HardTruncation : attempt >= 3 HardTruncation --> Normal : oldest messages dropped Summarizing --> TimedOut : compaction-safety-timeout TimedOut --> HardTruncation : fall back to truncation
The state diagram shows the complete lifecycle of a single compaction cycle. Most sessions never leave the Normal → CheckingTokens → Normal loop. Long-running sessions may cycle through PreCompaction several times before needing full compaction. The TimedOut transition guards against summarization hangs, ensuring the session never blocks indefinitely.
Annotated Code Walkthrough
Walk through the key functions in execution order during a compaction cycle.
The compaction check runs before every LLM call. compaction-runtime-context.ts assembles the runtime context needed for the decision:
// compaction-runtime-context.ts
interface CompactionRuntimeContext {
messages: Message[];
contextWindow: number;
reserveTokens: number;
estimatedTokens: number;
toolResultSizes: Map<string, number>;
imageCount: number;
}
function buildCompactionContext(
session: Session,
model: ModelConfig,
): CompactionRuntimeContext {
const messages = session.getHistory();
const estimated = estimateTokens(messages);
return {
messages,
contextWindow: model.contextWindow,
reserveTokens: model.maxOutputTokens + 512, // +512 for system prompt
estimatedTokens: estimated,
toolResultSizes: scanToolResultSizes(messages),
imageCount: countImages(messages),
};
}
The splitMessagesByTokenShare() function divides the history at a token boundary. It scans from the oldest message forward, accumulating token estimates until the target threshold is reached:
// compact.ts — splitting logic
function splitMessagesByTokenShare(
messages: Message[],
ratio: number,
targetTokens: number,
): { toCompact: Message[]; toKeep: Message[] } {
const compactBudget = Math.floor(targetTokens * ratio);
let accumulated = 0;
let splitIndex = 0;
for (let i = 0; i < messages.length; i++) {
const msgTokens = estimateTokens([messages[i]]);
if (accumulated + msgTokens > compactBudget) {
splitIndex = i;
break;
}
accumulated += msgTokens;
splitIndex = i + 1;
}
// Always keep system message in both halves
const systemMsg = messages.find(m => m.role === "system");
return {
toCompact: messages.slice(0, splitIndex),
toKeep: systemMsg
? [systemMsg, ...messages.slice(splitIndex).filter(m => m.role !== "system")]
: messages.slice(splitIndex),
};
}
SAFETY_MARGIN exists.
The safety timeout wraps the entire compaction pipeline. If summarization takes too long, it aborts and falls through to hard truncation:
// compaction-safety-timeout.ts
const COMPACTION_TIMEOUT_MS = 30_000;
async function withCompactionTimeout<T>(
fn: () => Promise<T>,
fallback: () => T,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
COMPACTION_TIMEOUT_MS,
);
try {
const result = await fn();
clearTimeout(timer);
return result;
} catch (err) {
clearTimeout(timer);
if (controller.signal.aborted) {
console.warn("Compaction timed out, falling back to truncation");
return fallback();
}
throw err;
}
}
Edge Cases & Error Handling
| Scenario | Behavior | Code Location |
|---|---|---|
| Context window smaller than system prompt | Immediately raises a configuration error — compaction cannot help if the system prompt alone exceeds the limit | compaction-runtime-context.ts |
| All messages are system messages | System messages are never compacted; if they alone exceed the limit, the error propagates to the caller | compact.ts:splitMessagesByTokenShare() |
| Summarization produces longer output than input | Detected by the validation step; triggers a retry with a more aggressive ratio (ratio - 0.1) | compact.ts:compactHistory() |
| Tool result is a binary blob | tool-result-truncation.ts detects non-UTF-8 content and replaces it with a placeholder noting the content type and size | tool-result-truncation.ts |
| Image pruning removes a referenced image | The message text is updated to note [image removed to save context space] so the LLM knows an image was present | history-image-prune.ts |
| Concurrent compaction requests | A mutex in compaction-runtime-context.ts serializes compaction; concurrent requests queue behind the first | compaction-runtime-context.ts |
| LLM idle during compaction summarization | llm-idle-timeout.ts kills the connection after 60s of no chunks, triggering the compaction timeout fallback | llm-idle-timeout.ts |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
BASE_CHUNK_RATIO | 0.4 | Fraction of history (by token count) to compact on the first attempt |
MIN_CHUNK_RATIO | 0.15 | Minimum ratio on retries — below this, hard truncation is triggered instead |
SAFETY_MARGIN | 1.2 | Multiplier on token estimates to add headroom against estimation inaccuracy |
COMPACTION_TIMEOUT_MS | 30000 | Maximum time allowed for the entire compaction pipeline before falling back to truncation |
TOOL_RESULT_MAX_BYTES | 8192 | Maximum byte size for a single tool result before truncation applies |
IMAGE_PRUNE_AGE_TURNS | 10 | Images older than this many turns are candidates for pruning |
IMAGE_PRUNE_MAX_COUNT | 3 | Maximum number of images to retain in history at any time |
LLM_IDLE_TIMEOUT_MS | 60000 | Maximum idle time on an LLM connection before it is terminated |
MAX_COMPACTION_RETRIES | 3 | Maximum number of compaction attempts with progressively reduced ratios |
SESSION_TRUNCATION_KEEP_RECENT | 5 | Number of most recent user messages to always preserve during hard truncation |
Related Topics
See also:
- Streaming overview — the parent L2 page covering the full streaming pipeline
- Chunk Assembly — how raw SSE events are normalized into unified payloads
- Error Recovery — how errors during streaming are categorized and recovered from
Directory Map
Source files for this topic
src/agents/pi-embedded-runner/
├── compaction/
│ ├── compact.ts ← Core compaction algorithm with splitMessagesByTokenShare()
│ ├── compaction-runtime-context.ts ← Runtime context builder and concurrency mutex
│ ├── compaction-safety-timeout.ts ← Timeout guard for compaction pipeline
│ └── session-truncation.ts ← Last-resort hard truncation
├── context-guard/
│ ├── tool-result-truncation.ts ← Truncates oversized tool results
│ └── tool-result-context-guard.ts ← Proactive guard on incoming tool results
├── history/
│ └── history-image-prune.ts ← Prunes old images from conversation history
└── streaming/
└── llm-idle-timeout.ts ← Idle LLM connection timeout handler