Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks

# Conflicts:
#	docs/architecture.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/tool-catalog.md
#	packages/bash/tool-bash/tests/integration.spec.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/core/agent-core/tests/agent-core.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/index.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/index.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/subagent/subagent/README.md
#	packages/subagent/subagent/src/index.ts
#	packages/subagent/tool-subagent/README.md
#	packages/subagent/tool-subagent/src/index.ts
#	pnpm-lock.yaml
#	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-07-13 15:57:17 +08:00
248 changed files with 14796 additions and 5140 deletions

View File

@@ -139,12 +139,11 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'weird',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('weird-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
cancel() {},
dispose: async () => {},
}),
})
@@ -165,14 +164,13 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'capture',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('capture-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -195,14 +193,13 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'bare',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request) => {
start: async (request) => {
seen = request
return {
id: AgentId('bare-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => {},
}
},
@@ -245,7 +242,7 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false)
const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false)
await ctx.plugin(tool, { provider: 'mock' })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation')
@@ -253,7 +250,7 @@ describe('dsh-tool-subagent', () => {
await backend.dispose()
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
// Backend reloads with a DIFFERENT contract: the wording is re-derived
// Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived
// from the fresh provider, not served stale from the first mount.
await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true })
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
@@ -298,7 +295,7 @@ describe('dsh-tool-subagent', () => {
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
})
it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {
it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => {
const ctx = await setup({ provider: 'mock' })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('does not see this conversation')
@@ -306,7 +303,7 @@ describe('dsh-tool-subagent', () => {
expect(props['prompt']!.description).toContain('include everything it needs')
})
it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => {
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('INHERITS this conversation')
@@ -325,12 +322,11 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -348,12 +344,11 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => ({
start: async () => ({
id: AgentId('spy-child'),
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
cancel() {},
dispose: async () => void disposed(),
}),
})
@@ -364,7 +359,7 @@ describe('dsh-tool-subagent', () => {
expect(disposed).toHaveBeenCalledTimes(1)
})
it('bridges the tool abort signal to run.cancel()', async () => {
it('passes the tool abort signal as the provider cancellation channel', async () => {
const cancelled = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -372,18 +367,19 @@ describe('dsh-tool-subagent', () => {
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
start: async (request) => {
if (request.signal.aborted) throw new Error('start aborted')
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
request.signal.addEventListener('abort', () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId('spy-child'),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
},
@@ -392,12 +388,7 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
// Abort AFTER the tool body has had a chance to register its abort listener
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
// body runs, so the listener is not registered synchronously). A few
// microtask turns let execute() reach `addEventListener('abort')`, so this
// exercises the LIVE onAbort bridge — distinct from the already-aborted
// sync path the next test covers.
// Let provider.start install its listener before aborting.
await Promise.resolve()
await Promise.resolve()
controller.abort()
@@ -406,33 +397,19 @@ describe('dsh-tool-subagent', () => {
expect(result.isError).toBe(true)
})
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
// `addEventListener('abort')` does not fire for a signal already aborted
// before the listener is added, so a step cancelled before the tool ran
// would never reach the child unless the bridge re-checks `signal.aborted`.
// A provider that leans only on the abort EVENT (this spy never inspects
// request.signal) proves the bridge itself must cancel.
const cancelled = vi.fn()
it('passes an already-aborted signal so provider startup rejects', async () => {
const sawAborted = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'spy',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
return {
id: AgentId('spy-child'),
result,
cancel: () => {
cancelled()
resolveResult({ output: [], stopReason: 'aborted' })
},
dispose: async () => {},
}
start: async (request) => {
if (request.signal.aborted) sawAborted()
throw new Error('start aborted')
},
})
await ctx.plugin(tool, { provider: 'spy' })
@@ -440,7 +417,7 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
controller.abort() // already aborted BEFORE the tool runs
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
expect(cancelled).toHaveBeenCalledTimes(1)
expect(sawAborted).toHaveBeenCalledTimes(1)
expect(result.isError).toBe(true)
})
@@ -479,12 +456,144 @@ describe('dsh-tool-subagent', () => {
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
it('passes persona/toolFilter/maxDepth config through to the start request', async () => {
let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'capture2',
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
inheritsParentContext: false,
start: async (request) => {
seen = request
return {
id: AgentId('capture2-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
},
})
await ctx.plugin(tool, {
provider: 'capture2',
persona: 'You are the child.',
toolFilter: { deny: ['subagent'] },
maxDepth: 2,
})
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen?.persona).toBe('You are the child.')
expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] })
expect(seen?.maxDepth).toBe(2)
})
it.each([
{ label: 'null', value: null as unknown as number },
{ label: 'a string', value: '1' as unknown as number },
{ label: 'NaN', value: Number.NaN },
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
{ label: 'a negative integer', value: -1 },
{ label: 'a fractional number', value: 1.5 },
{ label: 'negative zero', value: -0 },
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
])('rejects maxDepth=$label when the plugin loads', async ({ value }) => {
await expect(setup({ provider: 'mock', maxDepth: value }))
.rejects.toThrow()
})
it('validates maxDepth when apply() is invoked directly without Schemastery', () => {
const ctx = new Context()
expect(() => {
tool.apply(ctx, {
provider: 'unused',
maxDepth: Number.NaN,
})
}).toThrow('subagent maxDepth must be a non-negative safe integer')
})
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'capture3',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
inheritsParentContext: false,
start: async (request) => {
seen = request
return {
id: AgentId('capture3-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
},
})
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen?.toolFilter).toEqual({ deny: ['subagent'] })
expect(seen?.toolFilter).not.toHaveProperty('allow')
})
it('an omitted agentOptions does not materialize an empty object onto the request', async () => {
// Same schemastery trap as toolFilter, adjacent field: an omitted
// `agentOptions` config key materializes `{}` without the forced default,
// which reads as present and puts a dishonest `agentOptions: {}` on every
// start request.
let seen: { agentOptions?: unknown } | undefined
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'capture4',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async (request) => {
seen = request
return {
id: AgentId('capture4-child'),
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
dispose: async () => {},
}
},
})
await ctx.plugin(tool, { provider: 'capture4' })
await callSubagent(ctx, { description: 'd', prompt: 'p' })
expect(seen).toBeDefined()
expect(seen).not.toHaveProperty('agentOptions')
})
it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
ctx.subagents.registerProvider({
name: 'p',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
inheritsParentContext: false,
start: () => { throw new Error('unreachable') },
})
const fiber = ctx.plugin(tool, { provider: 'p', toolFilter: {} })
await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/)
})
})
describe('dsh-tool-subagent background mode', () => {
/** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
/** A live parent with a dedicated scope fiber for structural task cleanup. */
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
const scopeFiber = ctx.plugin(() => {})
const agent = {
id: AgentId(`agent-${sessionId}`),
ctx: scopeFiber.ctx,
inject,
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(agent)
return agent
}
@@ -540,22 +649,89 @@ describe('dsh-tool-subagent background mode', () => {
expect(text(result)).toContain('subagent delegation aborted')
})
it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
// A provider whose runs settle only on cancel — the mock settles on a
it('settles an asynchronous provider-start failure as a failed task', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
ctx.subagents.registerProvider({
name: 'broken-start',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async () => { throw new Error('setup failed') },
})
tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' })
const started = await ctx.tools.execute({
callId: CallId('broken-start'),
name: 'subagent_broken',
arguments: { description: 'broken', prompt: 'p', run_in_background: true },
agent: parent,
})
expect(text(started)).toBe('started background subagent task subagent-1')
const output = await ctx.tools.execute({
callId: CallId('broken-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
agent: parent,
})
expect(text(output)).toContain('[status: failed, Error: setup failed]')
})
it('kills a subagent task while provider readiness is still pending', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
ctx.subagents.registerProvider({
name: 'pending-start',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: request => new Promise((_resolve, reject) => {
request.signal.addEventListener('abort', () => { reject(new Error('startup aborted')) }, { once: true })
}),
})
tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' })
await ctx.tools.execute({
callId: CallId('pending-start'),
name: 'subagent_pending',
arguments: { description: 'pending', prompt: 'p', run_in_background: true },
agent: parent,
})
await ctx.tools.execute({
callId: CallId('pending-kill'),
name: 'task_kill',
arguments: { task_id: 'subagent-1', reason: 'no longer needed' },
agent: parent,
})
const output = await ctx.tools.execute({
callId: CallId('pending-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
agent: parent,
})
expect(text(output)).toBe('(no new output)\n[status: killed]')
})
it('forwards task_kill reasons through the run signal (and defaults one when absent)', async () => {
// A provider whose runs settle only on signal abort — the mock settles on a
// microtask, too fast to observe a LIVE kill through the real tools.
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
const parent = ownerAgent(ctx, 'sess-parent')
const cancels: (string | undefined)[] = []
let starts = 0
ctx.subagents.registerProvider({
name: 'hanging',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
start: async (request) => {
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
const id = AgentId(`hang-${++starts}`)
const result = new Promise<{ output: { type: 'text'; text: string }[]; stopReason: 'aborted' }>((res) => { settle = res })
request.signal.addEventListener('abort', () => {
cancels.push(typeof request.signal.reason === 'string' ? request.signal.reason : undefined)
settle({ output: [], stopReason: 'aborted' })
}, { once: true })
return {
id: AgentId(`hang-${cancels.length}`),
result: new Promise((res) => { settle = res }),
cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
id,
result,
dispose: () => Promise.resolve(),
}
},
@@ -597,7 +773,6 @@ describe('dsh-tool-subagent background mode', () => {
const completed = await settleRun({
id: AgentId('child-1'),
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
cancel() {},
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
@@ -609,11 +784,27 @@ describe('dsh-tool-subagent background mode', () => {
const failed = await settleRun({
id: AgentId('child-2'),
result: Promise.reject(new Error('transport gone')),
cancel() {},
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
const disposeFailed = await settleRun({
id: AgentId('child-3'),
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const bothFailed = await settleRun({
id: AgentId('child-4'),
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
})
})
})
@@ -625,20 +816,25 @@ describe('background preflight failure (no orphaned child, by construction)', ()
const ctx = await setup({ provider: 'mock' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
const scopeFiber = ctx.plugin(() => {})
const parent = {
id: AgentId('agent-sess-p'),
ctx: scopeFiber.ctx,
inject: () => {},
session: { header: { version: 0, id: 'sess-p', createdAt: 0 } },
} as unknown as Agent
ctx.agents.register(parent)
let starts = 0
ctx.subagents.registerProvider({
name: 'probe',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => {
start: async () => {
starts += 1
return {
id: AgentId('probe-child'),
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
cancel() {},
dispose: () => Promise.resolve(),
}
},