← Conhecimento
Arquitetura Texto

Cited Claims Compaction

How typed, cited claims preserve context across long conversations—and where lexical validation still falls short.

Available in English · this article has not been translated yet.

Architecture note. Literal validation checks provenance and certain error classes; it is not a proof that a claim is semantically true. The smaller evaluations did not show an accuracy advantage over iterative prose. The larger experiment and its limits are retained below.

What it is: a context-compaction module for a long-running LLM agent. Instead of asking a model to summarise old conversation into prose, it extracts a set of typed, ranked, cited claims, checks every claim against the messages it cites with plain code, and only puts those claims into the prompt while the underlying messages are byte-identical to what it verified. Corrected values supersede earlier ones instead of silently drifting.

Source snapshot: October 29, 2026. The original write-up reports a deployed BoB component. This public edition omits private deployment identifiers and the separate runtime-port work plan. It does not establish inclusion in the public installer.

Why it is worth copying: the failure it fixes is not “the prompt is too long”. It is that a sliding window forgets, and an iterative prose summary confidently misremembers. In a 939K-token, 21-compaction test, the prose arm turned a “must NOT be enabled” flag into ALLOWED — a wrong answer stated with certainty, not an omission. Claims scored 96/96 there (pooled over both raw-tail sizes); prose 90/96, with two polarities inverted.


1. The three failures

  1. Silent amnesia. The original transport window sent the last 20 messages / 8192 characters to every model and dropped the rest with no trace — and if the single newest message exceeded 8192 characters, the loop kept nothing. Real turn bodies reached 120K, 74K, 44K characters.
  2. Positional memory. A sliding window keeps what is recent, not what matters. The goal, the constraints and the decisions live in the opening exchange, which a window is the first thing to throw away.
  3. Summary drift. An iterative prose summary is a lossy function of a lossy function. Repeated compaction compounds the loss, and a summary written before a correction keeps restating the old value.

The design below attacks all three, and treats “no model judges a model” as a hard rule.

2. Design principles

3. Architecture

                 ┌─────────────────────── one turn ───────────────────────┐
 user/platform ─►│ gateway: history (ids, roles, timestamps, capped)      │
                 └───────────────────────┬────────────────────────────────┘
                                         ▼
                            ┌────────────────────────┐
                            │ 0. FIT  (budget.py)    │  protect opening exchange
                            │ window = model's real  │  keep newest contiguous tail
                            │ window × 0.5           │  announce the gap in-prompt
                            └───────────┬────────────┘
                                        ▼
                            ┌────────────────────────┐
                            │ 3. ASSEMBLE            │  claims block replaces the
                            │ (assembler.py)         │  oldest omitted messages, inside
                            │ ≤15% of history budget │  the SAME token budget
                            └───────────┬────────────┘
                                        ▼
                                   model call
                                        │
                       turn recorded ───┘
                                        ▼
                            ┌────────────────────────┐
                            │ 2. COMPACT (background)│  select closed region
                            │ compactor.py + cheap   │  → claims-v2 prompt
                            │ long-window seat       │  → parse → VALIDATE
                            └───────────┬────────────┘         │
                                        ▼                    reject
                            ┌────────────────────────┐           │
                            │ 1. STORE (store.py)    │  ◄────────┘ (region stays raw)
                            │ sqlite: conv_messages  │
                            │         compactions    │
                            └────────────────────────┘

Layer 0 — fit to the real window (windows.py, budget.py, history.py)

This layer is the safety net. It is also the whole system’s fallback: with claims switched off, you get “head + tail + honest marker” behaviour.

Layer 1 — the index and the store (store.py)

One SQLite file per lane (CONTEXT_DB, else dirname($MEMORY_SQLITE_PATH)/bobclaw_context.db), WAL mode, separate from the agent’s memory database. Two tables:

conv_messages(message_id, conversation_id, user_id, role, content,
              content_sha256, created_at, seq, parents_json, first_seen_ts,
              PRIMARY KEY (conversation_id, user_id, message_id))

compactions(compaction_id, conversation_id, user_id,
            region_first_id, region_last_id, region_count, input_sha256,
            status, backend, prompt_version, attempts,
            claims_json, narrative_json, validation_json,
            tokens_in_est, tokens_out_est, elapsed_ms, error, created_ts)

Notes that matter in practice:

Layer 2 — the compactor (compactor.py)

Region selection is structural, not positional:

The claim schema (claims-v2):

{"claims": [{"id": "c1", "type": "decision", "importance": 3,
             "text": "...", "cites": ["<message id>"], "supersedes": null}],
 "narrative": {"goal": "...", "constraints": "...", "decisions": "...",
               "progress": "...", "open_questions": "...", "next_steps": "..."}}

