From e6407477a719a7369cc469bd548180bf9d882f5f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:08:45 +0800 Subject: [PATCH] fix(agent-loop): clear compacted runtime context --- packages/core/agent-loop/src/agent.ts | 38 +++++++++----- packages/core/agent-loop/tests/loop.spec.ts | 56 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 94a3b7c667..a2e3c8c156 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -58,25 +58,37 @@ type StepOutcome = const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt' const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' -/** Latest retained cache-safe runtime-context snapshot, excluding compacted-away history. */ -function retainedRuntimeContext(session: Session): string | undefined { - for (const message of [...session.deriveMessages()].reverse()) { - if (message.role !== 'user' - || message.source.kind !== 'plugin' - || message.source.plugin !== RUNTIME_CONTEXT_SOURCE) continue - const [block] = message.content - if (message.content.length === 1 && block?.type === 'text') return block.text - return undefined +/** Whether one user message is owned by runtime-context materialization. */ +function isRuntimeContextMessage(message: UserMessage): boolean { + return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE +} + +/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */ +function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } { + const events = session.events + const nodes = session.surface.nodes + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const event = events[nodes[index] as number] + if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue + const [block] = event.data.content + return { + found: true, + text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined, + } } - return undefined + return { found: false, text: undefined } } /** Append a full current snapshot only when it changed or compaction removed it. */ function materializeRuntimeContext(session: Session, current: string): void { const previous = retainedRuntimeContext(session) - if (previous === undefined && current.length === 0) return + if (!previous.found && current.length === 0) { + const compactedPriorSnapshot = session.surface.replaceGeneration > 0 + && session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data)) + if (!compactedPriorSnapshot) return + } const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current - if (previous === snapshot) return + if (previous.text === snapshot) return session.append('user/message', createUserMessage({ content: [{ type: 'text', text: snapshot }], source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE }, @@ -550,7 +562,7 @@ export class ReactLoopAgent implements Agent { this.drainOutbox(turn) // Assemble request-owned prompt inputs fresh each step. Dynamic context is - // committed at the tail before deriving history, preserving the stable + // committed at the tail before deriving history once, preserving the stable // system/history cache prefix while keeping every model-visible byte logged. const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 499756f97e..a362db8552 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -355,6 +355,62 @@ describe('agent loop', () => { && message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true) }) + it('clears compacted runtime context after the active set becomes empty', async () => { + const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) + const ctx = await harness(adapter) + const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' }) + const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + const contextEvent = agent.session.events.find(event => + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') + if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context') + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary retaining old mode: read-only' }], + source: { kind: 'plugin', plugin: 'test-compaction' }, + }), { + surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq }, + sourceEventSeqs: [contextEvent.seq], + }) + dispose() + + send(agent, 'after compaction') + await waitForIdle(ctx, agent) + const clearing = adapter.requests[1]?.messages.find(message => + message.source.kind === 'plugin' + && message.source.plugin === '@deepseek-ai/dsh-system-prompt') + expect(clearing?.content).toEqual([{ + type: 'text', + text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.', + }]) + }) + + it('does not clear runtime context after an unrelated replacement', async () => { + const adapter = new MockAdapter([textResponse('ok')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' }) + const original = agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'old context' }], + source: { kind: 'plugin', plugin: 'test-context' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'test-compaction' }, + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + }) + + send(agent, 'after compaction') + await waitForIdle(ctx, agent) + expect(adapter.requests[0]?.messages.some(message => + message.source.kind === 'plugin' + && message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) + }) + it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter)