fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
describe('agent/turn-stop', () => {
|
||||
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('the ordinary decision is stop'),
|
||||
textResponse('must not be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
const downstream = await next()
|
||||
if (subject === agent && !steered) {
|
||||
steered = true
|
||||
subject.steer([{ type: 'text', text: 'late continuation steering' }])
|
||||
}
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('terminal answer'),
|
||||
textResponse('must not become a late-steering turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || injected) return
|
||||
injected = true
|
||||
agent.steer([{ type: 'text', text: 'steering from flush' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first terminal answer'),
|
||||
textResponse('queued follow-up answer'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let queued = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || queued) return
|
||||
queued = true
|
||||
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters a scoped terminal listener to its own agent', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('a1', 'echo', { text: 'a' }),
|
||||
toolCallResponse('b1', 'echo', { text: 'b' }),
|
||||
textResponse('b continues normally'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
|
||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(stopped)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await send(ordinary)
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unregisters with its scoped owner disposer', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('first', 'echo', { text: 'first' }),
|
||||
toolCallResponse('second', 'echo', { text: 'second' }),
|
||||
textResponse('continued after listener disposal'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
|
||||
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(agent, 'first turn')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
disposeStop()
|
||||
await send(agent, 'second turn')
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('throwing policy'),
|
||||
textResponse('malformed continue policy'),
|
||||
textResponse('malformed false policy'),
|
||||
textResponse('malformed null policy'),
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
|
||||
|
||||
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
|
||||
throw new Error('terminal policy exploded')
|
||||
})
|
||||
await send(agent, 'first')
|
||||
disposeThrowing()
|
||||
|
||||
for (const [index, malformed] of [
|
||||
{ action: 'continue' },
|
||||
false,
|
||||
null,
|
||||
].entries()) {
|
||||
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
|
||||
await send(agent, `malformed ${index}`)
|
||||
disposeMalformed()
|
||||
}
|
||||
|
||||
await send(agent, 'healthy')
|
||||
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
|
||||
expect(errors).toContain('terminal policy exploded')
|
||||
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
|
||||
expect(adapter.requests).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user