فهرست منبع

fix(llm): preserve retry routes across turns

Turtle 2 ماه پیش
والد
کامیت
c1f148ce7f

+ 3 - 3
packages/llm/llm-retry/src/history.ts

@@ -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
 }

+ 4 - 4
packages/llm/llm-retry/tests/invariant.spec.ts

@@ -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 () => {

+ 39 - 0
packages/llm/llm-retry/tests/retry.spec.ts

@@ -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([

+ 1 - 0
packages/sandbox/sandbox-local/tests/packed-install.e2e.ts

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