# Context offload

> Symbolic short-term memory: tool-pair offload, L1/L1.5/L2 Mermaid canvas, L3 injection thresholds, node_id drill-down, and independent enable defaults.

- Repository: TencentCloud/TencentDB-Agent-Memory
- GitHub: https://github.com/TencentCloud/TencentDB-Agent-Memory
- Human docs: https://grok-wiki.com/public/docs/tencentcloud-tencentdb-agent-memory-14fefdd76c97
- Complete Markdown: https://grok-wiki.com/public/docs/tencentcloud-tencentdb-agent-memory-14fefdd76c97/llms-full.txt

## Source Files

- `src/offload/index.ts`
- `src/offload/types.ts`
- `src/offload/hooks/llm-input-l3.ts`
- `src/offload/pipelines/l2-mermaid.ts`
- `src/offload/storage.ts`
- `src/offload/hooks/after-tool-call.ts`
- `src/config.ts`

---

---
title: "Context offload"
description: "Symbolic short-term memory: tool-pair offload, L1/L1.5/L2 Mermaid canvas, L3 injection thresholds, node_id drill-down, and independent enable defaults."
---

Context offload is the plugin’s **symbolic short-term memory** path: when `offload.enabled` is true, `registerOffload(api, cfg.offload)` registers OpenClaw hooks plus an `OffloadContextEngine` (`plugins.slots.contextEngine = "memory-tencentdb"`) that buffers tool pairs, summarizes them into JSONL, judges task boundaries, builds Mermaid canvases (`.mmd`), and compresses live context at fixed token-ratio thresholds. It is **orthogonal** to long-term L0–L3 memory (capture / extraction / persona / recall): offload defaults off and writes under `~/.openclaw/context-offload`, not `~/.openclaw/memory-tdai`.

<Note>
Offload layer names (L1 tool summary, L1.5 task judge, L2 Mermaid, L3 compression) are **not** the same as long-term memory L0 conversation / L1 atom / L2 scene / L3 persona. Same numeric labels, different pipelines and storage.
</Note>

## Independent enable defaults

| Surface | Default | Effect when off |
| --- | --- | --- |
| `offload.enabled` | `false` | `registerOffload` is not called; no offload hooks / context engine from this path |
| `capture.enabled` / `extraction.enabled` / `recall.enabled` | `true` | Unrelated; long-term memory keeps running |
| `plugins.slots.contextEngine` | unset | Even if `offload.enabled=true`, slot must be `"memory-tencentdb"` or all offload hooks become no-ops |

```jsonc
{
  "plugins": {
    "slots": {
      "contextEngine": "memory-tencentdb"
    },
    "entries": {
      "memory-tencentdb": {
        "enabled": true,
        "config": {
          "offload": {
            "enabled": true
          }
        }
      }
    }
  }
}
```

After enable:

1. Restart the OpenClaw gateway so `registerOffload` runs.
2. Apply the runtime patch once per OpenClaw install: `bash scripts/openclaw-after-tool-call-messages.patch.sh` (re-apply after OpenClaw upgrades). Without it, `after_tool_call` often lacks `event.messages` and in-loop L3 is skipped (`patch_not_effective`).
3. Confirm logs show `[context-offload]` registration and writes under `~/.openclaw/context-offload/`.

Full enable procedure: [Enable context offload](/enable-offload).

## Architecture

```mermaid
flowchart TB
  subgraph Host["OpenClaw host"]
    ATC["after_tool_call / before_tool_call"]
    BPB["before_prompt_build"]
    CE["OffloadContextEngine.assemble"]
    LI["llm_input token cache"]
  end

  subgraph Offload["src/offload"]
    BUF["pending ToolPair buffer"]
    L1["L1 summarize → OffloadEntry + refs/*.md"]
    L15["L1.5 judge → long/short boundary + active MMD"]
    L2["L2 generate → mmds/*.mmd + node_id backfill"]
    L3["L3 mild / aggressive / emergency"]
  end

  subgraph Disk["~/.openclaw/context-offload/{agent}/"]
    JSONL["offload-{sessionId}.jsonl"]
    REFS["refs/"]
    MMDS["mmds/"]
    ST["state.json"]
  end

  ATC --> BUF
  BUF --> L1
  L1 --> JSONL
  L1 --> REFS
  BPB --> L15
  CE --> L15
  L15 --> MMDS
  L15 --> ST
  L1 --> L2
  L2 --> MMDS
  L2 --> JSONL
  BPB --> L3
  ATC --> L3
  CE --> L3
  JSONL --> L3
  MMDS --> L3
  LI --> ST
```

