refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam

Reform the compaction blueprint so a runaway turn survives and the design
stops drifting across review rounds:

- Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head
  whole-unit walk; the only structural guard is step-alignment. A single turn
  that alone exceeds the window now compacts its own early closed steps instead
  of being retained verbatim (the failure mode that motivated this).
- Move auto-compaction off the agent/request waterfall onto a new awaited
  agent/pre-request loop seam, fired before history derivation. Compaction
  mutates the surface; the loop derives once from the result — no double-derive,
  and a listener structurally cannot act on not-yet-derived messages.
- Tighten compactIfNeeded to required (session, system, model, signal).
- Enforce a single-pass convergence invariant in resolveConfig: reject configs
  where summarizationMaxTokens + retainTokens exceeds the threshold, so a
  compaction can never immediately re-trigger.
- Document the crash vs recoverable failure taxonomy; core session repair stays
  compaction-agnostic (a log-only orphaned compact/start is inert).
- Wire dsh-compact-basic into examples/coding-agent and add a with-key
  compaction e2e (compaction's first real-world exercise + runaway net).
- Rewrite the RFC to encode the blueprint and move it to implemented/.

The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot
yet serve the interleaved summarization model call.
This commit is contained in:
Hypatia May
2026-06-26 08:59:33 +08:00
parent aa9afcefc7
commit cec32faa4e
22 changed files with 724 additions and 424 deletions

View File

@@ -320,6 +320,65 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.model).toBe('other-model')
})
it('agent/pre-request fires once per step before the request is derived', async () => {
// Two steps (a tool call, then a final text turn) → two model calls → two
// pre-request fires, each carrying the assembled system + model, BEFORE the
// request messages are derived (the request the adapter sees reflects any
// surface state at fire time).
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const fires: { turn: number; step: number; model: string }[] = []
ctx.on('agent/pre-request', (subject, turn, step, _system, model) => {
if (subject === agent) fires.push({ turn, step, model })
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// One fire per step, in order, each with the agent's model.
expect(fires).toEqual([
{ turn: 1, step: 1, model: 'mock' },
{ turn: 1, step: 2, model: 'mock' },
])
})
it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => {
// pre-request fires BEFORE deriveMessages(), so a listener that appends a
// surface node there sees it land in the SAME step's request — proving the
// loop derives once, after the checkpoint, with no stale pre-derive.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let injected = false
ctx.on('agent/pre-request', (subject, turn) => {
if (subject === agent && !injected) {
injected = true
subject.session.append('context/message', {
content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
void turn
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// The adapter's request includes the node injected during pre-request.
const text = JSON.stringify(adapter.requests[0]!.messages)
expect(text).toContain('INJECTED-IN-PRE-REQUEST')
})
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)