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

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	docs/cordis-catalog/services.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/core/session/README.i18n.yaml
#	packages/subagent/subagent-inprocess/README.i18n.yaml
#	packages/ui/user-approval/src/index.ts
This commit is contained in:
kingwl
2026-07-28 11:19:46 +08:00
1141 changed files with 36230 additions and 16667 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 6563fbda00c8fc9a22be5f34ffb327d5876e1c05
README.zh.md: f0cd20b3a5c40cb76141ff1fb0b6cd1f5f6b59f5
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 824672b7d94650ab1e2b00ed907014c14db44b01
README.zh.md: af98050220e4800d967e1efd348be0a7797f4d18

View File

@@ -40,7 +40,7 @@ Depth enforcement is internal to `startInProcessRun`: it reads the parent depth
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
- A monotonic tool guard blocks later calls after capture, and the structured-output execution's `concludeTurn()` marker ends the turn after the result commits.
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.

View File

@@ -40,7 +40,7 @@
- 一个顺序为 190 的系统提示词段会告诉子 agent,该工具调用就是终态答案。
- 两项贡献都是普通的子 agent 作用域注册。专家级 `system-prompt/assemble` 监听器可以替换它们,因此负责为该子 agent 保留结构化输出协议。
- `tools/result` 观察器只会在该次执行的权威最终工具结果成功后提交暂存值;Code Mode 子分派外层的 `run_code` 结果也包括在内。
- 单调工具防护会在捕获值后阻止后续调用,`agent/turn-stop` 则在结构化结果提交后结束轮次。
- 单调工具防护会在捕获值后阻止后续调用,结构化输出执行的 `concludeTurn()` 标记则在结果提交后结束轮次。
正常结束却始终未提交必需结构化值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。

View File

@@ -160,7 +160,7 @@ export async function startInProcessRun(
const result: Promise<SubagentResult> = (async () => {
try {
child.followup(request.prompt)
child.followup({ content: request.prompt, source: { kind: 'user' } })
await child.whenIdle()
return readResult(
child,

View File

@@ -5,15 +5,14 @@
* contribution is ordinary reconstructed request state.
*
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
* waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard
* then prevent later listeners or calls from reopening a completed structured run.
* waits for the enclosing `run_code` result. The terminal result marker and monotonic tool
* guard prevent later calls from reopening a completed structured run.
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
import type { Context } from 'cordis'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolRunContext } 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. */
@@ -83,7 +82,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
},
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
},
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
execute(args: unknown, exec: ToolRunContext): 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.
@@ -92,6 +91,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
// 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 })
exec.concludeTurn()
return Promise.resolve({ recorded: true })
},
})
@@ -102,13 +102,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// Stop the child's turn once its output is captured. This monotonic serial
// checkpoint runs after the ordinary continuation waterfall, its reason,
// and late-steering folding, so no ordering trick can resume a finished run.
childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined {
return captured === undefined ? undefined : { action: 'stop' }
})
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede

View File

@@ -186,7 +186,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
parent.followup([{ type: 'text', text: 'stage the session policy' }])
parent.followup({ content: [{ type: 'text', text: 'stage the session policy' }], source: { kind: 'user' } })
await parent.whenIdle()
const parentLogLength = parent.session.events.length
@@ -229,7 +229,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
},
textResponse('child done'),
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
@@ -264,9 +264,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('fork child done'),
textResponse('turn two done'),
)
parent.followup([{ type: 'text', text: 'turn one' }])
parent.followup({ content: [{ type: 'text', text: 'turn one' }], source: { kind: 'user' } })
await parent.whenIdle()
parent.followup([{ type: 'text', text: 'turn two: delegate' }])
parent.followup({ content: [{ type: 'text', text: 'turn two: delegate' }], source: { kind: 'user' } })
await parent.whenIdle()
const child = captured[0] as Agent
@@ -297,9 +297,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('race child done'),
textResponse('turn two done'),
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
parent.followup([{ type: 'text', text: 'delegate' }])
parent.followup({ content: [{ type: 'text', text: 'delegate' }], source: { kind: 'user' } })
await parent.whenIdle()
const child = captured[0] as Agent
@@ -329,9 +329,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
textResponse('child done'),
textResponse('parent done'),
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
parent.followup([{ type: 'text', text: 'delegate twice' }])
parent.followup({ content: [{ type: 'text', text: 'delegate twice' }], source: { kind: 'user' } })
await parent.whenIdle()
expect(captured).toHaveLength(2)
@@ -363,7 +363,7 @@ describe('inheritance survives prompt vetoes', () => {
},
// No child model entries: the blocked prompt closes a zero-step turn.
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
@@ -432,7 +432,7 @@ describe('what a blocked child experiences', () => {
},
textResponse('child done'),
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})
@@ -468,7 +468,7 @@ describe('what a blocked child experiences', () => {
}),
textResponse('child gave up'),
)
parent.followup([{ type: 'text', text: 'stage' }])
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
await parent.whenIdle()
const run = await startInProcessRun(spawnRequest(parent), {})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -116,8 +115,7 @@ describe('in-process structured output', () => {
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// Default continuation would run a second step after the tool call; the
// structured runtime's turn-continuation veto stops the turn instead.
// The structured tool marks its successful result as turn-concluding.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
@@ -219,63 +217,6 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
let wrapperInstalled = false
// Register before ready-only start: structured output is attached before session-start and the
// loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
// to the later terminal checkpoint.
ctx.on('agent/session-start', (child) => {
if (child === parent) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
return { action: 'continue' }
}, { prepend: true })
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
expect(adapter.requests).toHaveLength(1)
await run.dispose()
})
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
// folding would turn into continue. The terminal checkpoint must discard that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
return downstream
}, { prepend: true })
})
const result = await run.result
const child = ctx.agents.get(run.id)
expect(result.structured).toEqual({ answer: 9 })
expect(adapter.requests).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
@@ -522,7 +463,7 @@ describe('in-process structured output', () => {
textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
parent.followup([{ type: 'text', text: 'hello' }])
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
@@ -538,7 +479,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.followup([{ type: 'text', text: 'hello' }])
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
await parent.whenIdle()
// Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
@@ -552,7 +493,7 @@ describe('in-process structured output', () => {
// Child turn: must see it, with the run's schema.
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
parent.followup([{ type: 'text', text: 'hello' }])
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
@@ -630,7 +571,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.followup([{ type: 'text', text: 'q' }])
parent.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await parent.whenIdle()
const request = adapter.requests[0]!
expect(request.tools).toBeUndefined()

View File

@@ -79,6 +79,7 @@ describe('startInProcessRun', () => {
const result = await run.result
const child = ctx.agents.get(run.id)!
expect(injected).toBe(true)
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'completed' } } })
expect(result.stopReason).toBe('max-tokens')
@@ -87,7 +88,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.followup([{ type: 'text', text: 'parent question' }])
parent.followup({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = await startInProcessRun(request(parent), { seed })