Status: Implemented. Authoritative user docs: context-assembly.md, agent.md, plugin-bindings.md.
Working memory: what it is, and unifying its compaction story¶
Working memory is the conversation buffer an agent builds during a run. Compaction shrinks old history so it fits the model's context window. This note explains how the two relate and why one config surface controls compaction.
Compaction is context assembly, not a working-memory cache. spec.working_memory.compaction writes spec.context_manager when that slot is omitted. The summarising context manager's hysteresis is the cache. It reuses the existing summary until the managed history exceeds the budget by hysteresis_ratio (default 0.1, that is 10%).
What working memory is: one buffer, two phases¶
Each agent has one accumulating conversation buffer, not two. Think of it as two phases of one pipeline.
- In-flight (uncommitted) phase.
WorkingMemoryStore(runtime/src/mas/runtime/boundary/context/working_memory.py) holds it. While a turn's ReAct loop runs, every tool call and tool result of THIS turn is appended here. The model needs to see its own uncommitted tool trajectory before the turn ends. - Committed phase.
AutoCtxAssembler.committed_messagesandturn_history(runtime/src/mas/runtime/driver/mocks.py) hold it. At the end of each turn,note_agent_response()copies the in-flight buffer intocommitted_messages, then clears the in-flight buffer. It then applies the context manager's recency cap, so the stored log cannot grow without bound.
A model request therefore has three pieces, in this order:
- The system prompt (
injected_context). It is rebuilt from the manifest every time and never accumulates. committed_messages: the recency-capped record of recent user turns, tool calls, tool results, and assistant replies.- Whatever the current turn has produced but not yet committed.
assemble_llm_messages() stitches these three pieces into the message list sent to the LLM.
spec.working_memory.persistent governs the committed phase only. A delegated agent falls back on it across separate delegate_to_<agent> calls. WorkingMemoryRegistry keys it by (session_id or context_id, agent_id) (runtime/src/mas/runtime/boundary/context/working_memory_registry.py).
_finalize_turn (ctl/src/mas/ctl/session/controller.py) folds the in-flight buffer into committed_messages whenever there is a response or working memory to fold. This includes a turn paused for HITL (human in the loop), for example an approval gate on a tool call. Already-executed tool calls are therefore not lost when the next turn's note_user_input() clears working memory. Checkpointing still happens only for a turn that completed. See ctl/tests/test_session_controller_finalize_turn.py.
The problem: two overlapping compaction surfaces¶
docs/schemas/runtime/agent.schema.yaml once had two places that both claimed to control how much history is kept.
-
spec.context_manager.params:max_turnsorwindow_size,max_messages,summary_threshold,keep_turns(docs/schemas/runtime/fragments/context-manager-params.schema.yaml). This surface is real.CMFactory.create(manifest=manifest)(runtime/src/mas/runtime/contracts/cm_factory.py) resolves it through the plugin registry to one of three working, tested strategies inlibrary-standard/src/mas/library/standard/plugins/context/conversation.py:StackConversationcaps total messages.SlidingWindowConversationcaps by turn-pair count.SummarizingConversationcompresses older turns into a summary system block and keeps the last N verbatim.
The assembler plugin calls
cm.manage_history(past, budget_hint)on every turn.pastis the committed history.budget_hintis the model context window minus the completion reserve.spec.context_manager.params.trimmercan override it. context-assembly.md covers the assembly-time token trim (tool-group-aware, with the live working-memory round pinned at the tail). 2.spec.memory.compaction(formerly inagent.schema.yaml): a richer, schema-only surface. It offeredstrategy: keep_recent|summarize|chunked_summarize,keep_recent_ratio,min_chunk_ratio,safety_margin,identifier_preservation, andsummarize_instructions. Nothing in the codebase read it. A repo-wide grep forchunked_summarizeand for any consumer ofspec.memory.compactionfound only the schema file.
Two surfaces claimed the same job, and only one worked. flavour-boundary.md calls out the same kind of drift in other subsystems.
SummarizingConversation needs no summarizer to run. summarize_fn is optional. With a drop summarizer, or llm with no live engine, it keeps the last keep_turns user turns verbatim and discards older ones.
Decision: one config surface, spec.context_manager¶
spec.working_memory.compaction is sugar that writes that slot. Compaction policy is manage_history. The LLM view is bounded at assemble time. After each turn commit, the same recency cap is written back to the committed log, and the folded prefix is dropped. The cache for in-turn LLM calls is hysteresis on the context-manager instance.
-
working_memory.compactiontranslates tocontext_manager:working_memory: persistent: true compaction: strategy: keep_recent # keep_recent | sliding_window | summarize max_messages: 200 # keep_recent → stack window_size: 20 # sliding_window summary_threshold: 0 # summarize: 0 uses model context_window − reserve keep_turns: 10 # summarize: recent user turns kept verbatimMapping:
keep_recenttostack,sliding_windowtosliding_window,summarizetosummarising. Prefer writingspec.context_managerdirectly. If both are set,context_managerwins. -
Summarizer sub-plugins (registry type
summarizer):llm(default) uses the agent's engine throughCMFactory.create(..., engine=ctx.engine).- An omitted or
anyparams.modeluses the summarizer slot (Agent, then MAS, thenexperiment.models.summarizer), else the turn model. - An optional
params.model(aspec.models[].idor a LiteLLM string) uses a different model for the summary call. - An optional
params.instructionsreplaces the package system prompt. The trigger is still the token budget. dropdiscards older turns.- The spec does not store the engine.
See summarization.md. Example: summarizer-override.
-
Package default when both slots are omitted:
context_manager: summarisingwithsummarizer: llm(see plugin-bindings.md). This is not unbounded stack history. -
spec.memory.compactionis deleted. Nothing wired it.
Tests:
library-standard/tests/test_working_memory_compaction.py: facade translation,build_llm_summarize_fn, and realCMFactoryinstantiation.ctl/tests/test_working_memory_compaction_bootstrap.py: end to end throughinstantiate_runtime().
Non-goals¶
- Working memory that persists across processes or restarts (
spec.memory.persistence). This is a separate concern. - Compacting the semantic and episodic retrieval-store memory layers (
spec.memory.types[]). That is a different subsystem (MemoryContract) with its own lifecycle. chunked_summarize,identifier_preservation, andsafety_marginfrom the oldmemory.compactionshape. No evidence of demand beyond the unimplemented schema.