Backpressure & Flow Control

Focus Deep-Dive

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

Primary role

Prevent context window overflow by compacting conversation history when estimated token count exceeds the safe threshold.

Main inputs

Current message history, token estimates, model context window size, tool result payloads, and image attachments.

Key decisions

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.

Outputs / side effects

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

DecisionWhat controls itWhy it matters
Compaction triggerestimateTokens() > context_window - reserveThe reserve must account for the next LLM response plus system prompt; too small a reserve triggers compaction too late
Split ratio selectionBASE_CHUNK_RATIO = 0.4, falling to MIN_CHUNK_RATIO = 0.15 on retryThe ratio determines what fraction of history is kept verbatim vs summarized; aggressive ratios lose more detail
Safety margin multiplierSAFETY_MARGIN = 1.2Multiplied against the token estimate to add headroom; prevents borderline cases from still overflowing
Tool result truncationPer-result byte limit in tool-result-truncation.tsLarge tool outputs (file reads, search results) are the most common cause of unexpected context blow-up
Image pruningAge threshold and count limit in history-image-prune.tsImages consume disproportionate tokens; pruning old ones is the cheapest way to free context space
Compaction timeoutcompaction-safety-timeout.ts timerPrevents compaction from running indefinitely if the summarize LLM call hangs
Idle connection handlingllm-idle-timeout.tsTerminates 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.

Key detail: The 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;
}
Watch out: The 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.

Failure mode: Hard truncation can remove critical context (e.g., the initial user goal). To mitigate this, the system always preserves the first system message and the most recent N user messages, even during hard truncation.

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.

Design insight: The three-layer approach (pre-compaction → adaptive compaction → hard truncation) means the system degrades gracefully. Each layer is more aggressive but also more likely to succeed, so the session always makes forward progress.

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),
  };
}
Performance note: Token estimation uses a fast heuristic (words × 1.3) rather than running the actual tokenizer. This makes compaction checks near-instant but introduces ~10% estimation error, which is why 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

ScenarioBehaviorCode Location
Context window smaller than system promptImmediately raises a configuration error — compaction cannot help if the system prompt alone exceeds the limitcompaction-runtime-context.ts
All messages are system messagesSystem messages are never compacted; if they alone exceed the limit, the error propagates to the callercompact.ts:splitMessagesByTokenShare()
Summarization produces longer output than inputDetected by the validation step; triggers a retry with a more aggressive ratio (ratio - 0.1)compact.ts:compactHistory()
Tool result is a binary blobtool-result-truncation.ts detects non-UTF-8 content and replaces it with a placeholder noting the content type and sizetool-result-truncation.ts
Image pruning removes a referenced imageThe message text is updated to note [image removed to save context space] so the LLM knows an image was presenthistory-image-prune.ts
Concurrent compaction requestsA mutex in compaction-runtime-context.ts serializes compaction; concurrent requests queue behind the firstcompaction-runtime-context.ts
LLM idle during compaction summarizationllm-idle-timeout.ts kills the connection after 60s of no chunks, triggering the compaction timeout fallbackllm-idle-timeout.ts

Configuration

Knob / ParameterDefaultDescription
BASE_CHUNK_RATIO0.4Fraction of history (by token count) to compact on the first attempt
MIN_CHUNK_RATIO0.15Minimum ratio on retries — below this, hard truncation is triggered instead
SAFETY_MARGIN1.2Multiplier on token estimates to add headroom against estimation inaccuracy
COMPACTION_TIMEOUT_MS30000Maximum time allowed for the entire compaction pipeline before falling back to truncation
TOOL_RESULT_MAX_BYTES8192Maximum byte size for a single tool result before truncation applies
IMAGE_PRUNE_AGE_TURNS10Images older than this many turns are candidates for pruning
IMAGE_PRUNE_MAX_COUNT3Maximum number of images to retain in history at any time
LLM_IDLE_TIMEOUT_MS60000Maximum idle time on an LLM connection before it is terminated
MAX_COMPACTION_RETRIES3Maximum number of compaction attempts with progressively reduced ratios
SESSION_TRUNCATION_KEEP_RECENT5Number of most recent user messages to always preserve during hard truncation

See also:

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