feat(agent): the agent is a registration scope — Agent.ctx, setup slot, fused scoped dispatch
Every live agent owns a dsh-scope context (Agent.ctx, key = the agent), minted inside the loop's composite lifecycle effect: registrations through it are agent-visible and agent-lifetime, and agent.ctx listeners hear only that agent's dispatches. The composite yields the scope's raw disposer first (identity-nested, no un-nested window), then session entry (scoped enter captures the session carrier), then registration; teardown runs stop/drain -> unregister -> detach session -> unwind scope, keeping store/registry rollback synchronous on every failure path. CreateAgentOptions.setup(agentCtx) runs after the scope is minted and the agent registered, before agent/session-start and the loop start — the slot where a creator composes the agent's scoped world (persona sections, restrict(), scoped tools); a throwing setup unwinds inside the rollback boundary. Setup registers, it never drives. agentEvents(ctx, agent) fuses the scope carrier with the injected subject argument for every agent/* dispatch (the correct dispatch is the shortest spelling); assembleContextFor(agent) pairs the agent DX field with the scope layer selector. All loop/agent/registry dispatch sites converted; agent/* event declarations carry this: Scoped<Agent>; ctx.agent is a safe root accessor defaulting undefined, shadowed by each agent context.
This commit is contained in:
172
packages/core/agent-loop/tests/scope-lifecycle.spec.ts
Normal file
172
packages/core/agent-loop/tests/scope-lifecycle.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok')])) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
|
||||
describe('agent scope lifecycle', () => {
|
||||
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
expect(scopeOf(agent.ctx)).toBe(agent)
|
||||
expect(agent.ctx.agent).toBe(agent)
|
||||
// The root accessor default: a plain context answers undefined, not a throw.
|
||||
expect(ctx.agent).toBeUndefined()
|
||||
await ctx.agents.get(AgentId('a1'))?.whenIdle()
|
||||
})
|
||||
|
||||
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const { agent } = handle
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
agent.ctx.tools.register({
|
||||
name: 'mine', description: 'scoped', parameters: {},
|
||||
execute: () => Promise.resolve(text('ran')),
|
||||
})
|
||||
|
||||
const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
|
||||
expect(scopedAssembly.tools.map(t => t.name)).toContain('mine')
|
||||
// Other assemblies are untouched.
|
||||
const globalAssembly = await ctx.systemPrompt.assemble()
|
||||
expect(globalAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
|
||||
expect(globalAssembly.tools.map(t => t.name)).not.toContain('mine')
|
||||
|
||||
await handle.dispose()
|
||||
// The scoped world unwound with the agent: nothing leaked into the registries.
|
||||
expect(ctx.tools.get('mine', agent)).toBeUndefined()
|
||||
const after = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
expect(after.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are the deployment.')
|
||||
})
|
||||
|
||||
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
|
||||
const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
|
||||
const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' })
|
||||
|
||||
const heard: string[] = []
|
||||
a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
|
||||
a.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'user/message') heard.push('a-sees:user-message')
|
||||
})
|
||||
|
||||
b.send(text('for b'))
|
||||
await waitForIdle(ctx, b)
|
||||
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
|
||||
|
||||
a.send(text('for a'))
|
||||
await waitForIdle(ctx, a)
|
||||
expect(heard).toContain('a-sees:a:running')
|
||||
expect(heard).toContain('a-sees:user-message')
|
||||
})
|
||||
|
||||
it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
|
||||
const ctx = await harness()
|
||||
const order: string[] = []
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
order.push('session-start')
|
||||
// The scoped section is already registered by the time session-start fires.
|
||||
void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
|
||||
order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona')?.text}`)
|
||||
})
|
||||
})
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('child'),
|
||||
sessionId: SessionId('child-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
order.push('setup')
|
||||
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
||||
},
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a throwing setup unwinds the half-created agent completely', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.agents.create({
|
||||
agentId: AgentId('bad'),
|
||||
sessionId: SessionId('bad-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { throw new Error('boom setup') },
|
||||
})).toThrow('boom setup')
|
||||
|
||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
||||
const ctx = await harness()
|
||||
let boom = true
|
||||
ctx.on('session/created', () => {
|
||||
if (boom) { boom = false; throw new Error('boom created') }
|
||||
})
|
||||
expect(() => ctx.agents.create({
|
||||
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
|
||||
})).toThrow('boom created')
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
await handle.dispose()
|
||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('agentEvents fuses carrier and subject for custom drivers', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
||||
const heard: string[] = []
|
||||
agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
|
||||
|
||||
agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1'))
|
||||
agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1'))
|
||||
expect(heard).toEqual(['a1:2'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user