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

@@ -155,7 +155,7 @@ describe('dsh-subagent-fork', () => {
// 1 from the seeded parent turn + 1 from the child's own completed turn.
expect(seedTurnEnds.length).toBe(2)
parent.cancel()
parent.cancel({ kind: 'user' })
await run.dispose()
})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -116,8 +115,7 @@ describe('in-process structured output', () => {
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// Default continuation would run a second step after the tool call; the
// structured runtime's turn-continuation veto stops the turn instead.
// The structured tool marks its successful result as turn-concluding.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
@@ -219,63 +217,6 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
let wrapperInstalled = false
// Register before ready-only start: structured output is attached before session-start and the
// loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
// to the later terminal checkpoint.
ctx.on('agent/session-start', (child) => {
if (child === parent) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
return { action: 'continue' }
}, { prepend: true })
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
expect(adapter.requests).toHaveLength(1)
await run.dispose()
})
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
// folding would turn into continue. The terminal checkpoint must discard that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
return downstream
}, { prepend: true })
})
const result = await run.result
const child = ctx.agents.get(run.id)
expect(result.structured).toEqual({ answer: 9 })
expect(adapter.requests).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),

View File

@@ -80,7 +80,7 @@ describe('startInProcessRun', () => {
const child = ctx.agents.get(run.id)!
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'completed' } } })
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, symbols, type EffectMeta } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -52,6 +52,17 @@ function start(ctx: Context, provider: string, request: Omit<SubagentStartReques
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */
function disposeChildLifecycle(parent: Agent): void {
const lifecycle = [...parent.ctx.fiber._disposables]
.find((dispose) => {
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
return effect?.label.startsWith('agentLoop.lifecycle(') === true
})
if (lifecycle === undefined) throw new Error('child lifecycle effect not found')
void lifecycle()
}
describe('dsh-subagent-spawn', () => {
it('runs a fresh child to completion and returns its final assistant output', async () => {
// One model call for the child: a plain text answer.
@@ -460,6 +471,12 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
let teardownStarted = false
ctx.on('internal/plugin', (fiber) => {
if (teardownStarted || fiber.name !== 'scope') return
teardownStarted = true
disposeChildLifecycle(parentHandle.agent)
})
const starting = start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
@@ -468,8 +485,8 @@ describe('dsh-subagent-spawn', () => {
// The factory has entered its awaited unpublished setup transaction. The
// parent context owns that transaction, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
await parentHandle.dispose()
expect(published).toEqual([])
})