fix(llm): preserve retry routes across turns
This commit is contained in:
@@ -4,8 +4,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the provider in force when one step closed, excluding later recovery mutations.
|
* Find the provider in force when one step closed, excluding later recovery mutations.
|
||||||
* A preceding retry is also a route marker because every provider change
|
* Request headers remain effective across turn boundaries until a newer full
|
||||||
* requires a newer full request-header snapshot.
|
* snapshot changes them. A preceding retry in the same turn is also a route
|
||||||
|
* marker because every provider change requires a newer full snapshot.
|
||||||
* @param events - session events containing the closed step.
|
* @param events - session events containing the closed step.
|
||||||
* @param turn - turn that owns the failed step.
|
* @param turn - turn that owns the failed step.
|
||||||
* @param step - failed step whose provider is required.
|
* @param step - failed step whose provider is required.
|
||||||
@@ -32,7 +33,6 @@ export function providerForClosedStep(
|
|||||||
&& event.data.step < step) {
|
&& event.data.step < step) {
|
||||||
return event.data.provider
|
return event.data.provider
|
||||||
}
|
}
|
||||||
if (event.type === 'turn/start' || event.type === 'turn/end') return undefined
|
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('llm-retry invariants', () => {
|
|||||||
}] as never, 1, 1)).toBeUndefined()
|
}] as never, 1, 1)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not inherit a provider across a turn boundary', () => {
|
it('inherits the latest provider across a turn boundary when the header is unchanged', () => {
|
||||||
expect(providerForClosedStep([
|
expect(providerForClosedStep([
|
||||||
{ type: 'turn/start', data: { turn: 1 } },
|
{ type: 'turn/start', data: { turn: 1 } },
|
||||||
{
|
{
|
||||||
@@ -48,7 +48,7 @@ describe('llm-retry invariants', () => {
|
|||||||
{ type: 'turn/end', data: { turn: 1 } },
|
{ type: 'turn/end', data: { turn: 1 } },
|
||||||
{ type: 'turn/start', data: { turn: 2 } },
|
{ type: 'turn/start', data: { turn: 2 } },
|
||||||
{ type: 'step/end', data: { turn: 2, step: 1 } },
|
{ type: 'step/end', data: { turn: 2, step: 1 } },
|
||||||
] as never, 2, 1)).toBeUndefined()
|
] as never, 2, 1)).toBe('prior')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => {
|
it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => {
|
||||||
@@ -226,7 +226,7 @@ describe('llm-retry invariants', () => {
|
|||||||
}).toThrow(/does not match the failed request provider mock/)
|
}).toThrow(/does not match the failed request provider mock/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a current-turn retry without a current-turn provider route', async () => {
|
it('accepts a current-turn retry under an unchanged prior provider route', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const session = closeStep(ctx, 'retry-invariant-prior-route')
|
const session = closeStep(ctx, 'retry-invariant-prior-route')
|
||||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||||
@@ -243,7 +243,7 @@ describe('llm-retry invariants', () => {
|
|||||||
delayMs: 1,
|
delayMs: 1,
|
||||||
failure,
|
failure,
|
||||||
})
|
})
|
||||||
}).toThrow(/does not match the failed request provider undefined/)
|
}).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects non-numeric durable delays', async () => {
|
it('rejects non-numeric durable delays', async () => {
|
||||||
|
|||||||
@@ -207,6 +207,45 @@ describe('provider-routed retry policy', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retries a later turn under its unchanged provider header', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const adapter = new ScriptedAdapter([
|
||||||
|
textResponse('first turn'),
|
||||||
|
new LlmError('busy on second turn', 'RATE_LIMIT'),
|
||||||
|
textResponse('second turn recovered'),
|
||||||
|
])
|
||||||
|
;({ ctx: context } = await harness(adapter, { mock: normalConfig({
|
||||||
|
backoff: { initialDelayMs: 1, maxDelayMs: 1 },
|
||||||
|
}) }))
|
||||||
|
const agent = context.agentLoop.create(SessionId('retry-later-turn'), {
|
||||||
|
provider: 'mock',
|
||||||
|
model: 'mock',
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstIdle = waitForIdle(context, agent)
|
||||||
|
agent.followup([{ type: 'text', text: 'first' }])
|
||||||
|
await firstIdle
|
||||||
|
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||||
|
|
||||||
|
const scheduled = waitForRetry(context, agent, 1)
|
||||||
|
agent.followup([{ type: 'text', text: 'second' }])
|
||||||
|
expect((await scheduled).data).toMatchObject({
|
||||||
|
turn: 2,
|
||||||
|
step: 1,
|
||||||
|
provider: 'mock',
|
||||||
|
})
|
||||||
|
const secondIdle = waitForIdle(context, agent)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
await secondIdle
|
||||||
|
|
||||||
|
expect(adapter.requests).toHaveLength(3)
|
||||||
|
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||||
|
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'second turn recovered' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => {
|
it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const adapter = new ScriptedAdapter([
|
const adapter = new ScriptedAdapter([
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [
|
|||||||
'packages/sandbox/sandbox',
|
'packages/sandbox/sandbox',
|
||||||
'packages/llm/llm',
|
'packages/llm/llm',
|
||||||
'packages/util/brand',
|
'packages/util/brand',
|
||||||
|
'packages/util/timeout',
|
||||||
'packages/support/invariants',
|
'packages/support/invariants',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user