Skip to content

feat(agents): compact agent history when it outgrows the context window - #3455

Open
cf3901646 wants to merge 1 commit into
dimensionalOS:mainfrom
cf3901646:feat/agent-history-compaction
Open

feat(agents): compact agent history when it outgrows the context window#3455
cf3901646 wants to merge 1 commit into
dimensionalOS:mainfrom
cf3901646:feat/agent-history-compaction

Conversation

@cf3901646

Copy link
Copy Markdown

Refs #1899

Problem

McpClient._process_message appends every message to self._history and replays the whole list on each turn:

for update in state_graph.stream({"messages": self._history}, stream_mode="updates"):

Nothing bounds that list. Tool-heavy sessions grow it fastest, because each command adds a human turn, an AIMessage carrying the tool call, and a ToolMessage carrying the full result. Once the history passes the model's context window the request fails, and because the history is never trimmed, every subsequent turn fails too. The session is dead until someone restarts it.

Approach

New module dimos/agents/compaction.py. Once the history passes a configurable fraction of the context window, the oldest messages are dropped and replaced by a single summary message; recent turns are kept verbatim.

compact_history() is pure — it takes a list of messages and returns a new one, and takes no model. Summarisation is injected via a summarizer callable, which is what makes the whole thing testable offline.

Three details that drove the design:

  1. Tool-call integrity. Cutting the history at an arbitrary index can leave a ToolMessage whose originating AIMessage was dropped. The OpenAI API rejects an orphaned tool result, so naive truncation trades a context-overflow error for a 400. _split_index advances the cut past any leading ToolMessage, so a dropped tool call always takes its results with it. Two tests cover this, including parallel tool calls.

  2. Compaction must not re-trigger every turn. keep_ratio has to be meaningfully below trigger_ratio or every turn pays for a summarisation. This is validated at the API boundary rather than left as a footgun, and test_repeated_compaction_is_stable asserts a compacted history is not immediately re-compacted.

  3. Compaction must never fail a turn. A summarizer failure falls back to the offline digest, and a failure inside compaction itself is logged and the turn proceeds uncompacted. A bug here should degrade the session, not kill it.

Also: a leading SystemMessage is treated as instruction rather than conversation and is never dropped, and estimate_tokens charges images a flat 1500 tokens so image-carrying histories compact early rather than late.

Context windows

resolve_context_window() maps a model name to its window by longest-prefix match, stripping any provider: prefix so ollama:llama3.1:8b resolves via llama3.1. Longest-prefix matters: gpt-4.1 must not resolve through the gpt-4 entry and get an 8k window. Unknown models fall back to a conservative 128k — over-compacting costs some fidelity, under-compacting fails the request.

Configuration

On McpClientConfig, enabled by default:

Option Default
compaction_enabled True
context_window None (resolved from model)
compaction_trigger_ratio 0.8
compaction_keep_ratio 0.35
compaction_summarize_with_model True

Checks I ran

  • dimos/agents/test_compaction.py — 40 tests, all passing.
  • ruff check and ruff format --check clean on all four changed files. One pre-existing BLE001 remains in mcp_client.py at the dispatch_continuation handler; it is untouched by this PR and present on main.

Honest limitations:

  • I could not run the full uv run pytest suite in my environment, so the existing McpClient tests are unverified against this change. The integration is small and additive — one config block, one guarded method, one call site before state_graph.stream — but it does deserve a CI run.
  • No hardware and no simulation was involved. This touches agent conversation bookkeeping only; nothing in the control, driver, planner or motion path is affected.

The issue asks for this to be "tested to see how well it works in practice with real commands". test_a_long_tool_heavy_session_stays_within_budget is my stand-in: it drives 300 command turns through the real compaction path and asserts the history never exceeds the window and the newest turn survives intact. That is a simulation of the shape of the load, not a substitute for running it against a live model, which I could not do here.

Notes

  • This is an open question I would rather flag than silently decide: compaction_summarize_with_model defaults to True, which spends an extra model call each time compaction fires. If you would rather compaction be free by default, flipping it to False uses the offline digest and needs no other change.
  • Per AI_POLICY.md: this was written with AI assistance. Happy to walk through any part of it, and equally happy to adjust the design if you would rather compaction live somewhere other than McpClient.

McpClient replays the whole conversation on every turn, so a long
session eventually exceeds the model's context window and every request
from then on fails.

Add dimos/agents/compaction.py, which drops the oldest messages and
replaces them with a summary once the history passes a configurable
fraction of the window:

- resolve_context_window() maps a model name to its window by longest
  prefix, stripping any provider: prefix, with a conservative default
- estimate_tokens() is a cheap offline estimator that charges images
  heavily so image-carrying histories compact early
- compact_history() is pure and model-free, so it is testable offline;
  summarisation is injected and falls back to an offline digest
- tool results are never separated from the call that produced them,
  since an orphaned ToolMessage is rejected by the OpenAI API
- a leading system message is never dropped

Wired into McpClient behind McpClientConfig, on by default. Compaction
failures are logged and the turn proceeds uncompacted, so a bug here can
never fail a turn that would otherwise have worked.

40 unit tests, including a 300-turn tool-heavy session that asserts the
history never exceeds the window and that the newest turn survives.

Refs dimensionalOS#1899

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the first-time-contributor PR opened by an author who had not previously committed to this repository label Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

first-time-contributor PR opened by an author who had not previously committed to this repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant