Keep a long conversation inside the window
Off by default. AgentSpec.compaction is None, and a conversation grows until the provider refuses it.
Off by default. AgentSpec.compaction is None, and a conversation grows until
the provider refuses it.
spec = psych_runtime.AgentSpec(
name="support",
model=psych_runtime.ModelRef(model="gpt-4o"),
compaction=psych_runtime.CompactionPolicy(
trigger_tokens=96_000, # required, no default
keep_recent_turns=3,
model="gpt-4o-mini", # None means the agent's own
max_summary_tokens=2_048,
summary_instructions=None,
),
)It lives on the Spec rather than the Runtime because an agent that summarises its own history is a different agent: what the model is shown on a long Run is not the same conversation. That difference belongs in the Version hash, not in a flag a Runtime could change under a published Version.
Compaction never loses anything
The replaced records stay in the log. What compaction produces is a
CompactionApplied Record naming the range it replaced and the summary standing
in for it. The report, the trace and the audit trail are untouched.
Compaction changes what the model sees next. It never changes what happened.
trigger_tokens is required, and fires one turn late
There is no default because Psych has no context window to compare against. The
ModelClient port speaks to a proxy that can reach models it has never been
told about; known_models returns empty for exactly that reason. Any default
would be a guess about somebody else's model. You know which model you pointed
this at and what fraction of its window you want to spend.
It is measured from Usage, which the provider reports on every call, not
estimated by a tokenizer. Psych takes no tokenizer dependency: one tokenizer per
provider is a maintenance burden, and an estimate that looks like a measurement
is worse than no number at all.
The number compared is input + cache_read + cache_write of the last finished
call, which is the size of the prompt actually sent. Cached input still occupies
the window.
The consequence: the trigger is read one call late. A Run compacts on the
turn after the one that crossed the line. So set trigger_tokens far enough
below the real window that one more turn fits. A real number one turn late beats
an invented one on time, and that trade is deliberate.
The cut always lands on a turn boundary
A turn is one model call and the tool results it produced, which is what the
log's turn_started boundaries mark. keep_recent_turns (default 3) stays
verbatim below the summary.
Cutting anywhere else could put an assistant message's tool calls above the line and their results below it, and a provider rejects a conversation whose tool calls have no answers. Cutting on a boundary makes that shape unrepresentable rather than merely unlikely.
Crash safety
The summary is requested first, the record appended second, and the next model call third. That ordering makes both crash directions converge:
- Died after the summary call, before the append. No record. The next Worker recomputes the same cut, finds it still ahead of the boundary, and summarises again. One model call is paid for twice; nothing is compacted twice.
- Died after the append. The boundary is at the cut. The next Worker recomputes the same cut, finds it no longer ahead, and does not summarise.
Appending first and filling the summary in later would leave a boundary with nothing standing in for the range it replaced, and a replay would send the model a conversation with a hole in it.
The summariser is told what it is doing, not who the agent is
The summarisation request carries its own system message rather than the
agent's. A summariser is doing a bounded, mechanical read of a transcript.
Handing it the agent's persona spends tokens on instructions it must not follow
and invites it to answer the conversation instead of summarising it. That is
also why CompactionPolicy.model exists: this call can go to a cheaper model.
summary_instructions adds, never replaces
Psych's own instruction always applies: keep what was asked and the constraints on it, facts established by tool results, decisions and why, what is done and what is outstanding; preserve identifiers, numbers and quoted text exactly; drop pleasantries and superseded attempts.
That is the floor and yours cannot lower it, which is the point. You know what your domain cannot afford to lose, and nobody knows in advance what a summariser will decide was pleasantry. Yours is placed last, where a model weighs it most heavily. Caps at 4000 characters.
Reading it back
report = await psych_runtime.report(store, run_id)
for c in report.compactions:
print(c.seq, c.reason, c.replaced_from_seq, c.replaced_to_seq, c.usage, c.cost)reason is "threshold", "manual" or "overflow". The summariser's own
tokens and cost are reported, so compaction is not free and does not hide.
report.totals.latency.compaction_seconds accounts for its time.
Gotchas
max_summary_tokensdefaults to 2048 and caps at 32000. A summary allowed to run as long as the conversation it replaces saves nothing.CompactionPolicy.modelis checked at publish against the ModelClient's known models, the same as the agent's own model.- Compaction is not
Limits.max_history_records. That bounds how much of a multi-Run conversation chain is replayed. This replaces the middle of one Run's conversation with a summary.
Pause a run and resume it later
A Run that needs something outside itself suspends: it releases its lease, persists, and waits. Nothing is held in memory. The process that resumes it need not be the one that...
Stream results and reconnect
The log is the stream. Every Record has a sequence number and is persisted before it is delivered. There is no separate stream state, which is why an interrupt cannot break...