Types: decision, constraint, definition, commitment, fact, correction, open_question, failure, artifact, preference. Importance: 3 = decisions, constraints, prohibitions, definitions, commitments, corrections, blockers; 2 = identifiers, paths, URLs, versions, thresholds, dates that later work must refer to; 1 = everything else, sparingly. The prompt explicitly tells the model not to write claims for routine status, counts, prices, quotes, schedules or small talk.

Later regions are shown the still-active claims of earlier regions and may supersede them by exact id. Claim ids are namespaced per region (r2.c5) so they stay unique across the conversation.

Deterministic validation (validate_claims) — a claim is accepted only if all hold:

CheckRule
Shapeknown type, non-empty text ≤600 chars, cites is a non-empty list of ids inside the region
Literalsevery number / path / URL / version / UUID / hex / ticket / backticked code / quoted string in the claim appears verbatim in the cited messages
Numbersmatched as whole tokens — 47 is not “supported” because 48213 appears somewhere
Polarityif the cited text carries a negation cue (not, never, n't, forbidden, prohibited, off limits…), a constraint/decision/commitment/preference claim must keep a negation or equivalent (forbidden, disabled, avoid…). A , not X contrast is ignored; corrections are exempt
Dedupeidentical id or identical (type, text) is rejected
Supersessionsupersedes must name an earlier accepted claim; otherwise the link is dropped with a warning

If more than half the raw claims are rejected (or none survive), the whole region is marked shadow_rejected, the reasons are fed back to the model for one retry, and if it still fails the region is left uncompacted — the raw messages stay in the prompt. There is no “accept it anyway” path.

This is deliberately lexical, not semantic. It catches the errors that actually happened (a shifted number, a transposed identifier, a dropped NOT) and it is honest about what it cannot catch: a claim with no literal at all, a paraphrase flip with no cue word, or a look-alike twin stated in the same cited message.

Importance chain. A replacement inherits the highest importance in the chain it replaces, so a correction can never be filtered out while the value it corrected was still being shown.

Layer 3 — assembly (assembler.py)

When layer 0 had to omit messages, the assembler tries to cover the oldest part of that gap:

  1. Requires head_count == HEAD_PROTECT (the untouched opening) and claims enabled.
  2. Re-reads stored compactions for the conversation and builds the trusted chain.
  3. Uses only regions that lie entirely inside the omitted messages, stopping at the first that doesn’t.
  4. Formats the claims block and checks it against 15% of the history budget; if it does not fit it drops the oldest regions first, then lowers the importance floor from 2 to 3 (identifiers, then decisions).
  5. Refits the raw history against budget − claims, and verifies the refit still omits everything the claims cover. If not, it returns the original fit untouched.
  6. Only the messages not covered by claims are announced as omitted.

The block itself looks like this:

[compacted history: claims extracted from earlier messages that are no longer shown; every value in a
 claim was checked against the message ids it cites (if you have the conv_expand tool, use it to read
 the originals); a claim marked (replaces ...) supersedes an earlier one]
region m2..m333 (332 messages):
- r1.c1 [constraint] Pin libfoo to 3.14.2; do not move to 3.15.0 because ... [cites: m54, m55]
- r2.c4 [correction] The admin API port is 9443 now; 8443 clashed ... [cites: m144, m145] (replaces r1.c2)

Only the newest region’s narrative is rendered by default. Older narratives were written before later corrections existed and will still state superseded values — a real defect observed in testing, where three corrected values survived inside older region prose.

Escape hatch. Faces with tools get conv_expand(ids, offset) (whole ids or the first..last range printed in the omission note, 20 messages / 60K chars per call, paging) and conv_search(query, limit). The prompt tells the model to expand before relying on an exact value. Tool-less faces see the claims but cannot expand them — that is a known asymmetry, not a bug.

The trust chain

A stored compaction is usable only if all of these hold, evaluated at prompt time:

One edited or regenerated message invalidates its region and every region after it. That is the point: the system would rather fall back to a raw fit than cite a claim about text that no longer exists.

4. Triggers, budgets and guards

KnobDefaultMeaning
history budget50% of window (long profile: 75%)max tokens of prior conversation
soft trigger25% of window, capped at 200K (long: 70%)prompt size at which background compaction starts
region size12K tokensbigger regions produced 66% noise; 8–12K regions produced 0.6–4.5%
regions per run4run extends the chain this far, then stops
max concurrent2across all conversations
failure cooldown300 safter a failed region, per conversation
claims share15% of history budgethard cap on the claims block
compactor seatdeepseek_v4_flashcheap long-window model, overridable

Guards: one writer per conversation (async lock); compacting the same content twice is impossible (content-addressed id); background compaction only runs for conversations whose backend hint is a known cloud backend — local, unknown and unset backends are never shipped to a cloud compactor seat; any exception inside the assembler falls back to the plain fit and logs.

Kill switches, in increasing scope:

The staged rollout order that made this safe: fit-only → shadow compactor (computes, logs, is never read) → claims in prompt. Separate the observation that a component runs from evidence that its output is used.

5. Evidence

All numbers: DeepSeek Flash as the compactor, exact-value scoring (whole-token match), no LLM judge, repeat runs, arms share the same fixture and budget.

Eval v1 — 400 messages, 16 planted facts, 10K budget

armaccuracycontext tokens
full history16/1653,782
sliding window2/169,755
plain LLM summary16/1610,617
claims + narrative16/1612,605

The window answered UNKNOWN on every dropped fact — amnesia, not invention. Both compaction arms recovered everything at about a quarter of the tokens. 60 claims, 60 accepted, 242 literals checked, 0 unsupported, one attempt, 12.7 s.

Eval v2 — correctness under iteration and capacity (sliding window / iterative prose / claims-merge)

scenariowindowprose_iterclaims
16 planted facts12.5%—100% (v1: 93.8–100%)
330 exchanges, 4 successive compactions11%97% / 100%97% / 100%
182 facts + 20 corrections, 43 questions0%100% / 96.5%100% / 100%

Honest reading: at this scale iterative prose is roughly as accurate as claims. Every prose miss was an omission, not an invention. Claims are not shown to be more accurate here — what they add is verifiability, provenance, supersession and a steadier context size (claims 12.7–18.7K tokens across runs; prose 12.8–18.3K and climbing).

Long stage A — 939K tokens, 21 sequential compactions, 24 questions × 2

armaccuracycontext tokens
prose, 100K raw tail91.7% (44/48)103.7K
prose, 250K raw tail95.8% (46/48)216.5K
claims, 100K raw tail100% (48/48)116.8K
claims, 250K raw tail100% (48/48)225.2K

Pooled over both tail sizes: claims 96/96, prose 90/96.

This is where the design pays. The prose failures were flag:zeta-sync answered ALLOWED in both repeats (a polarity inversion of “must NOT be enabled”) and two missed corrections; the running summary grew every cycle (1,095 → 3,723 tokens, no plateau). Claims: 500 claims, 0 rejected, 1,506 literals checked, 0 unsupported, 15/16 corrections carrying supersedes, 167/168 planted keys present in the compacted prefix.

Validator stress on real claims (one corruption per kind): number shifted by one 151/152; wrong citation 170/170; path tweak 19/19; identifier transposition 53/55; NOT removed from a constraint 7/8 (vs 0/29 before the polarity check existed). Offline: 227 real claims flagged 0 false positives while catching 31/31 synthetic cue removals.

Known limitation, not fixed: at 40K-token regions, 66% of shown claims were filler (bin counts, supplier quotes) against 0.6% at 8K — the model pads to fill the region. Dropping to the default 12K regions is the mitigation; it was not re-measured end to end. A proportional claim cap is the obvious next experiment.

6. Deployment shape

Operational lesson: a long-lived process hides a broken restart path. The first restart in weeks surfaced a memory-DB WAL/SHM pair owned by a different OS user, which crashed that lane at startup when the old process had merely kept its handles open since September. On any lane you have not restarted recently, restart it before you need to.

7. Porting the shape to another agent runtime

The module is four pure-ish functions plus two hooks, which is why it ports:

  1. select_region(messages) → region | None
  2. build_prompt(region, prior_claims) → messages
  3. validate_claims(raw, region, prior_ids, prefix) → (accepted, report)
  4. render_claims_block(results, min_importance) → str

Hooks needed from the host: a per-turn record of messages with stable ids and timestamps, a post-turn callback to start background work, and a way to swap the history for a single request without mutating the saved transcript.

The open risk for any port is stated plainly: all the evidence above is plain chat. Agent sessions are tool-heavy, with large tool results and tool-call messages whose ids and semantics differ. Replay real sessions offline before switching it on.

8. Checklist for building the same shape


Source: the October 29, 2026 Cited Claims Compaction architecture write-up, based on the BoB context module and author-run evaluations. Results are reported from that record, not independently rerun for this page. The private deployment notes and runtime-specific port plan are omitted from this public edition.