refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -128,7 +128,7 @@ describe('plan mode through the agent loop', () => {
expect(second.data.header.system).toContain('plan mode')
})
it('a mode flip during request recovery shapes the retry before its assembly', async () => {
it('a mode flip at error idle shapes the retry before its assembly', async () => {
const failedRequest = [{
type: 'finish',
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
@@ -136,20 +136,14 @@ describe('plan mode through the agent loop', () => {
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => {
if (subject !== agent) return next()
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
ctx.on('agent/idle', (subject, _turn, reason) => {
if (subject !== agent || reason.kind !== 'error') return
ctx.planMode.set(agent, true)
agent.retry()
})
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.planMode.set(agent, true)
releaseRecovery.resolve(true)
await idle
expect(adapter.requests).toHaveLength(2)
@@ -158,8 +152,10 @@ describe('plan mode through the agent loop', () => {
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
const firstEnd = log.find(event => event.type === 'step/end'
&& event.data.turn === 1 && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start'
&& event.data.turn === 2 && event.data.step === 1)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)

View File

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
@@ -19,9 +19,8 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Turn boundaries are simulated by appending the real boundary events and
* dispatching the interception seams the loop fires there. Recovery retries
* exercise the separate `agent/request-error` wrapper.
* Request boundaries are simulated by dispatching the real prompt-admission
* and between-step seams used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
@@ -53,39 +52,15 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
}
/**
* Append a boundary event and dispatch the interception seam the loop fires
* there — `agent/prompt-submit` inside the just-opened turn,
* `agent/turn-continuation` after the step closed. Recovery retries use the
* separately covered `agent/request-error` wrapper; post-commit
* `session/event` observers remain observe-only.
* Dispatch either prompt admission or the between-step checkpoint.
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
return
}
agent.session.append('step/end', { turn: 1, step: 1 })
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
}
/** Dispatch the closed-step recovery seam with one terminal decision. */
function recoveryBoundary(
ctx: Context,
agent: Agent & { session: Session },
decision: RequestErrorDecision,
): Promise<RequestErrorDecision> {
return agentEvents(ctx, agent).waterfall(
'agent/request-error',
1,
1,
new Error('request failed'),
{ message: 'request failed', code: 'SERVER' },
[],
new AbortController().signal,
() => Promise.resolve(decision),
)
await events.serial('agent/step', 1, 2, new AbortController().signal)
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
@@ -218,16 +193,14 @@ describe('the boundary flush', () => {
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.planMode.set(agent, true)
await next()
return decision
return next()
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
)
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
@@ -243,20 +216,18 @@ describe('the boundary flush', () => {
// A downstream listener captured before disposal keeps the waterfall
// continuation alive across the unload; the resumed wrapper must not
// append through the disposed service.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => {
await fiber.dispose()
await next()
return decision
return next()
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
it('flushes at the between-step seam too', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
@@ -264,30 +235,6 @@ describe('the boundary flush', () => {
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('contains an append failure at the retry boundary without changing its decision', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
@@ -937,30 +884,6 @@ describe('exit_plan_mode', () => {
})
describe('HMR disposal', () => {
it('does not flush a retry boundary that resumes after plugin disposal', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
})
ctx.planMode.set(agent, true)
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
await recoveryEntered.promise
await fiber.dispose()
releaseRecovery.resolve(true)
expect(await recovery).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -976,7 +899,7 @@ describe('HMR disposal', () => {
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
await boundary(ctx, agent, 'step/end')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})