fix(agent-loop): clear compacted runtime context
This commit is contained in:
@@ -58,25 +58,37 @@ type StepOutcome =
|
|||||||
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||||
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
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. */
|
/** Whether one user message is owned by runtime-context materialization. */
|
||||||
function retainedRuntimeContext(session: Session): string | undefined {
|
function isRuntimeContextMessage(message: UserMessage): boolean {
|
||||||
for (const message of [...session.deriveMessages()].reverse()) {
|
return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE
|
||||||
if (message.role !== 'user'
|
}
|
||||||
|| message.source.kind !== 'plugin'
|
|
||||||
|| message.source.plugin !== RUNTIME_CONTEXT_SOURCE) continue
|
/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */
|
||||||
const [block] = message.content
|
function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } {
|
||||||
if (message.content.length === 1 && block?.type === 'text') return block.text
|
const events = session.events
|
||||||
return undefined
|
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. */
|
/** Append a full current snapshot only when it changed or compaction removed it. */
|
||||||
function materializeRuntimeContext(session: Session, current: string): void {
|
function materializeRuntimeContext(session: Session, current: string): void {
|
||||||
const previous = retainedRuntimeContext(session)
|
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
|
const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
|
||||||
if (previous === snapshot) return
|
if (previous.text === snapshot) return
|
||||||
session.append('user/message', createUserMessage({
|
session.append('user/message', createUserMessage({
|
||||||
content: [{ type: 'text', text: snapshot }],
|
content: [{ type: 'text', text: snapshot }],
|
||||||
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
|
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
|
||||||
@@ -550,7 +562,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
this.drainOutbox(turn)
|
this.drainOutbox(turn)
|
||||||
|
|
||||||
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
|
// 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.
|
// system/history cache prefix while keeping every model-visible byte logged.
|
||||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||||
signal.throwIfAborted()
|
signal.throwIfAborted()
|
||||||
|
|||||||
@@ -355,6 +355,62 @@ describe('agent loop', () => {
|
|||||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
|
&& 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 () => {
|
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
|
|||||||
Reference in New Issue
Block a user