fix(llm): preserve retry routes across turns

This commit is contained in:
Turtle
2026-07-25 10:49:48 +08:00
parent d07e7dd317
commit c1f148ce7f
4 changed files with 47 additions and 7 deletions

View File

@@ -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.
* A preceding retry is also a route marker because every provider change
* requires a newer full request-header snapshot.
* Request headers remain effective across turn boundaries until a newer full
* 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 turn - turn that owns the failed step.
* @param step - failed step whose provider is required.
@@ -32,7 +33,6 @@ export function providerForClosedStep(
&& event.data.step < step) {
return event.data.provider
}
if (event.type === 'turn/start' || event.type === 'turn/end') return undefined
}
return undefined
}

View File

@@ -38,7 +38,7 @@ describe('llm-retry invariants', () => {
}] 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([
{ type: 'turn/start', data: { turn: 1 } },
{
@@ -48,7 +48,7 @@ describe('llm-retry invariants', () => {
{ type: 'turn/end', data: { turn: 1 } },
{ type: 'turn/start', data: { turn: 2 } },
{ 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 () => {
@@ -226,7 +226,7 @@ describe('llm-retry invariants', () => {
}).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 session = closeStep(ctx, 'retry-invariant-prior-route')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -243,7 +243,7 @@ describe('llm-retry invariants', () => {
delayMs: 1,
failure,
})
}).toThrow(/does not match the failed request provider undefined/)
}).not.toThrow()
})
it('rejects non-numeric durable delays', async () => {

View File

@@ -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 () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([

View File

@@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [
'packages/sandbox/sandbox',
'packages/llm/llm',
'packages/util/brand',
'packages/util/timeout',
'packages/support/invariants',
]