fix: commit step context before request dispatch
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
|
||||
|
||||
@@ -17,8 +17,7 @@ export const inject = ['invariants']
|
||||
|
||||
/** Install the request-reconstruction contribution into its child registration fiber. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
// Prepend prevents a short-circuiting replay listener from silencing the
|
||||
// check; correctness itself comes from the sequence-bounded reconstruction.
|
||||
// Prepend prevents a short-circuiting replay listener from silencing the check.
|
||||
ctx.on('llm/stream', (options: GenerateOptions, next) => {
|
||||
if (!isAgentLoopRequest(options)) return next()
|
||||
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
|
||||
@@ -30,27 +29,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
}
|
||||
|
||||
const events = session.events
|
||||
let boundary = -1
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
if (events[index]?.type === 'step/start') {
|
||||
boundary = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (boundary === -1) {
|
||||
if (!events.some(event => event.type === 'step/start')) {
|
||||
return fail('a loop-built request with no step/start in its session log')
|
||||
}
|
||||
const header = foldRequestHeader(events)
|
||||
if (header === undefined) {
|
||||
return fail('a loop-built request with no request/header event in its session log')
|
||||
}
|
||||
const rebuilt = new Session(
|
||||
SessionId(`${String(session.id)}-invariant-rebuild`),
|
||||
structuredClone(events.slice(0, boundary)),
|
||||
)
|
||||
const expected = rebuilt.deriveMessages()
|
||||
const expected = session.deriveMessages()
|
||||
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
|
||||
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
|
||||
fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
|
||||
}
|
||||
|
||||
const headerMatches = options.model === header.config.model
|
||||
|
||||
@@ -42,12 +42,16 @@ describe('request-reconstruction invariant', () => {
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
it('includes context appended inside the open step before dispatch', async () => {
|
||||
const { ctx, session } = await requestSetup()
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' },
|
||||
content: [{ type: 'text', text: '[step context]' }], source: { kind: 'plugin', plugin: 'x' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
const options = loopRequest({
|
||||
model: 'm',
|
||||
messages: Object.freeze(session.deriveMessages()),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -57,16 +61,16 @@ describe('request-reconstruction invariant', () => {
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
||||
.not.toThrow()
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
})
|
||||
|
||||
it('rejects message and header divergence', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the folded request header/)
|
||||
})
|
||||
@@ -133,6 +137,6 @@ describe('request-reconstruction invariant', () => {
|
||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
|
||||
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user