feat(core): add post-step request recovery (PR3 phase 1)

This commit is contained in:
Hypatia May
2026-07-15 16:03:52 +08:00
parent e55e96eec6
commit e8d066f750
29 changed files with 1207 additions and 120 deletions

View File

@@ -12,10 +12,10 @@ import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -139,6 +139,59 @@ describe('Agent.cancel()', () => {
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'danger', {}),
textResponse('recovered after cancellation'),
])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
name: 'danger',
description: 'must not run after cancellation',
parameters: {},
async execute() {
executions += 1
return [{ type: 'text', text: 'ran' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('cancel-after-assistant-message'), { model: 'mock' })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
agent.cancel('cancelled after assistant message')
}
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_session, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
dispose()
expect(executions).toBe(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }])
const call = agent.session.events.find(event => event.type === 'tool/call')
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
})
send(agent, 'continue safely')
await waitForIdle(ctx, agent)
const replayedResult = adapter.requests[1]!.messages
.flatMap(message => message.content)
.find(block => block.type === 'tool-result')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted', reason: 'cancelled after assistant message' },
{ kind: 'completed' },
])
})
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
const adapter = new MockAdapter(['hang', textResponse('second reply')])
const ctx = await harness(adapter)

View File

@@ -204,6 +204,16 @@ describe('abort during tool execution ends the turn', () => {
expect(executed).toEqual(['aborter']) // second tool never ran
expect(adapter.requests).toHaveLength(1) // no follow-up model call
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
const calls = agent.session.events.filter(event => event.type === 'tool/call')
const results = agent.session.events.filter(event => event.type === 'tool/result')
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
expect(results).toHaveLength(2)
expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false })
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
})
})
})

View File

