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

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -46,12 +47,9 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {

View File

@@ -26,12 +26,9 @@ declare module '@deepseek-ai/dsh-tasks' {
}
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = { session: { header: { cwd } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
/**
@@ -99,15 +96,8 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
}
}
function waitForIdle(ctx: Context, target: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (agent, status) => {
if (agent === target && status === 'idle') {
dispose()
resolve()
}
})
})
function waitForIdle(_ctx: Context, target: Agent): Promise<void> {
return target.whenIdle()
}
function messageText(message: Message | undefined): string {
@@ -236,6 +226,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
handle.agent.followup([{ type: 'text', text: 'recover' }])
await expect.poll(() => adapter.requests).toBe(2)
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2)
@@ -457,8 +448,8 @@ describe('dsh-agent-spine-demo bundle', () => {
handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[2])).toContain('prefix-order-skill')
await handle.dispose()
await ctx.fiber.dispose()
} finally {

View File

@@ -225,22 +225,24 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
const usageByStep = new Map<number, TokenUsage>()
const usageByStep = new Map<string, TokenUsage>()
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
let settled = false
let firstTurnEnded = false
const turnEnded = new Promise<void>((resolve, reject) => {
resolveTurn = resolve
rejectTurn = reject
})
const settleResolved = (): void => {
settled = true
if (firstTurnEnded) return
firstTurnEnded = true
resolveTurn()
}
const settleRejected = (error: Error): void => {
settled = true
if (firstTurnEnded) return
firstTurnEnded = true
rejectTurn(error)
}
const observe = (sessionId: string, event: SessionEvent): void => {
@@ -254,20 +256,26 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session || settled) return
if (session !== agent.session) return
if (targetTurn === undefined) {
if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return
targetTurn = event.data.turn
} else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry'
&& reason?.kind === 'error') {
targetTurn = event.data.turn
reason = undefined
}
observe(session.id, event)
if (event.type === 'assistant/chunk'
&& event.data.turn === targetTurn
&& event.data.chunk.type === 'usage') {
usageByStep.set(event.data.step, event.data.chunk.usage)
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage)
if (event.data.usage !== undefined) {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
}
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
@@ -289,14 +297,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
if (!firstTurnEnded) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.followup([{ type: 'text', text: options.task }])
}
await turnEnded
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
disposeListener()
await agent.whenIdle()
disposeListener()
}
/* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */

View File

@@ -1,9 +1,11 @@
import { mkdtemp } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -39,12 +41,9 @@ async function mount(config: cliDemo.Config, withBash = false): Promise<Context>
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
afterEach(async () => {

View File

@@ -319,7 +319,7 @@ describe('runOneShot and executeCli', () => {
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
const output = await invoke(ctx, ['task'])
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
@@ -379,11 +379,13 @@ describe('runOneShot and executeCli', () => {
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent)
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 2, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
@@ -409,7 +411,7 @@ describe('runOneShot and executeCli', () => {
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('turn 1 was aborted')
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
})
it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
@@ -444,7 +446,7 @@ describe('runOneShot and executeCli', () => {
expect(output.code).toBe(1)
expect(output.stdout).toBe('')
expect(output.stderr).toContain('stdout closed')
expect(final.agent.status).toBe('disposed')
expect(final.agent.status).toBe('idle')
const disposal = await harness([textResponse('answer')])
const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })