fix(compact): preserve replay ownership (PR2 round 2)

This commit is contained in:
Hypatia May
2026-07-15 15:13:57 +08:00
parent f038780ff6
commit 99dccf559a
10 changed files with 154 additions and 29 deletions

View File

@@ -13,7 +13,7 @@ This backend owns the compaction policy:
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.

View File

@@ -159,11 +159,12 @@ export class BasicCompactService extends CompactService {
/**
* Compact one inclusive positional surface range using the effective
* conversation model for all retention and shrink pricing.
* @param session - session whose surface is mutated.
* conversation model for all retention and shrink pricing. Reject an agent
* that does not own the exact target before any resolution or mutation.
* @param session - session whose surface is mutated; must equal `agent.session`.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer and model resolver.
* @param agent - owner of the target session, used by the summarizer and model resolver.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -174,6 +175,9 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
if (session !== agent.session) {
throw new Error('compactRegion: agent.session must be the exact target session')
}
const model = effectiveModel(agent)
if (model === undefined || model.length === 0) {
throw new Error('compactRegion: no routed or configured conversation model is available for token pricing')

View File

@@ -361,6 +361,26 @@ describe('pressure measurement and retention', () => {
})
describe('compaction region transaction', () => {
it('rejects an agent that does not own the exact target session before mutation', async () => {
const compact = service()
const target = conversation(2)
const owner = conversation(1)
const targetEvents = [...target.events]
const ownerEvents = [...owner.events]
const nodes = target.surface.nodes
await expect(compact.compactRegion(
target,
nodes[0]!.seq,
nodes[1]!.seq,
agent(owner),
)).rejects.toThrow('compactRegion: agent.session must be the exact target session')
expect(target.events).toEqual(targetEvents)
expect(owner.events).toEqual(ownerEvents)
expect(compact.calls).toEqual([])
})
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
const compact = service()
const session = conversation(3)