refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user