Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance

# Conflicts:
#	docs/event-producer-consumer.md
This commit is contained in:
kingwl
2026-07-25 11:01:40 +08:00
1570 changed files with 67657 additions and 14316 deletions

View File

@@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
])
// Parent does one real turn first, so the fork has a completed turn to seed.
parent.send([{ type: 'text', text: 'parent q1' }])
parent.followup([{ type: 'text', text: 'parent q1' }])
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
@@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
await forkRun.dispose()
// The parent is unaffected and keeps working after both delegations.
parent.send([{ type: 'text', text: 'parent q2' }])
parent.followup([{ type: 'text', text: 'parent q2' }])
await parent.whenIdle()
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')

View File

@@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => {
it('seeds every completed parent turn through the last turn/end', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
parent.followup([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'q2' }])
parent.followup([{ type: 'text', text: 'q2' }])
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
@@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => {
// Parent runs one turn, then we fork. The child's seeded log should contain
// the parent's first turn, and the child should run its own new turn on top.
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }])
parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const parentPrefixLen = parent.session.events.length
@@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => {
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
// balanced first turn; including the open turn would fail invariant replay during start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
parent.followup([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
// Start a second turn that hangs (open turn/start + open step, never ends).
parent.send([{ type: 'text', text: 'q2' }])
parent.followup([{ type: 'text', text: 'q2' }])
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
// Forking now must NOT throw (the open second turn is excluded from the seed).
@@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => {
textResponse('parent turn'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
])
parent.send([{ type: 'text', text: 'warm up' }])
parent.followup([{ type: 'text', text: 'warm up' }])
await parent.whenIdle()
const run = await start(ctx, 'fork', {
prompt: [{ type: 'text', text: 'report structured' }],
@@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => {
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }])
parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })

View File

@@ -11,7 +11,7 @@ The driver follows this sequence:
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
@@ -62,7 +62,7 @@ Independent of the parent request cache. The child's later history is append-onl
#### What the model sees
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Its canonical acknowledgement is `{ recorded: true }`, rendered as `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `<tool>` is not executed``.
##### Structured-output instruction

View File

@@ -174,7 +174,7 @@ export async function startInProcessRun(
const result: Promise<SubagentResult> = (async () => {
try {
child.send(request.prompt)
child.followup(request.prompt)
await child.whenIdle()
return readResult(
child,

View File

@@ -12,9 +12,9 @@
import type { Context } from 'cordis'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
@@ -44,10 +44,10 @@ export interface StructuredAttachment {
* its creation window. Child disposal removes every registration.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).
* `assertObjectJsonSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment {
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* authoritative `tools/result` notification. The execution object's identity
@@ -74,8 +74,17 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
childCtx.tools.register({
...schemaEntry,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
output: {
schema: {
type: 'object',
properties: { recorded: { type: 'boolean', const: true } },
required: ['recorded'],
additionalProperties: false,
},
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
},
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
const violations = validateJsonSchemaValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
@@ -83,7 +92,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
// waterfalls may still turn the success into an error. ToolRegistry has
// already frozen model-bound arguments at the actual input boundary.
staged.set(exec, { value: args })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
return Promise.resolve({ recorded: true })
},
})

View File

@@ -111,7 +111,17 @@ function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-
name: 'delegate',
description: 'delegate a task to an in-process child (test scaffold)',
parameters: { fork: { type: 'boolean', description: 'seed the child with the completed-turn prefix' } },
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
stopReason: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: `child:${(value).stopReason}` }],
},
async execute(args, exec) {
const caller = exec.agent
if (caller === undefined) throw new Error('delegate scaffold requires a calling agent')
const events = caller.session.events
@@ -129,7 +139,7 @@ function registerDelegate(ctx: Context, captured: Agent[], raceSwitch?: 'danger-
captured.push(run.localAgent as Agent)
const result = await run.result
await run.dispose()
return [{ type: 'text', text: `child:${result.stopReason}` }]
return { stopReason: result.stopReason }
},
}))
}
@@ -174,7 +184,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
parent.send([{ type: 'text', text: 'stage the session policy' }])
parent.followup([{ type: 'text', text: 'stage the session policy' }])
await parent.whenIdle()
const parentLogLength = parent.session.events.length
@@ -228,9 +238,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('fork child done'),
textResponse('turn two done'),
)
parent.send([{ type: 'text', text: 'turn one' }])
parent.followup([{ type: 'text', text: 'turn one' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'turn two: delegate' }])
parent.followup([{ type: 'text', text: 'turn two: delegate' }])
await parent.whenIdle()
const child = captured[0] as Agent
@@ -258,9 +268,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('fork child done'),
textResponse('turn two done'),
)
parent.send([{ type: 'text', text: 'turn one' }])
parent.followup([{ type: 'text', text: 'turn one' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'turn two: delegate' }])
parent.followup([{ type: 'text', text: 'turn two: delegate' }])
await parent.whenIdle()
const child = captured[0] as Agent
@@ -290,9 +300,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('race child done'),
textResponse('turn two done'),
)
parent.send([{ type: 'text', text: 'stage' }])
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'delegate' }])
parent.followup([{ type: 'text', text: 'delegate' }])
await parent.whenIdle()
const child = captured[0] as Agent
@@ -322,9 +332,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('child done'),
textResponse('parent done'),
)
parent.send([{ type: 'text', text: 'stage' }])
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
parent.send([{ type: 'text', text: 'delegate twice' }])
parent.followup([{ type: 'text', text: 'delegate twice' }])
await parent.whenIdle()
expect(captured).toHaveLength(2)
@@ -356,7 +366,7 @@ describe('inheritance survives prompt vetoes', () => {
},
// No child model entries: the blocked prompt closes a zero-step turn.
)
parent.send([{ type: 'text', text: 'stage' }])
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
@@ -422,7 +432,7 @@ describe('what a blocked child experiences', () => {
},
textResponse('child done'),
)
parent.send([{ type: 'text', text: 'stage' }])
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
@@ -458,7 +468,7 @@ describe('what a blocked child experiences', () => {
}),
textResponse('child gave up'),
)
parent.send([{ type: 'text', text: 'stage' }])
parent.followup([{ type: 'text', text: 'stage' }])
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})

