Context management and summarization
Overview
Keep long sessions inside the model’s context window without destroying the transcript. Add a token-estimation interface (heuristic now, tiktoken later), pre-summarization tool-argument truncation, non-mutating summarization triggered at 0.85 of the window keeping the last 0.10, history offload to /conversation_history/{session_id}.md, retry-on-overflow, and Anthropic prompt-cache breakpoints. Nothing here mutates the persisted message log: compaction is applied inside WrapModelCall only, so the raw history stays replayable.
Requirements
Functional
- Budgeting is computed over the **whole request**: `Estimator.EstimateRequest(req)` counts system blocks (including the memory and skills fragments contributed in phase 11), every message block, and the serialized tool definitions. Estimating only `messages` under-counts by the largest fixed cost in the request and is why a "0.85 trigger" would still overflow.
- Optional `TokenCounter` capability: a provider may implement `CountTokens(ctx, req) (int, error)`; the Anthropic adapter implements it over `Messages.CountTokens`. When present it is used for the trigger decision (with the heuristic as the fallback on error or timeout), so the threshold is based on real numbers rather than `bytes/4`.
- A model-profile table in config supplies
max_input_tokensper model with a documented default. - Tool-argument truncation runs first: old
write_file/edit_file/large tool arguments in messages older than the keep window are clipped to a preview, which often avoids summarization entirely. - Summarization triggers when estimated input tokens exceed
TriggerFrac(0.85) of the window; it keeps the lastKeepFrac(0.10) of messages verbatim, summarizes the rest with a dedicated summarizer call, and prepends the summary as a single user message. - **Boundary rule:** the computed keep boundary is snapped *backwards* to the nearest assistant message that has no `tool_use` block left unmatched by a `tool_result` in the kept slice. A boundary that lands inside a tool batch would send a `tool_result` whose `tool_use` was evicted (or vice versa) and the provider 400s permanently for that session.
- A post-compaction validator runs on the compacted slice before it is sent: every `tool_result` must have its `tool_use` earlier in the slice and every `tool_use` must have its `tool_result`. A validation failure aborts the compaction, logs at error, and falls back to mechanical truncation — it never sends an invalid request.
- Evicted messages are appended to
/conversation_history/{session_id}.mdthrough the filesystem backend, and the summary embeds that path with aread_fileinstruction. - The summarization prompt must include an identifier-preservation clause (ids, paths, file names, tool-call outcomes, open questions, current plan).
provider.ErrContextOverflow(or stop reasonmodel_context_window_exceeded) triggers summarization and one retry of the same turn. A second overflow does not fail the run: it degrades to mechanical truncation (drop whole oldest turns, preserving tool-batch integrity, until the request fits) and retries once more. Failing the run is the last resort after truncation cannot fit even the keep window.- Prompt-cache breakpoints are placed by `PromptCacheMiddleware` **during chain-time system-block assembly**: it records the index of the last stable system block and sets `CacheControl` there, and sets a second breakpoint on the summary message that opens the compacted history. Because memory (phase 11) contributes blocks later in the chain, its blocks land at a higher index than the breakpoint by construction, not by convention. The middleware skips breakpoint placement entirely when the resolved provider reports `Capabilities.PromptCaching == false`.
- A
compact_conversationtool lets the model compact on demand.
Non-functional
- Compaction is recorded in
State.Privateand applied only to the outgoingModelRequest.Messages;State.Messagesand themessagestable are never rewritten. - Summarizer calls use the same provider but a configurable (cheaper) model, and are attributed separately in usage.
- Summarization is idempotent per turn: a second
WrapModelCallin the same turn reuses the existing compaction event. - Deterministic tests: the estimator is injectable, and the fake provider scripts both the summarizer response and the overflow error.
Architecture
// internal/agent/tokens.go (extended)
type Estimator interface {
Estimate(text string) int
EstimateRequest(req *provider.Request) int // system blocks + messages + tool defs. Red team: F9
}
// Optional provider capability; used for the trigger decision when available. Red team: F9
type TokenCounter interface {
CountTokens(ctx context.Context, req provider.Request) (int, error)
}
type ModelProfile struct {
MaxInputTokens int
TriggerFrac float64 // 0.85
KeepFrac float64 // 0.10
SummarizerModel string
}
// internal/agent/mwsummarize.go
type SummarizationMiddleware struct {
Est Estimator
Profile ModelProfile
Backend fs.Backend
Provider provider.Provider
} // ModelWrapper + ToolProvider (compact_conversation)
type compaction struct {
AtTurn int
Summary string
KeptFrom int // index into State.Messages
OffloadPath string
Tokens struct{ Before, After int }
}
// internal/agent/mwtruncate.go
type ToolArgTruncationMiddleware struct {
Est Estimator
MaxArgTokens int
KeepRecent int // messages exempt from truncation
} // ModelWrapper, runs inside Summarization so summarization sees truncated args
// internal/agent/mwcache.go
type PromptCacheMiddleware struct{} // ModelWrapper: sets CacheControl breakpointsNo new tables; sessions gains counters.
-- migrations/00008_session_context_counters.sql
alter table sessions add column last_input_tokens int not null default 0;
alter table sessions add column compactions int not null default 0;
-- Red team: F9 — no `summary` column: nothing reads it. The authoritative summary is the
-- compaction record in runs.state_json plus /conversation_history/{session_id}.md.Instrumentation
- Span event
gen_ai.conversation.compactedon the turn span withhango.tokens.before,hango.tokens.after,hango.messages.evicted,hango.compaction.reason(threshold|overflow|tool),hango.fs.pathof the history offload; the attributegen_ai.conversation.compacted=trueis also set on the followingchatspan so a compacted turn is filterable. - The summarizer’s own provider call is a separate
chat {summarizer model}CLIENT span withhango.call.purpose=summarize, so summarizer cost never hides inside the main generation. - Span event
provider.context_overflow_retrywhen an overflow error triggers compaction and retry. - Metrics:
hango.context.input_tokenshistogram (attributegen_ai.request.model),hango.context.compactionscounter (attributereason),hango.context.evicted_messageshistogram,hango.context.tool_args_truncatedcounter,gen_ai.client.token.usagegainshango.call.purposeso summarizer tokens are separable,hango.cache.read_ratiohistogram derived from cache-read vs input tokens. - Log fields: one info line per compaction with session id, before/after tokens, evicted count and the offload path.
Related Code Files
Create
internal/agent/mwsummarize.go,internal/agent/mwsummarize_test.go.internal/agent/mwtruncate.go,internal/agent/mwtruncate_test.go.internal/agent/mwcache.go,internal/agent/mwcache_test.go.internal/agent/summarize_prompt.go— the summarization prompt with the preservation clause.internal/agent/profile.go— model profile table loaded from config with a documented fallback (170k trigger / keep 6 messages when a model is unknown).migrations/00008_session_context_counters.sql.docs/learning/10-context-management.md.
Modify
internal/agent/tokens.go— addEstimateRequest, the optionalTokenCountercapability, and theEstimatorinterface docs.internal/agent/mwstack.go— fill the Summarization slot (after Filesystem/offload, after Subagent) with truncation nested inside it, and the PromptCache slot before the Memory slot.internal/agent/loop.go— onErrContextOverflow, mark the compaction reason and retry the turn once.internal/store/pg/session.go— updatelast_input_tokensandcompactionsat the turn boundary.internal/config/config.go—HANGO_MODEL_PROFILES(JSON),HANGO_SUMMARIZER_MODEL,HANGO_CONTEXT_TRIGGER_FRAC,HANGO_CONTEXT_KEEP_FRAC,HANGO_TOOL_ARG_MAX_TOKENS.docs/system-architecture.md,docs/learning/README.md.
Implementation Steps
- Extend
internal/agent/tokens.gowithEstimateRequest(system blocks + all message blocks + serialized tool definitions, plus fixed per-message and per-tool-block overheads) and add the optionalTokenCountercapability with the AnthropicMessages.CountTokensimplementation and a bounded timeout plus heuristic fallback. - Write
internal/agent/profile.go: parseHANGO_MODEL_PROFILESas{"model": {"max_input_tokens": n, ...}}, fall back to 170000 trigger and a 6-message keep for unknown models, and log once per unknown model. - Write
internal/agent/mwtruncate.go: aModelWrapperthat copies the request’s message slice and clips tool-use argument JSON and tool-result text in messages older thanKeepRecentto a preview with a size note. Never touchState.Messages. - Write
internal/agent/summarize_prompt.go: instruct the summarizer to preserve identifiers, file paths, tool outcomes, decisions, the current plan and open questions verbatim, and to write the summary as a briefing for a fresh agent. - Write
internal/agent/mwsummarize.go:- In
WrapModelCall, estimate the whole outgoing request (EstimateRequest, orCountTokenswhen the provider supports it). If under the trigger, callnext. - Otherwise compute the keep boundary, snap it backwards to an assistant message with no unmatched
tool_use, append the evicted messages as markdown to/conversation_history/{session_id}.mdthrough the backend, call the summarizer model with the evicted slice, build[]provider.Message{summaryUserMessage} + kept, run the tool_use/tool_result validator, store thecompactionin typed private state, set the request messages to the compacted slice, then callnext. - Record the compaction span event and metrics; the session counters are written by the loop at the turn boundary, not here.
- Expose
compact_conversationas aToolProviderthat forces a compaction on the next model call with reasontool.
- In
- Handle overflow: in
loop.go, when a model call returnsErrContextOverflowor the stop reason isContextExceeded, set a forced-compaction flag with reasonoverflowand retry the turn once. On a second overflow, degrade to mechanical truncation (drop whole oldest turns, never splitting a tool batch) with reasontruncateand retry once more; only a third failure fails the run. - Write
internal/agent/mwcache.go: during chain-time system-block assembly, record the index of the last stable system block and attachCacheControlthere, plus a breakpoint on the summary message that opens the compacted history. Skip entirely whenCapabilities.PromptCachingis false. Comment states that anything contributing prompt blocks later in the chain (memory in phase 11) lands after the breakpoint index by construction. - Insert truncation, summarization and prompt cache into
DefaultStackin the documented slots; update the pinned order test. - Tests: estimator monotonicity and that
EstimateRequestgrows when tool definitions or system blocks grow; truncation clips only old messages and leaves recent ones intact; summarization triggers at the threshold, produces a compacted request, leavesState.Messagesand the DB untouched (asserted by reloading history), and writes the offload file; the summary contains the offload path; a keep boundary computed inside a tool batch is snapped back so the compacted slice has no orphanedtool_useortool_result, asserted by the validator over a fixture whose 0.10 boundary falls between atool_useand itstool_result; validator failure falls back to truncation instead of sending; overflow → compaction → second overflow → mechanical truncation → success (fake provider);compact_conversationforces compaction; cache-control placement test asserting the breakpoint index in the assembled system blocks and on the summary message, plus a no-op assertion whenPromptCachingis false. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/10-context-management.md must contain:
- Concept: the context window as a budget, and the three independent mechanisms (per-result offload, argument truncation, summarization) with the reason one is never enough.
- Design choices: non-mutating compaction inside
WrapModelCall, history offload plus a path in the summary so the agent can recover detail, identifier-preservation clause, separate summarizer model, overflow-triggered retry, cache breakpoints before the memory slot. - Trade-offs: 0.85/0.10 defaults; summarizing vs sliding window vs retrieval; heuristic estimation vs tiktoken (and why the interface exists now); one retry vs repeated compaction.
- Prompt caching mechanics: what invalidates a prefix, why ordering in the middleware stack is load-bearing, and how to read cache-read vs cache-write tokens.
- goclaw comparison:
loop_compact.gosummarizes the first ~70% and keeps ~30%/KeepLastMessageswith a hardcoded “MUST PRESERVE” prompt, mutating the stored session blob;TokenCounterinterface with a tiktoken implementation plus a pure-Go fallback. - deepagents comparison:
create_summarization_middlewarewith profile-driven 0.85 trigger / 0.10 keep, pre-summarization tool-arg truncation, eviction appended to/conversation_history/{session_id}.md, retry onContextOverflowError, compaction recorded in a private_summarization_eventand applied insidewrap_model_callsostate["messages"]stays replayable, plus the model-callablecompact_conversationtool. - Why hango copies deepagents here almost exactly (non-mutating is the correct call for a server that must replay runs) and diverges from goclaw’s mutating compaction.
- How to debug this layer: filter Tempo for spans with
gen_ai.conversation.compacted=trueto see which turns compacted and the before/after token counts;hango.context.input_tokensandhango.context.compactionsby reason show whether a session is thrashing; the summarizer’s ownchatspan (withhango.call.purpose=summarize) exposes summarizer cost;hango.cache.read_ratiocollapsing to zero means a cache breakpoint was invalidated by a later middleware;/conversation_history/{session_id}.mdis the ground truth of what was evicted. - Tool-batch integrity as a hard invariant of any context surgery, and why a validator is cheaper than debugging a permanently 400-ing session.
- Exercises: drive a session past the trigger and read the compaction event; construct a history whose keep boundary lands mid tool batch and watch the snap-back; add a middleware after the cache breakpoint and watch the cache-read ratio drop.
- Time budget: 11h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass. - Summarization test: a session over the trigger produces a compacted outgoing request while
MessageStore.Historystill returns the full untouched transcript (integration test). - The evicted transcript exists at
/conversation_history/{session_id}.mdand its path appears in the summary text (test). - Tool-arg truncation alone keeps a borderline session under the trigger without summarizing (test).
- Overflow triggers compaction and one retry; a second overflow degrades to mechanical truncation and succeeds; only a third failure fails the run (test).
- A compaction whose raw keep boundary falls inside a tool batch snaps back, and the post-compaction validator confirms every
tool_resulthas itstool_use(test over the adversarial fixture). - Cache-control breakpoints land at the expected indices of the assembled system blocks and on the summary message; with
Capabilities.PromptCaching == falseno breakpoint is set. -
EstimateRequestaccounts for system blocks and tool definitions (test shows the estimate rising when only the tool list grows); with aTokenCounter-capable provider the trigger uses the counted value and falls back to the heuristic when the count errors. - Session counters are written exactly once per turn (test counts the
UPDATE sessionsstatements across a two-turn run with one retried model call). -
compact_conversationforces compaction on the next call (test). - In Grafana, a compacted turn shows the
gen_ai.conversation.compactedspan event with before/after tokens, andhango.context.compactionsincrements with the rightreason; the summarizer call appears as its ownchatspan.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Summary loses an identifier the agent needed | High x High | Agent re-asks for known facts, or fails to find a file it wrote | Preservation clause in the prompt plus the history offload path in the summary so detail is recoverable by read_file; the offload file is the safety net |
| Compaction mutates persisted history | Medium x Critical | Replayed runs diverge | Compaction lives in State.Private and only rewrites the outgoing request; the integration test reloading history is the gate |
| Estimation error causes late compaction and a hard overflow | High x Medium | ErrContextOverflow in production | EstimateRequest covers the whole request, the optional TokenCounter uses real counts, and the overflow path degrades to mechanical truncation before it ever fails a run |
Compaction splits a tool_use/tool_result pair and permanently 400s the session | Medium x Critical | Provider 400 on every subsequent turn of that session | Boundary snapped back to a clean assistant message, post-compaction validator, and an adversarial fixture whose boundary falls inside a tool batch |
| Summarizer call itself overflows | Medium x Medium | Summarizer returns overflow | Evicted slice is chunked to the summarizer’s own window; failure falls back to a truncated mechanical summary listing message roles and tool names |
| Cache breakpoint invalidated by a later middleware | Medium x Medium | hango.cache.read_ratio near zero | Stack order pins PromptCache before Memory; the order test plus the cache-ratio metric detect regressions |
| Compaction thrashing on every turn | Medium x Medium | hango.context.compactions growing per turn | Keep fraction guarantees forward progress; a guard refuses to compact twice in one turn and logs at warn |
Rollback
goose down one step drops the session counters. Removing the Summarization, truncation and PromptCache slots from DefaultStack restores phase 9 behaviour; because compaction never mutated stored history, no data repair is needed.