@@ -0,0 +1,442 @@
/**
* Agent-loop coverage for the successful post-step checkpoint and model-request
* recovery. These tests keep the recovery boundary narrower than the whole
* step and pin retry reconstruction, numbering, cancellation, and identity.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, {
CallId,
CONTEXT_WINDOW_EXCEEDED_CODE,
LlmAdapter,
LlmError,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
class FailureScriptAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(private readonly entries: (Error | StreamChunk[])[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.entries.shift()
if (entry === undefined) throw new Error('failure script exhausted')
if (entry instanceof Error) throw entry
yield* entry
}
}
class IteratorConstructionFailureAdapter extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
throw new LlmError('iterator construction failed', 'ITERATOR_CONSTRUCTION')
},
}
}
}
class SynchronousDispatchFailureAdapter extends LlmAdapter {
constructor(private readonly error: Error) {
super()
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw this.error
}
}
class IteratorResultGetterFailureAdapter extends LlmAdapter {
constructor(
private readonly field: 'done' | 'value',
private readonly error: Error,
) {
super()
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
const result = this.field === 'done' ? {} : { done: false }
Object.defineProperty(result, this.field, { get: () => { throw this.error } })
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return { next: () => Promise.resolve(result as unknown as IteratorResult<StreamChunk>) }
},
}
}
}
const streamListenerFailureCases: readonly [string, (ctx: Context) => void][] = [
['synchronous listener throw', (ctx) => {
ctx.on('llm/stream', () => { throw new Error('synchronous stream listener failed') })
}],
['invalid listener iterable', (ctx) => {
ctx.on('llm/stream', () => ({}) as AsyncIterable<StreamChunk>)
}],
['listener wrapper iteration failure', (ctx) => {
ctx.on('llm/stream', (_options, next) => (async function * () {
for await (const chunk of next()) {
yield chunk
throw new Error('stream listener wrapper failed')
}
})())
}],
]
async function harness(adapter?: LlmAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
if (adapter) ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function send(agent: ReactLoopAgent): void {
agent.send([{ type: 'text', text: 'go' }])
}
function contextError(message = 'context too large'): LlmError {
return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE, 400)
}
describe('agent post-step and request-error lifecycle', () => {
it('fires post-step after results, buffered context, and steering but before step/end', async () => {
const twoCalls: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'work', arguments: '{}' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-2'), name: 'work', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'work',
description: 'do work',
parameters: {},
async execute(_args, exec) {
if (exec.callId === CallId('call-2')) {
exec.agent?.steer([{ type: 'text', text: 'steered' }], { source: { kind: 'plugin', plugin: 'test' } })
}
return [{ type: 'text', text: 'worked' }]
},
}))
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> => ({
kind: 'accept',
additionalContext: {
content: [{ type: 'text', text: `context for ${exec.callId}` }],
source: { kind: 'plugin', plugin: 'test' },
},
}))
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
const order: string[] = []
ctx.on('session/event', (_session, event) => {
if (
event.type === 'assistant/message' || event.type === 'tool/call'
|| event.type === 'tool/result' || event.type === 'context/message'
|| event.type === 'steering/message' || event.type === 'step/end'
) {
if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
}
})
ctx.on('agent/post-step', (subject, turn, step, signal) => {
if (subject !== agent || step !== 1) return
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: false })
subject.inject([{ type: 'text', text: 'listener mutation' }], { source: { kind: 'plugin', plugin: 'post-step' } })
order.push('agent/post-step')
})
send(agent)
await waitForIdle(ctx, agent)
expect(order).toEqual([
'assistant/message',
'tool/call',
'tool/result',
'tool/call',
'tool/result',
'context/message',
'context/message',
'steering/message',
'context/message',
'agent/post-step',
'step/end',
])
})
it('fires post-step for max-tokens and lets cancellation override that success', async () => {
const adapter = new FailureScriptAdapter([maxTokensResponse('partial')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('cancel-post-step-max-tokens'), { model: 'mock' })
let entered!: () => void
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
})
send(agent)
const idle = waitForIdle(ctx, agent)
await postStepEntered
agent.cancel('cancelled during max-tokens post-step')
await idle
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
data: { usage: { inputTokens: 10, outputTokens: 7 } },
})
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } },
})
})
it.each([
['thrown', contextError()],
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId(`recover-${_style}`), { model: 'mock' })
const attempts: number[] = []
ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => {
expect(subject).toBe(agent)
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
attempts.push(attempt)
subject.session.append('context/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
}, { surfaceOp: 'append' })
return { action: 'retry' }
})
send(agent)
await waitForIdle(ctx, agent)
expect(attempts).toEqual([0])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('RECOVERY SURFACE MUTATION')
const starts = agent.session.events.filter(event => event.type === 'step/start')
const ends = agent.session.events.filter(event => event.type === 'step/end')
expect(starts.map(event => event.data.step)).toEqual([1, 2])
expect(ends.map(event => event.data.step)).toEqual([1, 2])
const recovery = agent.session.events.find(event => event.type === 'context/message')!
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
})
it.each(streamListenerFailureCases)('does not offer %s to request recovery', async (_name, install) => {
const ctx = await harness(new FailureScriptAdapter([textResponse('unused')]))
const agent = ctx.agentLoop.create(AgentId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { model: 'mock' })
let recoveries = 0
install(ctx)
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries).toBe(0)
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
})
it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)(
'does not offer %s middleware failures to request recovery',
async (boundary) => {
const adapter = new FailureScriptAdapter([textResponse('unused')])
const ctx = await harness(adapter)
if (boundary === 'prompt-submit') {
ctx.on('agent/prompt-submit', () => { throw new Error('prompt submit failed') })
} else if (boundary === 'prompt-assembly') {
ctx.on('system-prompt/assemble', () => { throw new Error('prompt assembly failed') })
} else if (boundary === 'pre-step') {
ctx.on('agent/pre-step', () => { throw new Error('pre-step failed') })
} else {
ctx.on('agent/request', () => { throw new Error('request middleware failed') })
}
const agent = ctx.agentLoop.create(AgentId(`${boundary}-not-recoverable`), { model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries).toBe(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
},
)
it('does not offer result, tool, or post-step plugin failures to request recovery', async () => {
for (const failure of ['result', 'tool', 'post-step'] as const) {
const adapter = new FailureScriptAdapter([
failure === 'tool' ? toolCallResponse(`call-${failure}`, 'work', {}) : textResponse('done'),
])
const ctx = await harness(adapter)
if (failure === 'result') ctx.on('agent/step-result', () => { throw new Error('result failed') })
if (failure === 'post-step') ctx.on('agent/post-step', () => { throw new Error('post-step failed') })
if (failure === 'tool') {
vi.spyOn(ctx.tools, 'execute').mockRejectedValue(new Error('tool service failed'))
}
const agent = ctx.agentLoop.create(AgentId(`${failure}-not-recoverable`), { model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries, failure).toBe(0)
}
})
it.each([
['synchronous dispatch', (error: Error) => new SynchronousDispatchFailureAdapter(error)],
['done getter', (error: Error) => new IteratorResultGetterFailureAdapter('done', error)],
['value getter', (error: Error) => new IteratorResultGetterFailureAdapter('value', error)],
] as const)('preserves original Error identity for adapter %s', async (_name, makeAdapter) => {
const original = contextError(`${_name} overflow`)
const ctx = await harness(makeAdapter(original))
const agent = ctx.agentLoop.create(AgentId(`identity-${_name.replaceAll(' ', '-')}`), { model: 'mock' })
let seen: Error | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
seen = error
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seen).toBe(original)
})
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
for (const scenario of ['iterator', 'no-adapter'] as const) {
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
const agent = ctx.agentLoop.create(AgentId(`request-boundary-${scenario}`), { model: 'mock' })
let seen = ''
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
seen = error.code ?? ''
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER')
}
})
it('tracks consecutive retry attempts and resets after a successful request', async () => {
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
const cappedCtx = await harness(capped)
const cappedAgent = cappedCtx.agentLoop.create(AgentId('retry-cap'), { model: 'mock' })
const cappedAttempts: number[] = []
cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => {
cappedAttempts.push(attempt)
return attempt < 1 ? { action: 'retry' } : next()
})
send(cappedAgent)
await waitForIdle(cappedCtx, cappedAgent)
expect(cappedAttempts).toEqual([0, 1])
const reset = new FailureScriptAdapter([
contextError('first overflow'),
toolCallResponse('retry-reset-call', 'work', {}),
contextError('later overflow'),
])
const resetCtx = await harness(reset)
resetCtx.tools.register(defineTool({
name: 'work',
description: 'continue',
parameters: {},
async execute() { return [{ type: 'text', text: 'worked' }] },
}))
const resetAgent = resetCtx.agentLoop.create(AgentId('retry-reset'), { model: 'mock' })
const resetAttempts: { step: number; attempt: number }[] = []
resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => {
resetAttempts.push({ step, attempt })
return resetAttempts.length === 1 ? { action: 'retry' } : next()
})
send(resetAgent)
await waitForIdle(resetCtx, resetAgent)
expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }])
})
it('preserves the original provider error when recovery throws', async () => {
const adapter = new FailureScriptAdapter([contextError('original overflow')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('recovery-throws'), { model: 'mock' })
ctx.on('agent/request-error', () => { throw new Error('recovery exploded') })
send(agent)
await waitForIdle(ctx, agent)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
})
})
it.each(['cancel', 'dispose'] as const)('keeps %s live through request recovery', async (action) => {
const adapter = new FailureScriptAdapter([contextError()])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId(`${action}-recovery`), { model: 'mock' })
let entered!: () => void
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => {
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
return { action: 'retry' }
})
send(agent)
const idle = waitForIdle(ctx, agent)
await recoveryEntered
if (action === 'cancel') {
agent.cancel('cancelled during recovery')
await idle
} else {
await ctx.fiber.dispose()
}
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } },
})
})
})