| Stage | Trigger | Output |
| --- | --- | --- |
| Tool pair capture | `before_tool_call` caches params; `after_tool_call` buffers pairs | In-memory `ToolPair` queue |
| L1 | Pending count ≥ `forceTriggerThreshold` (default 4), or pre-flush before L1.5 / `before_prompt_build` | `OffloadEntry` rows + `refs/*.md` full results |
| L1.5 | User turn via Context Engine `assemble` or `before_prompt_build` | `long`/`short` boundary, active MMD file, `l15Settled` |
| L2 | Null `node_id` count ≥ `l2NullThreshold` (4) **or** timeout (`l2TimeoutSeconds` 300) after L1.5 settled | Patch/write `.mmd`, backfill `node_id` (`NNN-N#`) |
| L3 | Tokens ≥ ratio × context window (mild 0.5 / aggressive 0.85 / emergency 0.95) | Replace tool bodies with summaries; delete oldest prefix; inject Mermaid |

## Data layout

Default root: `DEFAULT_DATA_ROOT` = `~/.openclaw/context-offload` (override with `offload.dataDir`).

```text
~/.openclaw/context-offload/
  {agentName}/
    state.json
    sessions-registry.json
    offload-{sessionId}.jsonl
    refs/{iso-timestamp}.md
    mmds/{NNN}-{label}.mmd
    skills/{skillName}/SKILL.md   # L4 /create-skill
```

| Path field | Isolation |
| --- | --- |
| `dataDir` | Per agent (`sessionKey` form `agent:<name>:<sessionId>`) |
| `offload-*.jsonl` | Per session; L2 reads **all** `offload-*.jsonl` under the agent |
| `mmds/`, `refs/`, `state.json` | Shared across sessions of the same agent |
| Worker sessions | `swebench-w{N}` suffix merges into agent name so workers get separate dirs |

### OffloadEntry schema

| Field | Type | Role |
| --- | --- | --- |
| `tool_call_id` | string | Provider tool call id (required for JSONL validation) |
| `timestamp` | string | ISO time from the tool result |
| `tool_call` | string | Compact call description |
| `summary` | string | L1 LLM summary (or `[L1 degraded]…` fallback) |
| `result_ref` | string | Relative path under agent dir, e.g. `refs/….md` |
| `node_id` | `string \| null` | `null` until L2; `"wait"` while L2 runs; then `NNN-N#` |
| `score` | number 0–10 optional | Replaceability; higher → milder L3 prefers replacement first |
| `offloaded` | boolean \| `"deleted"` optional | Mild confirmed / aggressive-emergency deleted marker |

## Tool-pair offload (capture → L1)

1. **`before_tool_call`** — cache `event.params` by `toolCallId`.
2. **`after_tool_call`** — build `ToolPair` (`toolName`, `toolCallId`, `params`, `result`, `timestamp`, …); skip heartbeats (`HEARTBEAT.md`), approval-pending results, and already-processed ids.
3. **Force L1** when pending ≥ `forceTriggerThreshold` (default **4**).
4. **L1 flush** (`flushL1`):
   - Write full result to `refs/` (L1.1).
   - Batch ≤ 5 pairs per LLM call (backend limit); up to 3 retries then **degraded** local entries (`node_id: null`, truncated text summary).
   - Append sanitized JSONL via `appendOffloadEntries` (dedup by `tool_call_id`).
5. **Notify L2** when new null-`node_id` rows exist.

