fix(agent-loop): keep the prompt prefix at a series start with unchanged text
An in-history route carries prompt updates as later system nodes while node 0 stays stale. A surface replacement (compaction, tool-result prune, image offload) starts a new request series, and the projection used to re-baseline node 0 even when the rendering already matched the latest surviving node: it emptied that node and wrote identical text into node 0, so the request differed at its first message and the provider prefix cache missed from the first token (#6973). Re-baseline node 0 only when the effective text must move: an incapable route, an empty rendering, or a series start whose latest surviving non-empty node differs from the rendering. A capable series start with matching text emits no system event; the loop still logs the request series and rebuilds declarations. Retry compaction still folds the prompt to the head when it shadows the latest node.
This commit is contained in:
1 parent
71d5454904
commit
2676c67f32
3 files changed
+57
-12
No files matched your search
@@ -58,9 +58,12 @@ function eventsNewestFirst(session: Session): readonly SessionEvent[] {
|
||||
* Decides how a rendered system prompt reaches the surface without owning the
|
||||
* commit. The first prompt, even empty, reserves surface node 0.
|
||||
* A capable continuing series appends changed nonempty text after the
|
||||
* cached history. An incapable route, broken series, or cleared prompt instead
|
||||
* normalizes the first system node and empties later active nodes. Dormant empty
|
||||
* tails do not supply effective text or require repeated replacements.
|
||||
* cached history. An incapable route, a cleared prompt, or a new series whose
|
||||
* effective text differs instead normalizes the first system node and empties
|
||||
* later active nodes. A series start whose latest surviving node already holds
|
||||
* the rendering emits nothing: moving the same text to node 0 would change the
|
||||
* request prefix without changing the prompt. Dormant empty tails do not supply
|
||||
* effective text or require repeated replacements.
|
||||
*/
|
||||
export class SystemPromptProjection {
|
||||
constructor(private readonly session: Session) {}
|
||||
@@ -81,6 +84,8 @@ export class SystemPromptProjection {
|
||||
|
||||
/**
|
||||
* Reconcile effective text and retained nodes with the prepared route and series.
|
||||
* A series start re-baselines node 0 only when the effective text differs; a
|
||||
* capable continuing series keeps appended nodes in place to preserve the prefix.
|
||||
* @param rendered - the fully rendered system prompt; `''` when none is active.
|
||||
* @param input - the route capability and series facts for this step.
|
||||
* @returns ordered per-node updates; an empty list means no update is needed.
|
||||
@@ -92,7 +97,7 @@ export class SystemPromptProjection {
|
||||
return [{ message: createSystemMessage(rendered), intent: { surfaceOp: 'append' } }]
|
||||
}
|
||||
const latest = nodes.findLast(node => node.text !== '') ?? head
|
||||
if (!input.inHistory || input.startsSeries || rendered.length === 0) {
|
||||
if (!input.inHistory || rendered.length === 0 || (input.startsSeries && latest.text !== rendered)) {
|
||||
const updates = nodes.slice(1).filter(node => node.text !== '')
|
||||
.map(node => this.replace(node.seq, ''))
|
||||
if (head.text !== rendered) updates.push(this.replace(head.seq, rendered))
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('prepared-route prompt admission', () => {
|
||||
expect(request.messages.filter(message => message.role === 'developer')).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['explicit', 'replacement'] as const)('rebuilds prompt and declarations at a %s series start', async (reason) => {
|
||||
it.each(['explicit', 'replacement'] as const)('rebuilds declarations without re-baselining an unchanged prompt at a %s series start', async (reason) => {
|
||||
const h = await harness()
|
||||
h.capable.toolUpdate = toolUpdate
|
||||
await send(h.agent, 'first')
|
||||
@@ -144,7 +144,13 @@ describe('prepared-route prompt admission', () => {
|
||||
await send(h.agent, 'add fetch')
|
||||
|
||||
const request = h.capable.requests.at(-1)!
|
||||
expectPlain(request, 'prompt two')
|
||||
const previous = h.capable.requests.at(-2)!
|
||||
// The rendering is unchanged: the surviving prompt node keeps its place and the new series
|
||||
// only rebuilds declarations. Rewriting node 0 would invalidate the provider prefix.
|
||||
expect(systemTexts(request)).toEqual([
|
||||
[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }],
|
||||
])
|
||||
expect(request.messages[0]).toEqual(previous.messages[0])
|
||||
expect(request.tools?.find(tool => tool.name === 'fetch')?.deferLoading).not.toBe(true)
|
||||
expect(request.messages.filter(message => message.role === 'developer')).toEqual([])
|
||||
const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)
|
||||
@@ -217,7 +223,7 @@ describe('prepared-route prompt admission', () => {
|
||||
expect(JSON.stringify(adapter.requests.at(-1)!)).not.toContain('prompt ')
|
||||
})
|
||||
|
||||
it.each(['explicit', 'tools'] as const)('normalizes surviving prompt versions with unchanged text at a %s series start', async (reason) => {
|
||||
it.each(['explicit', 'tools'] as const)('keeps surviving prompt versions with unchanged text at a %s series start', async (reason) => {
|
||||
const h = await harness()
|
||||
await send(h.agent, 'first')
|
||||
h.setPrompt('prompt two')
|
||||
@@ -234,10 +240,21 @@ describe('prepared-route prompt admission', () => {
|
||||
}))
|
||||
}
|
||||
await send(h.agent, 'third')
|
||||
expectPlain(h.capable.requests[2]!, 'prompt two')
|
||||
const previous = h.capable.requests[1]!
|
||||
const request = h.capable.requests[2]!
|
||||
// The rendering is unchanged, so the new series keeps the surviving prompt node: the whole
|
||||
// previous request stays a wire prefix instead of moving the prompt into message 0.
|
||||
expect(systemTexts(request)).toEqual([
|
||||
[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }],
|
||||
])
|
||||
expect(request.messages.slice(0, previous.messages.length)).toEqual(previous.messages)
|
||||
const header = h.agent.session.snapshotEvents().filter(event => event.type === 'request/header').at(-1)
|
||||
expect(header?.data.reason).toBe(reason === 'explicit' ? 'series' : 'change')
|
||||
if (reason === 'tools') expect(header?.data.startsSeries).toBe(true)
|
||||
if (reason === 'tools') {
|
||||
// A tool-schema change starts the prompt series without a system event; the header records
|
||||
// the declaration change and tool history falls back to the full active set.
|
||||
expect(request.tools?.map(tool => tool.name)).toContain('extra')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the cached prefix at a compaction series start when the prompt text is unchanged', async () => {
|
||||
@@ -378,9 +395,15 @@ describe('prepared-route prompt admission', () => {
|
||||
return next()
|
||||
})
|
||||
await send(resumed, 'resume')
|
||||
expect(systemTexts(h.capable.requests[2]!)).toEqual(replace
|
||||
? [[{ type: 'text', text: 'prompt two' }]]
|
||||
: [[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }]])
|
||||
const previous = h.capable.requests[1]!
|
||||
const request = h.capable.requests[2]!
|
||||
// The first resumed pre-step replacement still starts a series, but an unchanged rendering
|
||||
// keeps both prompt versions in place instead of re-baselining node 0.
|
||||
expect(systemTexts(request)).toEqual([
|
||||
[{ type: 'text', text: 'prompt one' }], [{ type: 'text', text: 'prompt two' }],
|
||||
])
|
||||
expect(request.messages[0]).toEqual(previous.messages[0])
|
||||
if (!replace) expect(request.messages.slice(0, previous.messages.length)).toEqual(previous.messages)
|
||||
expect(resumed.session.snapshotEvents().filter(event => event.type === 'request/header').map(event => event.data.reason))
|
||||
.toEqual(['initial', 'resume'])
|
||||
})
|
||||
|
||||
@@ -177,6 +177,23 @@ describe('SystemPromptProjection', () => {
|
||||
.toEqual([{ type: 'text', text: 'v1' }, { type: 'text', text: 'v2' }, { type: 'text', text: 'v3' }])
|
||||
})
|
||||
|
||||
it('keeps the surviving prompt node at a series start when its text already matches', async () => {
|
||||
const ctx = await sessionStore()
|
||||
const session = ctx.sessions.create(SessionId('system-prompt-series-start-current'))
|
||||
const projection = new SystemPromptProjection(session)
|
||||
const head = commit(session, 1, projection.project('v1', CONTINUING)[0])
|
||||
appendUser(session, 'hello')
|
||||
const appended = commit(session, 2, projection.project('v2', CONTINUING)[0])
|
||||
|
||||
// The effective text is already current: re-baselining node 0 would move the same
|
||||
// text into the request's first message and invalidate the provider prefix.
|
||||
expect(projection.project('v2', NEW_SERIES)).toEqual([])
|
||||
expect(session.surface.nodes[0]).toBe(head.seq)
|
||||
expect(session.surface.nodes).toContain(appended.seq)
|
||||
expect(session.deriveMessages().flatMap(message => message.role === 'system' ? [message.content[0]] : []))
|
||||
.toEqual([{ type: 'text', text: 'v1' }, { type: 'text', text: 'v2' }])
|
||||
})
|
||||
|
||||
it('re-baselines node 0 and empties later active nodes at a series start', async () => {
|
||||
const ctx = await sessionStore()
|
||||
const session = ctx.sessions.create(SessionId('system-prompt-series-start'))
|
||||
|
||||
Reference in new issue
Block a user