Decision records
ADR 0137 — Agent state growth: an opt-in warning and a built-in compaction summarizer
- Status: Accepted (2026-11-01) — proposed 2026-10-31, implemented and merged; accepted by the owner
- Date: 2026-10-31
- Found by: GitHub issue #721 (measurement and plan). Owner ruling:
agentLoop$compaction stays OPT-IN; there is NO default behaviour change. - Builds on: ADR 0087 D5 (compaction as a recorded task), ADR 0122 (the conversation layer’s rolling summary), ADR 0083 (the second-consumer rule).
- Placement: 1P,
@loomwork/agentonly. Kernel, KERNEL_ABI, EVENT_SCHEMA and every SPI unchanged. No new event, no new log field.
Context
An agentLoop$ without compaction journals each turn’s whole model input (the transcript is re-sent every
turn), so its folded state grows O(turns^2) and resume reads it all. The #721 offline harness (re-measured with a
cloning store; the first table was ~2x high, see issue #722) measured 3.5 MB of folded state at 50 turns,
54.9 MB at 200, 279 MB at 450, and a RangeError: Invalid string length serializing it by 650
(table and caveats in docs/scaling.md, “Agent state growth”). compaction: { toolId, afterBytes } already
bounds it, but nothing says you need it, and opting in means writing a summarizer effect.
Decisions
D1 - warn, never act. With no compaction, when the loop’s running transcript byte count (the counter
afterBytes already reads; not re-serialized) exceeds warnTranscriptBytes, the loop emits ONE warning per
run through the existing seam (AgentLoopConfig.onWarn, else the app’s observability.onWarn via
configureAgentLoopDefaults, else a process warning LOOM_AGENT_STATE_GROWTH; a throwing sink never fails
the run). It names the fix and docs/scaling.md. Default threshold DEFAULT_STATE_GROWTH_WARN_BYTES =
256 KiB (~64k tokens): in the harness that is turn ~107 and ~16 MB of folded state, early enough to act on
(compacting there is cheap, and a 128 KiB default would warn at turn ~52 and 3-4 MB, noisier for ordinary
agents); the issue’s 1 MB example is turn ~450 and ~280 MB, past a Postgres JSONB value’s 255 MB cap. warnTranscriptBytes: false turns it off. The trigger
is a pure function of journaled content; nothing is written to the log; a resume in a fresh process replays
the crossing turn from the log and warns again (once). The log and the model’s requests are byte-identical
with and without the warning (tested).
D2 - a built-in summarizer, model supplied by the caller. createSummarizerEffect(provider, { instruction?, maxTokens? }) returns an ordinary createLlmEffect: { messages } -> { summary }, the contract of both
compaction.toolId and @loomwork/conversation’s budget.summarizerToolId. The user registers it under a name
of their choosing (effects.register("summarize", createSummarizerEffect(provider)), or passes a route’s
provider), so it runs inside the capability ring (grant, metering, quotas, guardRequest) like the agent’s own
model call, and ctx.execute records it once and serves it from the log on replay with no model call. It is
opt-in by being named in compaction.toolId; nothing registers it implicitly. The transcript is flattened
with summarizerText and re-wrapped tainted(..., "agent:summarizer-input") in a USER message, never the
system prompt. maxTokens defaults to 1024 (~4 KiB): a summary larger than afterBytes is re-summarized on
the next turn (the skip rule only covers a single oversize newest message), one summarizer call per turn until
it fits, so keep it well under afterBytes / 2. An empty or whitespace summary throws SummarizerEmptyError
(the summary replaces the folded transcript and is journaled permanently, so the step fails visibly instead).
D3 - reuse. The conversation layer had no built-in summarizer (its summarizerToolId is also
user-written), but it owned summarizerText, the bounded taint-unwrapping walker both contracts need. It moved
to @loomwork/agent (@loomwork/conversation depends on @loomwork/agent, not the reverse) and @loomwork/conversation
re-exports it unchanged. The summarizer is not conversation-specific.
D4 - second consumer (ADR 0083). Two unrelated consumers of the same { messages } -> { summary }
effect: agentLoop$ compaction.toolId and the conversation layer’s budget.summarizerToolId (one
registered effect serves both, as summarizerText’s documentation already said).
Rejected / not here
- Compaction as the
agentLoop$default (owner ruling: opt-in). - Dropping completed turns’ inputs from folded
frames(issue #721 item 4): a kernel change needing its own root-cause read, ADR and EVENT_SCHEMA check; not in this change.
Consequences
Additive public API on @loomwork/agent: AgentLoopConfig.warnTranscriptBytes, DEFAULT_STATE_GROWTH_WARN_BYTES,
createSummarizerEffect, DEFAULT_SUMMARIZER_INSTRUCTION, SUMMARIZER_INPUT_ORIGIN, and (moved)
summarizerText / SUMMARIZER_TEXT_TRUNCATED. An existing uncompacted loop that crosses 256 KiB now logs
one warning; set warnTranscriptBytes: false to silence it.
Amendment (2026-10-31) — the warning also names claim-check
ADR 0140 adds the opt-in execution.claimCheck, and the warning text now
names it beside compaction (tool outputs are about 80% of the state). Still report-only, still one warning
per run, still fires with claim-check on (compaction is what bounds the transcript).
Review round (2026-10-31): the warning text also says the opt-in WITHHOLDS an offloaded tool’s result from the model except its digest/preview, so only tools whose output the model does not need in full should be listed.
Source: docs/adr/0137-agent-state-growth-warning-and-builtin-summarizer.md in the loom repository.