fix(agent-loop): compose the session prefix before pre-step; hand it to the pressure gate
ds-review-bot critical (follow-up): on the first step of a resumed or seeded/forked instance, auto-compaction ran before runStep composed this instance's prefix, so the gate read the PREVIOUS instance's logged prefix from the header fold — a contributor that grew across resume/fork (skills added, AGENTS.md grown: exactly the environment-dependent case) could under-gate and ship an over-window first request. The loop now composes agent/session-prefix before the instance's first agent/pre-step (still once per instance; runStep just reads the cache), and agent/pre-step carries the composed prefix to its listeners. CompactService.compactIfNeeded gains the sessionPrefix parameter; BasicCompactService.estimatePressure gates on the handed value — the header-fold read is gone, so the estimate is exact at every step including a resumed/forked instance's first. Composition moving before the boundary snapshot also means a composing listener's session append now joins the CURRENT request (documented on the seam). New coverage: composition precedes pre-step and the seam receives the composed prefix; cancel and disposal landing inside the composition window drop the step cleanly; the compact gate test hands the prefix directly.
This commit is contained in:
@@ -557,27 +557,22 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('counts the logged session prefix toward pressure (every request carries it in front of the history)', async () => {
|
||||
it('counts the session prefix toward pressure (every request carries it in front of the history)', async () => {
|
||||
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 10 })
|
||||
const session = multiTurnSession(3, 1) // 6 derived messages ≈ 84 estimated tokens — under the 100 threshold alone
|
||||
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
|
||||
|
||||
// The loop records the composed agent/session-prefix product on the
|
||||
// request header; it rides every request, so pressure must include it.
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { model: 'm' },
|
||||
messagePrefix: [
|
||||
{ role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] },
|
||||
],
|
||||
},
|
||||
reason: 'initial',
|
||||
})
|
||||
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL)
|
||||
// The loop composes the agent/session-prefix product before the pre-step
|
||||
// seam and hands it to the gate; it rides every request, so pressure must
|
||||
// include it — the same history now crosses the threshold.
|
||||
const sessionPrefix: Message[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: `opener one.${LONG_FIXTURE_TEXT}` }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: `opener two.${LONG_FIXTURE_TEXT}` }] },
|
||||
]
|
||||
const result = await compactIfNeeded(svc, session, '', 'm', SIGNAL, sessionPrefix)
|
||||
expect(result).not.toBeNull()
|
||||
// The prefix itself is NOT history: compaction shadowed surface nodes only.
|
||||
expect(session.requestHeader()?.messagePrefix).toHaveLength(2)
|
||||
expect(sessionPrefix).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns the first compaction result when a zero-retry pass converges after the loop', async () => {
|
||||
@@ -1005,8 +1000,9 @@ function compactIfNeeded(
|
||||
fullSystemPrompt: string,
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
sessionPrefix: readonly Message[] = [],
|
||||
) {
|
||||
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, signal)
|
||||
return svc.compactIfNeeded(stubAgent(session, model), fullSystemPrompt, sessionPrefix, signal)
|
||||
}
|
||||
|
||||
function compactRegion(
|
||||
@@ -1174,7 +1170,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
|
||||
describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => {
|
||||
/** Fire the agent/pre-step serial checkpoint as the loop does. */
|
||||
function firePreStep(ctx: Context, agent: Agent, step: number, fullSystemPrompt: string): Promise<unknown> {
|
||||
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, SIGNAL)
|
||||
return ctx.serial('agent/pre-step', agent, 1, step, fullSystemPrompt, [], SIGNAL)
|
||||
}
|
||||
|
||||
it('compacts (mutating the surface) when over threshold', async () => {
|
||||
@@ -1276,7 +1272,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
|
||||
const session = multiTurnSession(5, 1)
|
||||
const agent = stubAgent(session, 'agent-model')
|
||||
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||
|
||||
expect(adapter.lastOptions?.model).toBe('routed-model')
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||
@@ -1415,7 +1411,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const session = multiTurnSession(4, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||
// The surface was mutated; the head message is the framed summary checkpoint.
|
||||
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||
@@ -1495,7 +1491,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const before = session.surface.nodes.length
|
||||
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', SIGNAL)
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, '', [], SIGNAL)
|
||||
// The failure was swallowed; the surface is untouched and a warning logged.
|
||||
expect(session.surface.nodes.length).toBe(before)
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
|
||||
@@ -1512,7 +1508,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
|
||||
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, SIGNAL)
|
||||
await ctx.serial('agent/pre-step', agent, 1, 1, bigSystem, [], SIGNAL)
|
||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
expect(svc.summarizeCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user