refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user