Skip to content

Context Engine: context compaction abstraction

源码版本v2026.6.11

Responsibilities

Context Engine is the abstraction layer for the agent context lifecycle: who ingests messages, who assembles the prompt, who compacts, who maintains the transcript — these decisions are pulled out of the agent main loop and turned into a few methods on the ContextEngine interface.

OpenClaw ships a LegacyContextEngine as the default implementation. It delegates all work back to the existing paths (SessionManager persistence, attempt.ts assembly, compactEmbeddedAgentSessionDirect compaction), keeping 100% backward compatibility. Third-party plugins can register their own engine via api.registerContextEngine("my-engine", factory), hooking in summary strategies, retrieval augmentation, and vector indexing.

Design motivation

Why abstract context into an engine? Because the longer an agent runs, the longer the transcript, the tighter the token budget. OpenClaw's native compaction strategy is "segmented summarization + retain important messages + rotate session files", hard-coded in compactEmbeddedAgentSessionDirect. But different models and scenarios need different compaction strategies: Claude long-context fits "keep recent N + remote summary"; RAG-heavy scenarios fit "build vector index while compacting"; some plugins want to sync-write memory during compact.

If the compaction strategy were hard-coded in the agent runner, a plugin wanting to replace it would have to fork the whole runner. Pulled into the ContextEngine interface, a plugin only implements ingest / assemble / compact / afterTurn — the agent main loop just calls them at the right times; specific strategy is left to the engine implementation.

But full plugin-ization has risks: an engine bug can directly lose conversational context. So the registry also implements a "quarantine" mechanism — if an engine factory throws or misbehaves, the engine id is automatically quarantined and falls back to default, letting the agent keep running.

Key files

Data flow

In the attempt loop, compaction has two entries: one is proactively checking token budget before prompt; the other is reactively compacting after the provider returns an overflow error. The reactive path (overflow compaction:L2638) goes through compactContextEngineWithSafetyTimeout:

