Skip to content

Conversation compaction

A conversation that runs long has to be made smaller, and the honest way to do it is to replace the older turns with a summary. Prose is what a summary is allowed to lose.

Provenance is not. A span of turns that each cited a policy, replaced by three sentences carrying no citations, reads as claims the agent made up itself — and the person holding the answer cannot check any of them. That is not a smaller conversation, it is a conversation whose sources were deleted.

The shape

from tesserix_adk.runtime import cited, compact_conversation

turn = cited(message, citation_ids)          # sources travel on the message

done = await compact_conversation(
    history,
    summarise=summariser,                    # writes the replacement message
    threshold_tokens=8_000,
    keep_recent=4,
)

done.history      # what to send
done.ran          # whether anything was folded
done.citations    # the ids carried across
done.event        # what to write down, or None

compact_conversation runs above the threshold and does nothing below it, so calling it every turn is the intended use.

Provenance is checked, not requested

cited(message, ids) puts the citation ids in the message's own metadata under adk.citations, because a history is what gets persisted, replayed and handed to a provider, and a parallel list of sources is what does not survive that. citations_of reads them back.

The Summariser is handed the turns and returns the replacement message, so it can carry across whatever else its messages hold. What is not left to it is the provenance: every id carried by a folded turn must be on the message that replaces them. Where one is missing, compact_conversation raises ProvenanceLostError naming the ids it would have dropped, and the history is returned to the caller exactly as it was. There is no partial result and no summary emitted with a lost source.

Summarisation quality is the summariser's business. This module has an opinion about exactly one thing.

The prefix is untouched

Compaction is a conversation-layer operation. The system prompt, the tool declarations and the pinned context — the cacheable prefix — are not inputs to it, so assemble_prompt produces the same fingerprint before and after and no cache downstream is refilled. Dropping the prefix to save tokens is a cost dressed as a saving; see docs/context-assembly.md.

Running it twice

The summary is marked adk.compacted. A second pass over an already-compacted conversation folds nothing: where the only foldable span is a previous summary, there are no tokens to win and a little fidelity to lose each time. Once new turns accumulate above the threshold, the next pass folds the earlier summary in with them, and its citations carry forward like any other turn's.

What it records

Compaction.event is a CompactionEvent — the run, how many turns were folded, the ids carried, and the token count before and after. event.attributes() gives the span attributes under adk.compaction.*: counts only, never the conversation. A pass that folded nothing records nothing, because an audit trail of non-events is one nobody reads.

Known limitations

Summarisation quality, and the choice of which model writes it, are out of scope. So is resolving a citation: this module carries ids and checks they survive — resolving one back to a document version and span is docs/citations.md.