mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [
|
||||
'packages/sandbox/sandbox',
|
||||
'packages/llm/llm',
|
||||
'packages/util/brand',
|
||||
'packages/util/timeout',
|
||||
'packages/support/invariants',
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user