typescript
overflowCompactionAttempts++;
log.warn(
  `context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
);
let compactResult: Awaited<ReturnType<typeof contextEngine.compact>>;
await runOwnsCompactionBeforeHook("overflow recovery");
try {
  const overflowCompactionRuntimeSettings = buildEmbeddedContextEngineRuntimeSettings(
    {
      tokenBudget: ctxInfo.tokens,
      degradedReason: "context_overflow",
    },
  );
  compactResult = await compactContextEngineWithSafetyTimeout(
    contextEngine,
    {
      sessionId: activeSessionId,
      sessionKey: params.sessionKey,
      sessionFile: activeSessionFile,
      tokenBudget: ctxInfo.tokens,
      ...(overflowTokenCountForCompaction !== undefined
        ? { currentTokenCount: overflowTokenCountForCompaction }
        : {}),
      force: true,
      compactionTarget: "budget",
      runtimeContext: overflowCompactionRuntimeContext,
      runtimeSettings: overflowCompactionRuntimeSettings,
    },
    resolveCompactionTimeoutMs(params.config),
    params.abortSignal,
  );

Four details: force: true skips the engine's threshold self-check — compact unconditionally on overflow; compactionTarget: "budget" makes the engine converge to the token budget not the threshold; the safety timeout comes from config, and a hung plugin gets interrupted; abortSignal is run-level — if the run is cancelled, compact must stop too. This set of constraints is clearly written in the compact() contract (compact contract:L402):

typescript
  /**
   * Compact context to reduce token usage.
   * May create summaries, prune old turns, etc.
   *
   * The host always bounds this call with a finite safety timeout (the same
   * one that protects native runtime compaction). Engines that run long
   * operations SHOULD additionally honor `abortSignal` so an in-flight
   * compaction can be canceled promptly on run abort or host timeout instead
   * of running to completion in the background.
   */
  compact(params: {
    sessionId: string;
    sessionKey?: string;
    sessionFile: string;
    tokenBudget?: number;
    /** Force compaction even below the default trigger threshold. */
    force?: boolean;
    /** Optional live token estimate from the caller's active context. */
    currentTokenCount?: number;
    /** Controls convergence target; defaults to budget. */
    compactionTarget?: "budget" | "threshold";
    customInstructions?: string;
    /** Optional runtime-owned context for engines that need caller state. */
    runtimeSettings?: ContextEngineRuntimeSettings;
    runtimeContext?: ContextEngineRuntimeContext;
    /**
     * Optional abort signal honored before and during compaction. The host
     * aborts it on run-level abort or when its compaction safety timeout
     * fires; engines should stop work and reject promptly when it aborts.
     */
    abortSignal?: AbortSignal;
  }): Promise<CompactResult>;

How does an engine register? A plugin calls registerContextEngine via the SDK (registerContextEngine:L542), which internally delegates to registerContextEngineForOwner:

typescript
export function registerContextEngineForOwner(
  id: string,
  factory: ContextEngineFactory,
  owner: string,
  opts?: RegisterContextEngineForOwnerOptions,
): ContextEngineRegistrationResult {
  const normalizedOwner = requireContextEngineOwner(owner);
  const registry = getContextEngineRegistryState().engines;
  const existing = registry.get(id);
  if (
    id === defaultSlotIdForKey("contextEngine") &&
    normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
  ) {
    // The default fallback id is core-owned; plugins can select other ids through slots.
    return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
  }
  if (existing && existing.owner !== normalizedOwner) {
    return { ok: false, existingOwner: existing.owner };
  }
  if (existing && opts?.allowSameOwnerRefresh !== true) {
    return { ok: false, existingOwner: existing.owner };
  }
  registry.set(id, { factory, owner: normalizedOwner });
  clearContextEngineRuntimeQuarantine(id);
  return { ok: true };
}

Three layers of protection: the default slot can only be registered by core, plugins can't preempt it; when the same id is already registered by another owner, reject; same-owner refresh defaults to off, preventing plugins from accidentally overwriting their own registered instance. clearContextEngineRuntimeQuarantine clears any prior quarantine record after a successful registration, allowing a previously-quarantined engine to be retried.

Resolve flow and quarantine fallback:

Boundaries and failures

  • default slot locked: the id "legacy" is the default engine slot; only CORE_CONTEXT_ENGINE_OWNER can register it — plugins can't preempt. This guarantees the fallback is always reachable.
  • quarantine isolation: wrapContextEngineWithRuntimeQuarantine (src/context-engine/registry.ts:L785) wraps every engine method call in a try-catch; on throw, the engine id is written to quarantinedEngines, and subsequent resolves skip it directly. Quarantine info is persisted to process-level storage; clearContextEngineRuntimeQuarantine only runs on re-registration.
  • safety timeout: compactContextEngineWithSafetyTimeout uses resolveCompactionTimeoutMs(config) to hard-cap plugin compact; on timeout it throws, gets caught, and is treated as a quarantine trigger. The same timeout also protects native runtime compaction, keeping plugin and native behavior consistent.
  • abortSignal must be honored: the contract says the engine "SHOULD" reject immediately on abort, but the host doesn't hard-depend on it — because the safety timeout is the backstop. But an engine that ignores abort wastes CPU/IO resources until the timeout.
  • LegacyContextEngine's compact is delegated: LegacyContextEngine.compact doesn't implement its own algorithm; it directly calls delegateCompactionToRuntime (src/context-engine/delegate.ts:L34), which lazy-imports compact.runtime.js and calls compactEmbeddedAgentSessionDirect. This bridge lets third-party engines also reuse the native compaction path — they only need to implement the differentiated assemble or ingest logic; compact can be delegated.
  • compactionTarget ignored in delegate path: delegateCompactionToRuntime notes explicitly that the native runtime doesn't expose this knob, so legacy engine's compact behavior isn't affected by target. Engines that need target-specific compaction must implement compact themselves.
  • ownsCompaction flag: ContextEngineInfo.ownsCompaction (src/context-engine/types.ts:L166-L167) tells the host that this engine manages its own compaction lifecycle, and the host no longer auto-triggers compact — used for fully self-contained RAG-style engines.
  • resolve failure falls back to default: engine id not in registry, factory throws, or returned engine missing methods — all go through resolveDefaultContextEngine (src/context-engine/registry.ts:L1013), ensuring the agent loop doesn't completely fail just because a plugin isn't installed properly.
  • afterTurn triggers proactive compaction: compact() isn't only called on overflow; the afterTurn hook (src/context-engine/types.ts:L353-L369) lets the engine decide whether to compact proactively after each turn. The host passes the current budget via tokenBudget, and the engine decides to compact now or wait for the next turn.
  • also called on subagent spawn: SubagentSpawnPreparation (src/context-engine/types.ts:L182-L187) lets the engine prepare isolated context before a subagent starts, avoiding parent-child agent context contamination.

Summary

Context Engine is a pluggable abstraction for the agent context lifecycle. The default LegacyContextEngine delegates all work back to the existing paths; third parties inject their own implementations via registerContextEngine. The registry uses three layers — owner validation + default slot lock + quarantine — to contain plugin engine failure radius; the host uses safety timeout + abortSignal to constrain compact duration. How compact is actually triggered by the attempt loop is in Agent main loop; how memory injected by the engine lands on disk is in Memory files.

Official references: Context Engine docs · README.