Context management¶
A long run grows its conversation every turn: each tool result and each reply is appended and sent again. Sooner or later it no longer fits the model's context window, or it fits but costs too much to resend. A context policy fits the conversation to a budget just before each model call, so a run can go on for hundreds of steps.
"""Context management: a 25-step run inside a deliberately small window. Runs offline."""
import asyncio
from aaa.algos import ToolLoop
from aaa.context import ContextPolicy, HeuristicCounter
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Engine
from aaa.tools import ToolHandler, ToolRegistry, tool
WINDOW = 4_000 # tokens the model can take as input (use your model's real limit)
STEPS = 25
@tool
def lookup(topic: str) -> str:
"""Look a topic up (returns a long document)."""
return f"Notes on {topic}: " + "lorem ipsum " * 150
def run_agent(request, sizes): # stands in for the real LLM
sizes.append(HeuristicCounter().count(request.messages, request.tools))
if len(sizes) <= STEPS:
return tool_call("lookup", topic=f"topic {len(sizes)}")
return text("Done: I looked up all 25 topics.")
async def run(policy):
sizes: list[int] = []
async with Engine() as engine:
LLMHandler(FakeLLMClient(lambda r: run_agent(r, sizes)), context=policy).attach(engine)
registry = ToolRegistry([lookup])
ToolHandler(registry).attach(engine)
handle = await engine.start(
ToolLoop(registry, max_turns=40), {"task": "research everything"}
)
outcome = await handle.result()
log = await engine.store.read(handle.run_id)
return outcome, sizes, log
async def main() -> None:
_, unmanaged, _ = await run(None)
summarizer = FakeLLMClient(
lambda r: text("Looked up the earlier topics; each returned long notes.")
)
policy = ContextPolicy(max_input_tokens=WINDOW, reserve_output=0, summarizer=summarizer)
outcome, managed, log = await run(policy)
print(f"without a policy the largest request was {max(unmanaged)} tokens (window {WINDOW})")
print(f"with a policy the largest request was {max(managed)} tokens")
print(f"the model was called {len(managed)} times and finished: {outcome.value['text']}")
reports = [
e.data["context"] for e in log if e.type == "aaa.llm.completed" and "context" in e.data
]
used = sorted({a.split("(")[0] for r in reports for a in r["applied"]})
print(f"compaction was applied on {len(reports)} calls, using: {used}")
print(f"summaries written to the log: {sum(1 for e in log if e.type == 'aaa.context.summary')}")
requests = [e for e in log if e.type == "aaa.llm.requested"]
print(
f"the log still holds every message: {all(len(e.data['conv']['delta']) <= 2 for e in requests[1:])}"
)
asyncio.run(main())
without a policy the largest request was 13668 tokens (window 4000)
with a policy the largest request was 3363 tokens
the model was called 26 times and finished: Done: I looked up all 25 topics.
compaction was applied on 19 calls, using: ['clear_tool_results', 'stored_summary', 'summarize']
summaries written to the log: 4
the log still holds every message: True
from aaa.context import ContextPolicy
policy = ContextPolicy(
max_input_tokens=128_000, # your model's real context window
reserve_output=4_096, # kept free for the reply
summarizer=cheap_llm_client, # optional: summarize old turns instead of dropping them
summarizer_model="small-model",
)
LLMHandler(client, context=policy).attach(engine)
How it works¶
- It is a view, not an edit. The handler keeps the logical conversation (what the algorithm and the event log hold) and gives the provider a
fitted copy. Requests in the log are still small deltas,
conv.prevstill points at the previous request, and nothing is rewritten, so replay and resume behave exactly as before. A reply'saaa.llm.completedevent carries acontextreport (tokens_before,tokens_after,applied) so you can see what was done and when. - It acts only when needed, and goes below the line. Compaction starts when the estimate passes
trigger(default 85 percent of the input budget) and stops undertarget(60 percent). That hysteresis means it runs now and then, not on every turn. - Estimates are calibrated.
HeuristicCounterestimates tokens from characters.CalibratedCounter(the default wrapper) compares each estimate with the provider's ownusage.prompt_tokensand learns the correction per model. Exact counts are available for OpenAI-family models withTiktokenCounter. If the provider still reports an overflow, the handler retries once assuming the conversation is twice as big as estimated.
Strategies¶
They run in order until the conversation fits, cheapest and least lossy first. Every one preserves three invariants: tool calls stay with their results
(providers reject orphans), the head is untouched (the system prompt, recalled memories and the original task), and the most recent turns
stay verbatim (keep_recent, default 3 turns).
| Strategy | What it does | Loses |
|---|---|---|
TruncateToolResults(max_chars=8000) |
Cuts any single huge tool result down to its start and end. | The middle of very large outputs. |
ClearToolResults(keep_last=4) |
Replaces old tool results with a placeholder; the calls stay, so the model remembers what it did. | Old tool output. |
Summarize(client, model) |
Replaces the oldest turns with a model-written, incremental summary, kept as [head] [summary] [recent turns]. Added when you pass summarizer=. |
Detail, but keeps the gist. |
DropOldest() |
Last resort: deletes the oldest turns and leaves a one-line marker. | Everything it drops. |
Pass your own list with ContextPolicy(..., strategies=[...]), or write one: a strategy is an object with name and async apply(work).
Summaries are events¶
A summary costs a model call, so it is made once and kept: the handler writes it as an aaa.context.summary event holding the text, how many messages it covers
and a hash of exactly that text. A restart, a failover to another worker, or a later request finds it in the log and reuses it, but only if the conversation's
beginning still hashes the same (so a summary can never be applied to the wrong conversation). The next summary folds the previous one in, so it stays a fixed size.
What summarizing cost counts towards the run's budget ledger.
Mind provider prompt caching
Many providers cache a conversation's unchanged prefix. Summaries are friendly to that: the head and the summary stay identical from turn to turn until the
next compaction. ClearToolResults and DropOldest move as the conversation grows and so shift the prefix each turn; prefer a summarizer for long cached runs.
Choosing numbers¶
max_input_tokensis yours to supply. AAA does not carry a table of model windows (it would go stale). Too large means overflow errors (retried once, then failed); too small wastes context.- Leave
reserve_outputat least as large as the replies you ask for (max_tokens). - Use a small, cheap model for
summarizer_model; the quality bar is "keeps names, numbers and decisions". keep_recentguards the turns the model is actively working on. Raise it if the agent loses its place.
Limits¶
- The estimate is an estimate. Calibration and the overflow retry cover most of the gap, not all of it.
- Summaries are lossy by nature. The facts a run must never lose belong in the task, the system prompt, or long-term memory, not only in the transcript.
- The policy is applied by
LLMHandler; a custom client that bypasses it is not managed. Images and other non-text content are not counted.