Internal memory-pipeline sessions matching `memory-.*-session-\d+` are ignored end-to-end.

## L1.5 task boundary and Mermaid canvas

L1.5 is **not** driven from L1 completion. It runs on the user-turn path (`assemble` / `before_prompt_build`):

1. Flush pending L1 pairs that belong to the **previous** turn.
2. Snapshot `startIndex = entryCounter` (boundary for this turn’s offload rows).
3. Call `l15Judge` with recent history, current MMD, and up to 10 recent MMD metas.
4. Normalize judgment: `taskCompleted`, `isContinuation`, `continuationMmdFile`, `newTaskLabel`, `isLongTask`.
5. `handleTaskTransition` creates/reactivates/clears active MMD (`NNN-{label}.mmd` with seed `flowchart TD` node).
6. Push boundary `{ startIndex, result: "long"|"short", targetMmd }`.
7. On success: `l15Settled = true`, MMD injection ready. On failure after one 3s retry: **fail-safe** short boundary, `activeMmd=null` (L2 will not attribute entries to a canvas).
8. Task switch can fire a forced L2 **flush** of residual null/`wait` rows for the **old** MMD so they are not orphaned.

### Active MMD injection

When L1.5 is settled and an active MMD exists, injectors insert a marked user message (`_mmdContextMessage: "active"`) with:

- Task goal metadata from `%%{ … }%%` header when present
- Fenced ```mermaid``` body
- Guidance that `doing` nodes are recent focus and `done` nodes are completed

Active injection runs from `injectMmdIntoMessages` (assemble / `before_prompt_build` / `llm_input` path) and incremental update on `after_tool_call` when L2 patches the file mid-loop. History MMDs are **not** injected here; they appear only after aggressive L3 deletes messages (`buildHistoryMmdInjection`).

## L2 independent Mermaid generation

L2 no longer chains directly off L1. Scheduler + `checkL2Trigger`:

| Condition | Default | Behavior |
| --- | --- | --- |
| A — null count | `l2NullThreshold = 4` | Eligible `node_id === null` under a **long** boundary ≥ threshold |
| B — timeout | `l2TimeoutSeconds = 300` | Age since last L2 (or oldest null if never ran) |
| Gate | `l15Settled` | Poll waits; force-settle after 60s if L1.5 never settles |
| Wait retry | `l2WaitRetrySeconds = 120` | `node_id === "wait"` re-eligible after wait |
| Time needs new rows | `l2TimeTriggerRequiresNewOffload = true` | Timeout path requires a null row newer than last L2 |

Eligible rows must have boundary coverage with `result === "long"` and a `targetMmd`. Heartbeat tool calls are skipped.

`runL2WithBackend` (local LLM client or remote backend):

1. Mark batch entries `node_id = "wait"`.
2. Call `l2Generate` (batches ≤ 30 entries).
3. Apply `fileAction` / `replaceBlocks` via `patchMmd`, or full `writeMmd`.
4. `backfillNodeIds` from `nodeMapping`; fallback to most-frequent mapped id or max `NNN-N#` in MMD text.
5. Failed batches leave `"wait"` for later retry.

Node id pattern: `\d{3}-N\d+` (e.g. `003-N12`).

## L3 compression thresholds

Context window resolution order: model `contextWindow` in `models.providers` → `offload.defaultContextWindow` (200000) → `PLUGIN_DEFAULTS.defaultContextWindow`.

| Phase | Threshold (default) | Action |
| --- | --- | --- |
| Below mild | tokens &lt; `floor(window × mildOffloadRatio)` (0.5) | No compression |
| Mild | ≥ 0.5 | Score-cascade replace tool results/tool_use with L1 summaries (`score` high first); scan first `mildOffloadScanRatio` (0.7) of messages; mark `offloaded: true` |
| Aggressive | ≥ 0.85 | One-shot head delete until under threshold; protect last real user message; mark `offloaded: "deleted"`; inject history MMDs for deleted `node_id` prefixes (budget `mmdMaxTokenRatio` 0.2) |
| Emergency | ≥ 0.95 (or force after stalled aggressive) | Delete/truncate until ≤ `emergencyTargetRatio` 0.6; tool-pair-aware tail delete; in-place truncate oversized messages |

