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

@@ -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 })