Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/package-readme-limitations-audit-20260712
# Conflicts: # packages/core/scope/README.md # packages/session-persistence/session-persistence-jsonl/README.md # packages/session-persistence/session-persistence-sqlite/README.md # packages/subagent/subagent-acp/README.md # packages/subagent/subagent-fork/README.md # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent/README.md # packages/subagent/tool-subagent/README.md # packages/support/invariants/README.md # packages/support/subagent-mock/README.md # packages/workflow/workflow-workerthread/README.md # packages/workflow/workflow/README.md
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
# @deepseek-ai/dsh-subagent-spawn
|
||||
|
||||
The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown.
|
||||
The spawn provider creates a fresh child `Agent` in the current process. The child has its own session, sees no parent conversation history, and reuses the host's agent factory and LLM/tool services.
|
||||
|
||||
The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other.
|
||||
## Behavior
|
||||
|
||||
## What it does
|
||||
`start(request)` delegates to [`startInProcessRun`](../subagent-inprocess/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation.
|
||||
|
||||
`start(request)` delegates to `startInProcessRun(ctx, request, {})` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. The driver creates one run-owner fiber under `parent.ctx`; parent teardown, this provider's teardown, and manual disposal all converge there before child publication. Its `run.started` boundary resolves only after the fresh child is published, so `subagent/start` observers see a live registry entry. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose).
|
||||
The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run.
|
||||
|
||||
## Capabilities
|
||||
|
||||
`{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`. It constructs the child, so it enforces a recursion cap and composes the child's persona, global-tool restriction, and [structured runtime](../subagent-inprocess/README.md) inside the agent-creation setup window. At apply it registers this one named provider on `ctx.subagents`; per-run contributions belong to each child's scope.
|
||||
Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -20,5 +20,5 @@ The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs; the consumer collects synchronously.
|
||||
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
|
||||
- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required.
|
||||
|
||||
@@ -53,16 +53,16 @@ class SpawnProvider implements SubagentProvider {
|
||||
// Context contract: a spawned child starts fresh — it never sees the parent conversation.
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context) {}
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
||||
// depth, drives the one-shot (including the structured capture when the
|
||||
// request carries an outputSchema), and maps the result.
|
||||
return startInProcessRun(this.ctx, request, {})
|
||||
return startInProcessRun(request, {})
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx))
|
||||
ctx.subagents.registerProvider(new SpawnProvider(config.providerName))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '../src/index.ts'
|
||||
import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
@@ -44,11 +44,15 @@ function text(blocks: { type: string; text?: string }[]): string {
|
||||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
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.
|
||||
const { ctx, parent } = await setup([textResponse('child answer')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('child answer')
|
||||
@@ -62,11 +66,11 @@ describe('dsh-subagent-spawn', () => {
|
||||
if (info.provider === 'spawn') childAtStart = ctx.agents.get(info.id)
|
||||
})
|
||||
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
|
||||
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'do X' }], parent })
|
||||
// Creation is asynchronous; no lifecycle claim is made while the child is
|
||||
// still inside its unpublished setup transaction.
|
||||
expect(childAtStart).toBeUndefined()
|
||||
await run.started
|
||||
const run = await starting
|
||||
expect(childAtStart).toBe(ctx.agents.get(run.id))
|
||||
expect(childAtStart?.id).toBe(run.id)
|
||||
|
||||
@@ -76,7 +80,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
|
||||
it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('hi')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.header.id).not.toBe(parent.session.header.id)
|
||||
@@ -92,7 +96,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
const parentEventCount = parent.session.events.length
|
||||
expect(parentEventCount).toBeGreaterThan(0)
|
||||
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The child's first user/message is its OWN prompt, not the parent's history.
|
||||
@@ -103,7 +107,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
|
||||
it('disposes the child to quiescence (agent removed from the registry)', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('x')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
await run.result
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
await run.dispose()
|
||||
@@ -114,7 +118,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('x')])
|
||||
expect(depthOf(parent)).toBe(0)
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(depthOf(child)).toBe(1)
|
||||
@@ -124,13 +128,13 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('refuses to spawn past maxDepth (depthLimit capability)', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// parent is depth 0, child would be depth 1 — cap at 0 forbids any child.
|
||||
expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
|
||||
.toThrow(SubagentDepthError)
|
||||
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }))
|
||||
.rejects.toThrow(SubagentDepthError)
|
||||
})
|
||||
|
||||
it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => {
|
||||
const { ctx, parent } = await setup([maxTokensResponse('cut off')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
@@ -140,14 +144,14 @@ describe('dsh-subagent-spawn', () => {
|
||||
// Empty script: the child's first model call throws "script exhausted", the
|
||||
// turn ends `error`, and there is no assistant/message → empty output.
|
||||
const { ctx, parent } = await setup([])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => {
|
||||
it('rejects without publishing when the request signal is already aborted', async () => {
|
||||
// Regression: a signal aborted BEFORE the run starts never fires an `abort`
|
||||
// event, so the listener can't catch it. The driver must check the
|
||||
// already-aborted case up front and settle `aborted` without running the
|
||||
@@ -156,43 +160,15 @@ describe('dsh-subagent-spawn', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const { ctx, parent } = await setup([])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }))
|
||||
.rejects.toThrow('aborted before child publication')
|
||||
})
|
||||
|
||||
it('cancelling BEFORE the child turn starts settles aborted, not error', async () => {
|
||||
// Regression: a cancel landing in the pre-turn window clears the queued
|
||||
// prompt before any `turn/end` is logged. Deriving the stop reason from
|
||||
// `turn/end` alone then mis-maps the no-turn case to `error`; the run must
|
||||
// honor the cancel contract and settle `aborted`. The cancel is synchronous
|
||||
// (same tick as start, before the loop's queued-wait continuation runs), so
|
||||
// the turn is dropped and the empty script is never consumed.
|
||||
const { ctx, parent } = await setup([])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
run.cancel('early')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(result.output).toEqual([])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
ctx.on('agent/queued', (agent) => {
|
||||
if (agent.id === run.id) run.cancel('queued-window')
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const result = await run.result
|
||||
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
|
||||
it('same-tick cancellation rejects start and prevents child publication', async () => {
|
||||
// Regression: cancellation before publication used to set a flag but let the
|
||||
// async factory publish a child anyway, so `started` fulfilled and lifecycle
|
||||
// observers saw an agent for an attempt the caller had already cancelled.
|
||||
// The empty script also proves no model turn can run.
|
||||
const { ctx, parent } = await setup([])
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
@@ -200,22 +176,36 @@ 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'))
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
ctx.on('subagent/start', () => void published.push('subagent/start'))
|
||||
ctx.on('subagent/end', () => void published.push('subagent/end'))
|
||||
const controller = new AbortController()
|
||||
const starting = start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
controller.abort('early')
|
||||
|
||||
// Same tick: the factory has reserved ids and entered its async setup
|
||||
// transaction, but has not published the child yet.
|
||||
await run.dispose()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
await expect(starting).rejects.toThrow()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
ctx.on('agent/queued', () => { controller.abort('queued-window') })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
const result = await run.result
|
||||
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
|
||||
// 'hang' makes the child's model stream one chunk then wait until aborted.
|
||||
const controller = new AbortController()
|
||||
const { ctx, parent } = await setup(['hang'])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal })
|
||||
// Let the child's turn start, then abort via the request signal (the
|
||||
// backend bridges it to child.cancel()).
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -225,29 +215,18 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('run.cancel() also cancels the child directly', async () => {
|
||||
it('dispose cancels the child and reaches quiescence', async () => {
|
||||
const { ctx, parent } = await setup(['hang'])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
run.cancel('test cancel')
|
||||
await run.dispose()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('run.cancel() with no reason uses the default cancel reason', async () => {
|
||||
const { ctx, parent } = await setup(['hang'])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
run.cancel()
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('x')])
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
expect('sendMessage' in run).toBe(false)
|
||||
expect('resume' in run).toBe(false)
|
||||
await run.result
|
||||
@@ -263,7 +242,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
meta: { cwd: '/tmp/parent-workspace' },
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
|
||||
const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.header.cwd).toBe('/tmp/parent-workspace')
|
||||
@@ -280,7 +259,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
agentOptions: {},
|
||||
})
|
||||
// The request supplies the child's model explicitly.
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'p' }],
|
||||
parent: parentHandle.agent,
|
||||
agentOptions: { model: 'mock' },
|
||||
@@ -312,7 +291,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'produce the answer' }],
|
||||
parent,
|
||||
outputSchema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] },
|
||||
@@ -325,7 +304,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a backend unload mid-structured-run settles the run and releases the runtime', async () => {
|
||||
it('a backend unload does not revoke an accepted holder-owned run', async () => {
|
||||
// Rebuild the stack by hand so we hold the backend's fiber.
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
@@ -340,52 +319,27 @@ describe('dsh-subagent-spawn', () => {
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const controller = new AbortController()
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'q' }],
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
outputSchema: { type: 'object', properties: { a: { type: 'number' } } },
|
||||
})
|
||||
// Let the child's step start streaming, then unload the backend. The
|
||||
// backend owns the child agent, so the unload tears the child down and
|
||||
// the run settles — releasing its own runtime acquisition on the way out.
|
||||
// Provider removal prevents new starts but the returned run belongs to its
|
||||
// holder and remains live.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await fiber.dispose()
|
||||
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
|
||||
expect(ctx.agents.get(run.id)).toBeDefined()
|
||||
controller.abort('test complete')
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a backend unload during child creation prevents every publication notification', async () => {
|
||||
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: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const published: string[] = []
|
||||
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'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }], parent,
|
||||
})
|
||||
await fiber.dispose()
|
||||
await run.result.catch(() => undefined)
|
||||
await run.dispose()
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
|
||||
it('a start racing an already-unloading backend cannot begin child creation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -402,10 +356,10 @@ describe('dsh-subagent-spawn', () => {
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const unloading = fiber.dispose()
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never start' }], parent,
|
||||
})).toThrow(/inactive context/)
|
||||
await unloading
|
||||
await expect(start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never start' }], parent,
|
||||
})).rejects.toThrow(/no subagent provider/)
|
||||
|
||||
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
|
||||
expect(published).toEqual([])
|
||||
@@ -432,7 +386,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
parent.send([{ type: 'text', text: 'hi' }])
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
persona: 'You are the tersest test runner.',
|
||||
@@ -455,7 +409,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
name: 'forbidden_tool', description: 'global', parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const run = await start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['forbidden_tool'] },
|
||||
@@ -475,13 +429,11 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const before = ctx.agents.list().length
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
await expect(start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
|
||||
await run.dispose()
|
||||
})).rejects.toThrow(/unknown global tool "no_such_tool"/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -501,12 +453,10 @@ 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'))
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
await expect(start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent: parentHandle.agent,
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/inactive context/)
|
||||
await run.dispose()
|
||||
})).rejects.toThrow(/inactive context/)
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
expect(published).toEqual([])
|
||||
@@ -524,18 +474,16 @@ describe('dsh-subagent-spawn', () => {
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
const starting = start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }],
|
||||
parent: parentHandle.agent,
|
||||
})
|
||||
// The factory has entered its awaited unpublished setup transaction. Parent
|
||||
// ownership was installed before that await, so disposal wins without an
|
||||
// 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(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
await run.dispose()
|
||||
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user