Additional runtime defaults (internal `PLUGIN_DEFAULTS`, not all exposed in `openclaw.plugin.json`):

| Key | Default |
| --- | --- |
| `aggressiveDeleteRatio` | 0.4 |
| `emergencyCompressRatio` | 0.95 |
| `emergencyTargetRatio` | 0.6 |
| `mildOffloadScanRatio` | 0.7 |
| `l3TokenCountMode` | `tiktoken` |
| `l3TiktokenEncoding` | `cl100k_base` |

L3 entry points:

| Path | Role |
| --- | --- |
| `after_tool_call` | In-loop compression when `event.messages` is present (needs patch) |
| `before_prompt_build` | Full path for Responses API / gateway HTTP (assemble may not run) |
| Context Engine `assemble` | CLI / pi-embedded path; sets per-turn flag to avoid double work |
| `mode: "collect"` | L1/L1.5/L2 still run; **L3 and context engine registration are disabled** |

### Mild replacement surface (drill-down hooks)

Replaced tool results look like:

```text
[Offloaded Tool Result | node: 003-N12]
Summary: …
result_ref: refs/….md (read this file for full tool call and raw result)
```

Tool_use blocks become compact `{ _offloaded: true, node_id, tool_call }`. The agent can read `result_ref` files on disk for full recovery.

## node_id drill-down path

```text
Mermaid canvas node  003-N12
        │
        ▼
offload-*.jsonl  rows with node_id = "003-N12"
  (tool_call, summary, score, tool_call_id)
        │
        ▼
result_ref → refs/{timestamp}.md
  (full sanitized tool result)
```

| Layer | Location | Use |
| --- | --- | --- |
| Symbol | `mmds/{NNN}-{label}.mmd` nodes `NNN-N#` | Task orientation in context |
| Index | JSONL `node_id` + `summary` | Mild L3 replacement text; L4 skill generation filters by node set in MMD |
| Evidence | `result_ref` MD | Lossless tool I/O recovery |

After aggressive deletion, `buildHistoryMmdInjection` re-injects non-active MMDs whose prefix matches deleted entries’ `node_id` prefixes, so lost turns keep a symbolic canvas.

## Modes and LLM routing

| `offload.mode` | Selection | L1/L1.5/L2 | L3 | Context engine |
| --- | --- | --- | --- | --- |
| `local` (default if no `backendUrl`) | `LocalLlmClient` via `offload.model` or `agents.defaults.model` + `models.providers` (apiKey / auth-profile) | Yes | Yes | Registered when slot matches |
| `backend` | `BackendClient` requires `backendUrl` | Via remote service | Yes | Registered when slot matches |
| `collect` | Backend or local for async pipeline | Yes (async) | **No** | **Not** registered (legacy compaction) |

If the LLM client cannot be constructed, logs warn that L1/L1.5/L2/L4 are disabled while L3 compression may still run when summaries already exist.

BYOK/BYOC: local mode uses the host’s configured OpenAI-compatible providers only; no hard-coded vendor is required. Backend mode is optional remote offload for summarization/judge/canvas generation.

## Configuration keys

Parsed under plugin config `offload` (`parseConfig`):

