History management
The runtime keeps one conversation history per agent session and sends it to the model on every turn. Left unbounded that history outgrows the context window, so the runtime has two complementary mechanisms to bound it:
- Whole-turn trimming (the primary mechanism): drops oldest whole turns to fit a token budget.
- Message-count hard cap (a safety net): enforces a ceiling on the total number of messages after each turn.
This page documents both, disambiguates them from the adjacent operations people conflate them with, and describes the user-visible signals they emit.
Everything here is sourced from the runtime code in
crates/zeroclaw-runtime/src/agent/. Where a behavior is named, the function
that implements it is named with it.
Whole-turn trimming (primary)
history_trim::trim_to_recent_turns(history, budget_tokens) is the primary
trimming mechanism. Its rule is deliberately small enough to hold in your head:
Keep the most recent whole turns that fit the token budget, drop the rest, never cut a turn in half.
A turn starts at a real user message and runs until the next real user
message, covering the assistant reply, any assistant tool-call rows, and any
tool-result rows in between. Tool exchanges live entirely inside a turn, so
dropping whole turns can never split a tool_use from its tool_result.
Pairing safety for providers that enforce it (Anthropic among them) is
structural, not patched up afterward.
The function returns a TrimResult carrying dropped_turns, dropped_messages,
kept_turns, tokens_before, tokens_after, and trimmed. trimmed is true
only when at least one whole turn was dropped. Leading system messages are
always preserved, and at least the most recent whole turn is always kept even
if that single turn exceeds the budget: the model never gets nuked to nothing.
When it runs
Trimming runs at two moments, never mid-tool-loop:
- Preemptive, once at the start of a turn, when the history already exceeds
the effective budget (
run_tool_call_loop, iteration 0). - Reactive, when a provider returns a context-window-exceeded error; the
recovery path drops oldest whole turns and retries
(
turn::context_recovery::try_recover_context_overflowand the interactive loop’s overflow arm).
Both paths call the same trim_to_recent_turns. There is no per-iteration
pruning and no summarization step.
The budget
The budget comes from ResolvedRuntime::effective_context_budget():
- When
history_pruning.enabledis set with a positivehistory_pruning.max_tokens, the budget is the lower of that floor andmax_context_tokens, so an explicit budget trims earlier than the hard ceiling. - Otherwise the budget is
max_context_tokensand the hard ceiling is the only trigger.
Token counts are estimated by history::estimate_history_tokens: roughly four
characters per token plus four framing tokens per message. It is a heuristic,
not a tokenizer.
The
history_pruning.*config keys are reused as-is;collapse_tool_resultsandkeep_recentno longer drive any code path (the most recent whole turn is kept structurally). The key idents are scheduled to be renamed at config schema V4 and are intentionally left in place until then.
Message-count hard cap (safety net)
After the primary whole-turn trim, a second mechanism enforces an absolute ceiling on the number of messages in the conversation:
history::trim_history(history, max_history)operates on the interactive (ChatMessage) history inloop_.rs. It preserves the system prompt, the first user message (the framing anchor that prevents silent-amnesia bugs), and the most recentmax_historymessages, dropping from the middle when the ceiling is exceeded.Agent::trim_history()(agent.rs) applies an equivalent cap to structuredConversationMessagehistory in the ACP/gateway turn path, with additional orphan-safety cascades that avoid creating danglingToolResultsorAssistantToolCallsat the head after a drop.
Both emit a log record on every fire so silent message loss is detectable.
The value of max_history is set through max_history_messages in the
agent’s runtime profile config. Because the hard cap runs after every turn,
it acts as a safety net: even if the whole-turn trim’s token budget is
generous enough to keep many turns, the message-count cap limits the
structural size of the history.
The hard cap does not inject a breadcrumb or emit a
history_trimmedevent (unlike whole-turn trimming). The only user-visible signal is the log record. The cap is intended as a structural safety net, not a first-line trimming strategy.
It is never silent
When trimmed is true the caller does two things so the loss is always visible:
-
Injects a breadcrumb into the history, after the leading system messages and before the first kept turn:
[earlier turns omitted to fit the context window](history_trim::breadcrumb). -
Emits a visible “context was trimmed” signal on every client surface, the same multi-surface visibility contract that turn cancellation uses:
- ACP (
session/updateof typehistory_trimmed, mapped inacp_server.rs) carryingsessionId,droppedMessages,keptTurns, andreason(SessionUpdateEvent::HistoryTrimmed). - Gateway WebSocket (
{"type":"history_trimmed", ...}, mapped inws.rsfromTurnEvent::HistoryTrimmed). - SSE
/api/events({"type":"history_trimmed", ...}, mapped insse.rsfromObserverEvent::HistoryTrimmed) carryingdropped_messages,kept_turns,reason, plusagent_alias/channel/turn_idwhen the attribution span carries them.
When the context changes underneath the model, the end user is told why.
- ACP (
The breadcrumb matters beyond the UI. With it in context, a model asked to recall dropped work answers honestly (“the earlier turns were omitted from my context window”) instead of fabricating a result it can no longer see.
What trimming is not
These are distinct operations. Only the first two drop conversation history.
| Operation | What it does | Where |
|---|---|---|
| Whole-turn trimming | Drops oldest whole turns to fit the token budget. The primary history-removal mechanism. | history_trim::trim_to_recent_turns |
| Message-count hard cap | Drops oldest messages when the number of messages exceeds max_history_messages. A safety net that preserves the system prompt, first user anchor, and most recent messages. | history::trim_history / Agent::trim_history |
| Orphan sweep | Removes a tool_result whose tool_use is gone (or vice versa) so providers do not 400 on a dangling pair. A pairing-safety net, not a size control. | history_pruner::remove_orphaned_tool_messages |
| System normalization | Merges and reorders system messages to the front. Changes shape, never drops turns. | history::normalize_system_messages |
| Tool-result capping | At collection time, caps a single tool result’s length (max_tool_result_chars). Bounds one message as it is recorded; does not touch history. | history::truncate_tool_result |
| Provider truncation | The provider’s own context-window enforcement, server-side. Out of the runtime’s hands; the reactive path reacts to it. | provider API |
There is no context compression or summarization step. The runtime does not replace old turns with a synthetic summary and does not inject placeholder markers into provider-visible history. If you are looking for that, it was removed: collapsing turns into summaries is exactly the silent-mutation pattern that made models report work they could no longer see.
Pairing safety
The hard invariant is that a request never carries a tool_use without its
tool_result or vice versa. Two things guarantee it:
- Whole-turn trimming cannot split a pair, because both halves live inside the same turn and turns are dropped atomically.
- The orphan sweep runs as a final net for histories that arrive already broken (reloaded sessions, upstream edits), removing any dangling tool row before the request goes out.
A trimmed history therefore passes the orphan sweep with nothing to remove,
which is asserted directly in the history_trim unit tests.