diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index cb959b055a..20782ea5b0 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -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)) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index d47be3fa50..00e48f0b6b 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -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']) }) diff --git a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts index 65ec74c486..9b58a2e8d9 100644 --- a/packages/core/agent-loop/tests/system-prompt-projection.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-projection.spec.ts @@ -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'))