View File

@@ -10,8 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
@@ -39,7 +39,7 @@ interface SetupOptions {
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
}
const SCHEMA: StructuredOutputSchema = {
const SCHEMA: ObjectJsonSchema = {
type: 'object',
properties: { answer: { type: 'number' }, note: { type: 'string' } },
required: ['answer'],
@@ -97,10 +97,15 @@ describe('in-process structured output', () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
let acknowledgement: unknown
ctx.on('tools/result', (exec, toolResult) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
expect(acknowledgement).toEqual({ recorded: true })
await run.dispose()
})
@@ -131,15 +136,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
@@ -159,15 +164,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
@@ -196,15 +201,15 @@ describe('in-process structured output', () => {
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
@@ -332,17 +337,17 @@ describe('in-process structured output', () => {
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
}))).rejects.toThrow(/unsupported output schema/)
outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema/)
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
@@ -450,8 +455,10 @@ describe('in-process structured output', () => {
expect(result.structured).toEqual({ answer: 12 })
const request = adapter.requests[0]!
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
expect(request.system).toContain('declare const tools:')
expect(request.system).toContain('structured_output(args:')
expect(request.system).toContain('interface ToolArgsMap')
expect(request.system).toContain('interface ToolOutputMap')
expect(request.system).toContain('recorded: true;')
expect(request.system).toContain('Promise<ToolOutputMap[K]>')
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose()
})
@@ -515,7 +522,7 @@ describe('in-process structured output', () => {
textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
parent.send([{ type: 'text', text: 'hello' }])
parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
@@ -531,7 +538,7 @@ describe('in-process structured output', () => {
describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
parent.send([{ type: 'text', text: 'hello' }])
parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
// Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
@@ -545,7 +552,7 @@ describe('in-process structured output', () => {
// Child turn: must see it, with the run's schema.
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
parent.send([{ type: 'text', text: 'hello' }])
parent.followup([{ type: 'text', text: 'hello' }])
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
@@ -559,7 +566,7 @@ describe('in-process structured output', () => {
})
it('two concurrent structured children each see their OWN schema', async () => {
const otherSchema: StructuredOutputSchema = {
const otherSchema: ObjectJsonSchema = {
type: 'object',
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
required: ['verdict'],
@@ -601,12 +608,12 @@ describe('in-process structured output', () => {
])
// A global tool sorts lexicographically after structured_output, while a
// global section above the 190 band follows the capture instruction.
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'zz_probe',
description: 'probe',
parameters: { type: 'object', properties: {} },
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
})
}))
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
@@ -623,7 +630,7 @@ describe('in-process structured output', () => {
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([textResponse('plain')])
parent.send([{ type: 'text', text: 'q' }])
parent.followup([{ type: 'text', text: 'q' }])
await parent.whenIdle()
const request = adapter.requests[0]!
expect(request.tools).toBeUndefined()
@@ -659,7 +666,7 @@ describe('in-process structured output', () => {
agent: parent,
})
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('UNKNOWN_TOOL')
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
@@ -671,7 +678,7 @@ describe('in-process structured output', () => {
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('UNKNOWN_TOOL')
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a failed execution stage is discarded and never promoted by a later call', async () => {

View File

@@ -68,7 +68,7 @@ describe('startInProcessRun', () => {
turn,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
})
session.append('context/message', {
session.append('user/message', {
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}, { surfaceOp: 'append' })
@@ -87,7 +87,7 @@ describe('startInProcessRun', () => {
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.send([{ type: 'text', text: 'parent question' }])
parent.followup([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = await startInProcessRun(request(parent), { seed })

View File

@@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
ctx = await spawnHarness(workdir)
const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
parent.send([{ type: 'text', text:
parent.followup([{ type: 'text', text:
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
+ 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
+ 'After the subagent finishes, tell me it is done.' }])

View File

@@ -13,6 +13,7 @@ import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-sub
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
type Script = ConstructorParameters<typeof MockAdapter>[0]
@@ -94,7 +95,7 @@ describe('dsh-subagent-spawn', () => {
it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
// Drive the parent through one real turn so it has history, THEN spawn.
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
parent.send([{ type: 'text', text: 'parent prompt' }])
parent.followup([{ type: 'text', text: 'parent prompt' }])
await parent.whenIdle()
const parentEventCount = parent.session.events.length
expect(parentEventCount).toBeGreaterThan(0)
@@ -187,10 +188,10 @@ describe('dsh-subagent-spawn', () => {
expect(published).toEqual([])
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
it('a cancel from agent/inbox/enqueue 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') })
ctx.on('agent/inbox/enqueue', () => { 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: [] })
@@ -371,7 +372,7 @@ describe('dsh-subagent-spawn', () => {
textResponse('parent answer'),
textResponse('child answer'),
])
parent.send([{ type: 'text', text: 'hi' }])
parent.followup([{ type: 'text', text: 'hi' }])
await parent.whenIdle()
const run = await start(ctx, 'spawn', {
@@ -393,10 +394,10 @@ describe('dsh-subagent-spawn', () => {
toolCallResponse('c1', 'forbidden_tool', {}),
textResponse('done'),
])
ctx.tools.register({
ctx.tools.register(defineContentToolFixture({
name: 'forbidden_tool', description: 'global', parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'ran' }]),
})
}))
const run = await start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,

View File

@@ -32,7 +32,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -242,7 +242,7 @@ export class SubagentService extends Service {
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
const parent = request.parent
const run = await provider.start(request)

View File

@@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -73,11 +73,11 @@ export interface SubagentStartRequest {
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
* a successful child returns the matching value as {@link SubagentResult.structured}.
*/
readonly outputSchema?: StructuredOutputSchema
readonly outputSchema?: ObjectJsonSchema
/**
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe

View File

@@ -6,9 +6,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md).
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).

View File

@@ -12,6 +12,7 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
@@ -95,6 +96,16 @@ function outputText(blocks: ContentBlock[]): string {
.join('')
}
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
function outputValueText(values: JsonValue[]): string {
return values
.filter((value): value is { type: 'text'; text: string } =>
typeof value === 'object' && value !== null && !Array.isArray(value)
&& value.type === 'text' && typeof value.text === 'string')
.map(value => value.text)
.join('')
}
/** A non-`completed` stop reason means the child did not finish cleanly. */
function stopReasonError(result: SubagentResult): string | undefined {
switch (result.stopReason) {
@@ -268,7 +279,36 @@ export function apply(ctx: Context, config: Config): void {
},
} : {},
},
async execute(args, exec): Promise<ContentBlock[]> {
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
runId: { type: 'string', required: true },
output: { type: 'array', required: true, items: { type: 'json' } },
},
},
],
},
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background subagent task ${value.taskId}`
: outputValueText(value.output),
}],
},
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers provide no parent for delegation ownership.
@@ -305,7 +345,7 @@ export function apply(ctx: Context, config: Config): void {
}
},
})
return [{ type: 'text', text: `started background subagent task ${id}` }]
return { kind: 'background' as const, taskId: id }
}
const request = startRequest(
@@ -324,7 +364,13 @@ export function apply(ctx: Context, config: Config): void {
// The registry converts this throw to isError; partial output is not success.
throw new Error(error)
}
return [{ type: 'text', text: outputText(result.output) }]
return {
kind: 'foreground' as const,
runId: run.id,
// Content blocks already cross durable JSON boundaries elsewhere;
// the registry performs the authoritative lossless snapshot here.
output: result.output as unknown as JsonValue[],
}
} finally {
// Dispose before returning so no child session outlives the call.
await run.dispose()

View File

@@ -65,6 +65,12 @@ describe('dsh-tool-subagent', () => {
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected subagent success')
expect(result.value).toEqual({
kind: 'foreground',
runId: 'scripted-subagent:mock:parent-1',
output: [{ type: 'text', text: 'child says hi' }],
})
expect(text(result)).toBe('child says hi')
})
@@ -445,7 +451,10 @@ describe('dsh-tool-subagent', () => {
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
expect(sawAborted).not.toHaveBeenCalled()
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
})
it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
@@ -643,6 +652,8 @@ describe('dsh-tool-subagent background mode', () => {
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
expect(start.isError).toBe(false)
if (start.isError) throw new Error('expected background subagent success')
expect(start.value).toEqual({ kind: 'background', taskId: 'subagent-1' })
expect(text(start)).toBe('started background subagent task subagent-1')
const collected = await ctx.tools.execute({
@@ -679,7 +690,10 @@ describe('dsh-tool-subagent background mode', () => {
controller.abort()
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(result.error).toEqual({
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(text(result)).toBe('Error: tool call aborted before dispatch')
})