Compact is a ModelMiddleware that keeps each step's prompt within the model's context window by
replacing older conversation with a checkpoint: a summary in a fixed template and a record of the
newest messages. It is a port of opencode's compaction (anomalyco/opencode,
packages/core/src/session/compaction.ts), its limits, prompts and fallbacks included.
$compact = new Compact(log: $logger);
$run = new Run($model, $tools, middleware: [$compact], options: new Options(contextWindow: 200_000));new Compact(?Model $summarizer = null, bool $auto = true, int $keep = 15_000,
string|int|null $buffer = null, ?Native $native = null,
?LoggerInterface $log = null, ?State $state = null)The conversation itself (Run::messages()) never changes: each step's prompt is the checkpoint
followed by the messages after what it covers.
The ceiling is the step's Options::$contextWindow less $buffer, the room kept free: a share of
the window from "1%" to "99%" ("20%" compacts at 80 % full) or a number of tokens. Unset, or
set to the whole window or more, it keeps 15 % free, and at least 16k when the window is 32k or
more (200k: 170k; 100k: 84k; 20k: 17k). Without a window it never compacts on its own.
The prompt's size is the provider's count of the last step (its prompt and reply) plus an estimate, at 4 characters a token, of the messages added since; before the first count, or after a new checkpoint, an estimate of the whole prompt (an image counts 1500, a PDF 2000). The step whose prompt reaches the ceiling compacts first, then goes out.
A prompt the provider rejects as too long (ErrorKind::ContextTooLong) is compacted, aimed at 85 %
of its size, and sent once more; a second rejection is thrown. $auto false compacts only when
compactNext() asks; that compacts the next step whatever its size.
The model is the run's own, reached through the rest of the middleware chain, unless $summarizer
is given; a middleware after Compact that adds to every step's prompt passes the summary request
on unchanged when Compact::isSummaryRequest($prompt) says it is one (Tools\Plan\Reminder does). It gets the step's request unchanged (system prompt, tools, conversation), the same
prefix as the step itself, so it reads it from the prompt cache, with one user message appended:
You MUST summarize the conversation above into a structured summary that will be given to another
agent to resume the work.
followed by opencode's template (Objective, Requirements, Decisions, Work State with Completed,
Active and Blocked, Next Move, at most 15 Relevant Files, Important Context) and its rules: terse
single-line bullets; exact paths, symbols, commands, error strings and identifiers; only the user's
requests still open, in their words; the workflow state (uncommitted, committed, pushed, merged).
A reply in which no line is one of the template's headings gets one reminder ("The previous
response did not fill in the required summary template..."); a second such reply fails the
compaction, as does a reply cut at the output limit or refused. <think> blocks are dropped.
Its output limit is the model's (Options::$maxOutputTokens, else 32k), lowered to the room the
request leaves in the window with 15 % margin on the estimate.
One user message:
<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
## Objective
...
</summary>
<recent-context>
[User]: ...
[Assistant]: ...
[Assistant tool call]: read_file({"path":"src/a.php"})
[Tool result]: ...
</recent-context>
</conversation-checkpoint>
The record holds the newest $keep tokens of the conversation, from a user message on, so a call
stays with its result; each tool output is cut at 1250 characters and reasoning is left out. When
everything would fit, only the latest exchange is kept; a single exchange is summarized whole and
nothing is kept. The next compaction summarizes the checkpoint and everything since, the prompt
asking to update it ("Newer history always takes precedence over the existing checkpoint..."), and
keeps a new record.
Past the ceiling the whole conversation still goes out unchanged while the window has room for the
summary. Otherwise the conversation before the record's messages is sent, unchanged when it fits,
else as one text message behind the previous summary: a transcript without tool definitions,
shortened oldest exchange first, each exchange one level before any goes further: tool outputs cut
to their first and last 1000 characters; tool outputs left out and tool inputs cut; only the
user's words; the exchange omitted, counted in a note ("[2 older exchanges omitted]"). The newest
exchange is never omitted. A summary request the model rejects as too long is retried as text,
aimed at 85, 70, 50 and 35 % of its size. After that, or when even the newest exchange cannot fit,
the compaction fails with a \RuntimeException, never the step's ContextTooLong.
$responses = new Responses($transport, 'https://api.openai.com/v1', 'gpt-x', $key);
$compact = new Compact(native: $responses);With a Native model (Provider\OpenAI\Responses) the provider compacts instead: the step's
request goes out with a compaction_trigger item, and the one encrypted compaction item that
comes back stands for all of it, behind the newest user messages within $keep tokens (without
their tool results). The item is a Part\Compacted in an assistant message; only the same model
reads it, and every other provider rejects it. Its size is unknown until the provider counts it, so
the next compaction waits for that count. A request too long to compact is retried smaller as
above.
One instance may serve many conversations (every fork of an Agents\AgentTool template): it keeps
the latest 8 checkpoints and provider counts, each keyed by a content hash of the messages it
covers. state() is a JSON value; a host that builds a Compact per request persists
$compact->state()->toArray() next to the conversation and passes State::fromArray() to the next
one, which sends the checkpoint it has without compacting again.
last() is the last compaction: its reason (auto, overflow, manual), how many messages it
covered, the prompt's size before and after, the summary and recent record, or native.
The log has one info line per compaction and a warning per rejection.