Skip to content

Latest commit

 

History

History
129 lines (101 loc) · 6.54 KB

compact.md

File metadata and controls

129 lines (101 loc) · 6.54 KB

Harness\Compact

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.

When it compacts

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 summary

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.

The checkpoint

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.

A summary request too large for the window

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.

Native compaction

$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.

State

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.

Seeing it

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.