| Key | Default | Notes |
| --- | --- | --- |
| `enabled` | `false` | Master switch |
| `mode` | `local` (or `backend` if `backendUrl` set) | `local` \| `backend` \| `collect` |
| `model` | unset → main agent model | `provider/model-id` |
| `temperature` | `0.2` | Local offload LLM |
| `disableThinking` | `false` | Local mode only |
| `forceTriggerThreshold` | `4` | Pending pairs → L1 |
| `dataDir` | `~/.openclaw/context-offload` | Absolute path |
| `defaultContextWindow` | `200000` | Fallback window |
| `maxPairsPerBatch` | `20` | Config field; L1 HTTP batch size is also capped at 5 in code |
| `l2NullThreshold` | `4` | Null rows → L2 |
| `l2TimeoutSeconds` | `300` | Time → L2 |
| `mildOffloadRatio` | `0.5` | L3 mild |
| `aggressiveCompressRatio` | `0.85` | L3 aggressive |
| `mmdMaxTokenRatio` | `0.2` | MMD token budget |
| `backendUrl` / `backendApiKey` | unset | Backend / collect |
| `backendTimeoutMs` | `120000` (runtime parse) | Plugin schema default may show 10000; runtime uses parseConfig |
| `offloadRetentionDays` | `0` | Reclaim only if ≥ 3; values in (0,3) forced to 0 |
| `logMaxSizeMb` | `50` | Debug log reclaim |
| `userId` | machine primary IPv4 | `X-User-Id` for backend `/store` |

Full schema: [Configuration reference](/configuration-reference).

## Hooks and context engine contract

| Hook / API | Offload role |
| --- | --- |
| `api.registerContextEngine("memory-tencentdb", …)` | Required for normal mode; singleton `OffloadContextEngine` |
| `before_tool_call` | Param cache |
| `after_tool_call` | Buffer pairs, MMD update, optional L3 |
| `llm_input` | Token/system overhead cache for L1/L2 context (not primary L1.5) |
| `before_prompt_build` | L1 flush, L1.5, L3 + MMD when assemble is not used |
| `before_agent_start` | L4 `/create-skill` command detection |
| `llm_output` | Pending-count diagnostics |

If `plugins.slots.contextEngine` ≠ `"memory-tencentdb"`, or registration returns `ok: false`, `_contextEngineRejected` is set and every `api.on` handler returns immediately.

## Verification signals

| Signal | Expected |
| --- | --- |
| Gateway logs | `[context-offload] Registering…`, `Context engine registered`, L1/L2/L3 debug lines |
| Data dir | `~/.openclaw/context-offload/<agent>/offload-*.jsonl` growing after tools |
| Refs | `refs/*.md` after successful L1 |
| MMD | `mmds/*.mmd` after long-task L1.5 + L2 |
| node_id | Transitions `null` → `wait` → `NNN-N#` |
| Mild L3 | Tool results contain `[Offloaded Tool Result \| node: …]` |
| Patch health | Absence of `after_tool_call patch check: NOT EFFECTIVE` |
| Slot misconfig | `Context engine slot not assigned… ALL offload functions disabled` |

## Failure modes

| Symptom | Cause | Recovery |
| --- | --- | --- |
| No offload activity | `offload.enabled` false | Set true, restart |
| Hooks silent / no L1.5 settle | Wrong or missing `contextEngine` slot | Set to `memory-tencentdb` |
| No in-loop L3 | Missing after-tool-call patch | Run `scripts/openclaw-after-tool-call-messages.patch.sh` |
| L1/L2 idle, L3 only | No model / backend credentials | Fix `offload.model` or `backendUrl` + providers |
| L2 never runs | L1.5 short / fail-safe; only short boundaries | Long tasks only get MMD; check L1.5 logs |
| Entries stuck on `wait` | L2 batch failure | Wait ≥ `l2WaitRetrySeconds` or fix LLM backend |
| Aggressive “stalled” | Last user message blocks head delete | Emergency force path |
| Slot conflict | Another context engine owns the slot | Free slot or disable other engine |

## Related pages

<CardGroup>
  <Card title="Enable context offload" href="/enable-offload">
    Turn on offload.enabled, set contextEngine slot, apply after-tool-call patch, verify Mermaid and data.
  </Card>
  <Card title="Memory layers" href="/memory-layers">
    Long-term L0–L3 (conversation, atom, scene, persona) — separate from offload L1–L3.
  </Card>
  <Card title="Data directories" href="/data-directories">
    On-disk trees for memory-tdai vs context-offload.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Full offload.* schema, defaults, and degrade rules.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Offload slot/patch missing and related recovery probes.
  </Card>
</CardGroup>
