feat(subagent): catalog one-shot child sessions
This commit is contained in:
@@ -11,7 +11,12 @@ import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
|
||||
|
||||
export const name = 'subagent-acp'
|
||||
@@ -143,7 +148,7 @@ class AcpProvider implements SubagentProvider {
|
||||
|
||||
constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
const spec: AcpRunSpec = {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
|
||||
@@ -64,6 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
label: 'reply pong',
|
||||
prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }],
|
||||
parent: fakeParent,
|
||||
signal: new AbortController().signal,
|
||||
@@ -95,6 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
label: 'write proof file',
|
||||
prompt: [{ type: 'text', text:
|
||||
'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt '
|
||||
+ 'in the current directory. Then reply DONE.' }],
|
||||
|
||||
@@ -27,7 +27,7 @@ const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url
|
||||
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
||||
|
||||
function request(text = 'p', signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
return { label: text, prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
}
|
||||
|
||||
interface SetupEnv {
|
||||
@@ -126,7 +126,9 @@ describe('child env layering (through the subprocess seam)', () => {
|
||||
// explicit entry merges after it and the child must see the value.
|
||||
const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' })
|
||||
const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('')
|
||||
@@ -208,7 +210,9 @@ describe('cwd resolution', () => {
|
||||
try {
|
||||
const ctx = await setup({ MOCK_ECHO_CWD: '1' })
|
||||
const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
// Line 1: where the child process actually ran; line 2: the workspace the
|
||||
@@ -229,7 +233,9 @@ describe('cwd resolution', () => {
|
||||
// A command that would create the sentinel if the child were ever spawned.
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
await expect(ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
}))
|
||||
.rejects.toThrow('no working directory')
|
||||
// Resolution failed BEFORE the process boundary — nothing was launched.
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
@@ -254,7 +260,9 @@ describe('cwd resolution', () => {
|
||||
env: { MOCK_ECHO_CWD: '1' },
|
||||
})
|
||||
const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
|
||||
const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
expect(text(result.output)).toBe(`${configured}\n${configured}`)
|
||||
@@ -350,7 +358,9 @@ describe('cwd resolution', () => {
|
||||
// re-introduce the launch-directory dependency this resolution removes.
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
await expect(ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
}))
|
||||
.rejects.toThrow('must be an absolute path')
|
||||
})
|
||||
|
||||
@@ -361,7 +371,9 @@ describe('cwd resolution', () => {
|
||||
try {
|
||||
const ctx = await setup({})
|
||||
const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
await expect(ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
}))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
@@ -377,7 +389,9 @@ describe('cwd resolution', () => {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
|
||||
const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
await expect(ctx.subagents.start('acp', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
}))
|
||||
.rejects.toThrow('not an accessible directory')
|
||||
expect(existsSync(sentinel)).toBe(false)
|
||||
} finally {
|
||||
|
||||
@@ -30,7 +30,7 @@ const fakeRuntime = fileURLToPath(new URL('../../../sdk/sdk-client/tests/fake-ru
|
||||
const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
|
||||
|
||||
function request(text = 'p', signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
return { label: text, prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
|
||||
}
|
||||
|
||||
/** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */
|
||||
@@ -441,7 +441,9 @@ describe('dsh-subagent-dsh-sdk provider', () => {
|
||||
it('fails loud when neither config cwd nor parent session cwd exists', async () => {
|
||||
const ctx = await setup()
|
||||
const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
await expect(ctx.subagents.start('dsh-sdk', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
|
||||
await expect(ctx.subagents.start('dsh-sdk', {
|
||||
label: 'p', prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal,
|
||||
}))
|
||||
.rejects.toThrow('no working directory for the child')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -14,9 +14,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
@@ -65,7 +65,7 @@ class ForkProvider implements SubagentProvider {
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(request, {
|
||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
||||
|
||||
@@ -22,8 +22,16 @@ async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
function start(
|
||||
ctx: Context,
|
||||
provider: string,
|
||||
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
|
||||
) {
|
||||
return ctx.subagents.start(provider, {
|
||||
label: request.label ?? 'child task',
|
||||
signal: request.signal ?? new AbortController().signal,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,8 +25,16 @@ async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function start(ctx: Context, provider: string, request: Omit<SubagentStartRequest, 'signal'> & { signal?: AbortSignal }) {
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
function start(
|
||||
ctx: Context,
|
||||
provider: string,
|
||||
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
|
||||
) {
|
||||
return ctx.subagents.start(provider, {
|
||||
label: request.label ?? 'child task',
|
||||
signal: request.signal ?? new AbortController().signal,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
/** A bare `stop` finish that streams no content → the turn ends `completed`
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/subagent/subagent-inprocess/README.md
|
||||
README.md: 0495b7cae003a8c280689c4bfdd991e0f6950569
|
||||
README.zh.md: 2e512ffd281c6334db925c97b110934bbcc19eef
|
||||
README.md: 800ba24b65cedbbff31008a94b45957d5a65657e
|
||||
README.zh.md: f6e0bd6fcb2340d195469ad57e2e96941c684fdd
|
||||
|
||||
@@ -11,7 +11,7 @@ This package is the shared run driver for the two in-process providers' one-shot
|
||||
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. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request.
|
||||
3. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
驱动器按以下顺序运行:
|
||||
|
||||
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
|
||||
2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
|
||||
2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时,以及一次性的 `agent/step` contribution;该 contribution 会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。
|
||||
3. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ import {
|
||||
resolveChildDepth,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentDescriptorData,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
@@ -72,16 +73,27 @@ function prePublicationAbort(): Error {
|
||||
return new Error('subagent request was aborted before child publication')
|
||||
}
|
||||
|
||||
/** Append one one-shot descriptor inside the child's initial turn before its first request. */
|
||||
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
|
||||
let appended = false
|
||||
childCtx.on('agent/step', (agent) => {
|
||||
if (appended) return
|
||||
appended = true
|
||||
agent.session.append('subagent/descriptor', descriptor)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish and drive one in-process one-shot child. Fulfillment means the agent
|
||||
* is already published in the registry; rejection means the agent factory's
|
||||
* creation transaction and any partially-created child have reached quiescence.
|
||||
* Every start appends its resolved descriptor inside the child's initial turn.
|
||||
* @param request - the trusted typed start request, including its required signal.
|
||||
* @param options - the optional fork seed.
|
||||
* @returns a ready holder-owned run.
|
||||
*/
|
||||
export async function startInProcessRun(
|
||||
request: SubagentStartRequest,
|
||||
request: ResolvedSubagentStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): Promise<SubagentRun> {
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
@@ -116,6 +128,7 @@ export async function startInProcessRun(
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
attachDescriptorAppend(childCtx, request.descriptor)
|
||||
}
|
||||
|
||||
const handle = await parent.ctx.agents.create({
|
||||
|
||||
@@ -14,6 +14,7 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
@@ -52,9 +53,15 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen
|
||||
|
||||
function spawnRequest(parent: Agent) {
|
||||
return {
|
||||
label: 'child task',
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
descriptor: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot',
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
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 SubagentService, {
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
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'
|
||||
@@ -69,7 +72,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(request, {}),
|
||||
start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}),
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
@@ -78,6 +81,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
|
||||
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
|
||||
return {
|
||||
label: 'produce the answer',
|
||||
prompt: [{ type: 'text', text: 'produce the answer' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
|
||||
@@ -9,7 +9,7 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
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 from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
|
||||
@@ -35,7 +35,17 @@ async function setup(script: Script, parentOptions: Partial<AgentOptions> = {})
|
||||
}
|
||||
|
||||
function request(parent: Agent, signal = new AbortController().signal) {
|
||||
return { prompt: [{ type: 'text' as const, text: 'child task' }], parent, signal }
|
||||
return {
|
||||
label: 'child task',
|
||||
prompt: [{ type: 'text' as const, text: 'child task' }],
|
||||
parent,
|
||||
signal,
|
||||
descriptor: snapshotSubagentDescriptor({
|
||||
mode: 'one-shot',
|
||||
provider: 'test',
|
||||
label: 'child task',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function text(blocks: readonly { type: string; text?: string }[]): string {
|
||||
|
||||
@@ -10,9 +10,9 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {
|
||||
ContinuableCreateSpec,
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
@@ -45,7 +45,7 @@ class SpawnProvider implements SubagentProvider {
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
// 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.
|
||||
|
||||
@@ -49,8 +49,16 @@ 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 })
|
||||
function start(
|
||||
ctx: Context,
|
||||
provider: string,
|
||||
request: Omit<SubagentStartRequest, 'label' | 'signal'> & { label?: string; signal?: AbortSignal },
|
||||
) {
|
||||
return ctx.subagents.start(provider, {
|
||||
label: request.label ?? 'child task',
|
||||
signal: request.signal ?? new AbortController().signal,
|
||||
...request,
|
||||
})
|
||||
}
|
||||
|
||||
/** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/subagent/subagent/README.md
|
||||
README.md: 0c93856bf973781254195b2f3869a54833bf83ac
|
||||
README.zh.md: aa4c58d01582863e702404d1c8f3d32d3db7f534
|
||||
README.md: df2582518e97bb38d124f8c33259c30b9d11d759
|
||||
README.zh.md: d788f30f31d92c3fd919b62891e9b03624f455f7
|
||||
|
||||
@@ -28,13 +28,13 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
|
||||
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
|
||||
| `list()` | Return provider names in insertion order. |
|
||||
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
|
||||
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuable children never enter through this operation. |
|
||||
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
|
||||
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct continuable children and per-child diagnostics in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
`SubagentStartRequest.label` is the short durable display label for a session-backed child. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the live child. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
|
||||
Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up is durable provenance retained on the delivered message and grants no authority.
|
||||
|
||||
@@ -53,7 +53,7 @@ Continuable creation is the optional `SubagentProvider.prepareContinuable?()` me
|
||||
|
||||
## The durable descriptor
|
||||
|
||||
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the declared composition before the child session exists, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Malformed current-version payloads fail before materialization; unsupported versions make the child non-resumable. The payload records the provider name, resolved child `agentOptions.provider`/`model`, and optional `persona`/`toolFilter` — explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. It omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (never captured for a continuable child). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction.
|
||||
The seam owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the durable creation label, provider name, and lifecycle `mode`. A `one-shot` descriptor stops there; a `continuable` descriptor additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime.
|
||||
|
||||
## Delegation depth
|
||||
|
||||
@@ -67,7 +67,7 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration.
|
||||
|
||||
## Continuable children and Activations
|
||||
|
||||
@@ -89,7 +89,9 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan without consulting the continuation manager, Agent registrations, Activations, or providers. It forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations require final durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -101,9 +103,10 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **ACP children remain one-shot** — an ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
|
||||
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
|
||||
- **No report delivery** — the MVP exposes no `report` tool, child-to-parent content delivery, or automatic parent wakeup; a completed child turn leaves its output in the durable child Session until a caller inspects that transcript or submits another authorized turn.
|
||||
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
|
||||
- **No subagent steering** — every continuation message opens a later FIFO turn, so a parent cannot redirect a turn already underway; the manager stores no current-turn controller state.
|
||||
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
|
||||
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
|
||||
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.
|
||||
|
||||
@@ -28,13 +28,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
|
||||
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
|
||||
| `list()` | 按插入顺序返回提供方名称。 |
|
||||
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。可继续子 agent 绝不通过此操作进入。 |
|
||||
| `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
|
||||
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出直接可继续 child 及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
|
||||
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
`SubagentStartRequest.label` 是由会话支撑的 child 所使用的简短持久化显示标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
|
||||
后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 是保留在所投递消息上的持久化来源,不授予任何权限。
|
||||
|
||||
@@ -53,7 +53,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 持久化描述符
|
||||
|
||||
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在子 agent 会话存在之前校验并分离声明的组合配置,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。格式错误的当前版本 payload 会在物化前失败;不受支持的版本会使子 agent 无法恢复。payload 记录提供方名称、已解析的子 agent `agentOptions.provider`/`model`,以及可选的 `persona`/`toolFilter`;这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。它省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(可继续子 agent 从不捕获它)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。
|
||||
该 seam 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个描述符,其中包含持久化创建标签、提供方名称与生命周期 `mode`。`one-shot` 描述符到此为止;`continuable` 描述符还会记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次激活的结果契约)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。
|
||||
|
||||
## 委派深度
|
||||
|
||||
@@ -67,7 +67,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。
|
||||
|
||||
## 可继续子 agent 与 Activation
|
||||
|
||||
@@ -89,7 +89,9 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释只读、实时优先的扫描结果,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。它会把调用方的取消信号转发给可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 要求最终持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -101,9 +103,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **ACP 子 agent 仍为一次性**:ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
|
||||
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,并按子 agent 声明继续执行功能,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
|
||||
- **无 report 投递**:MVP 不提供 `report` 工具、子到父的内容投递或自动唤醒父级;已完成的子 agent 轮次会把其输出留在持久化子 agent Session 中,直到调用方查看该 transcript 或提交另一个经授权的轮次。
|
||||
- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
|
||||
- **无 subagent steering**:每条后续消息都会开启后续 FIFO 轮次,因此父级无法重定向已经在进行的轮次;管理器不保存任何当前轮次控制器状态。
|
||||
- **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
|
||||
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。
|
||||
- **生命周期事件只供观察**:影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ContinuableStartSpec {
|
||||
* The delegation request. The manager reserves the stable child id, resolves
|
||||
* the durable descriptor, and composes the child itself.
|
||||
*/
|
||||
readonly request: Omit<SubagentStartRequest, 'signal' | 'outputSchema'>
|
||||
readonly request: Omit<SubagentStartRequest, 'label' | 'signal' | 'outputSchema'>
|
||||
/** Caller cancellation, owning the operation only until inbox acceptance. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -298,6 +298,7 @@ export class SubagentContinuationManager {
|
||||
const agentProvider = request.agentOptions?.provider ?? parent.options.provider
|
||||
const agentModel = request.agentOptions?.model ?? parent.options.model
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: spec.provider,
|
||||
label: spec.label,
|
||||
...agentProvider !== undefined ? { agentProvider } : {},
|
||||
@@ -587,7 +588,7 @@ export class SubagentContinuationManager {
|
||||
// which may carry an ANCESTOR's descriptor when the parent is itself a
|
||||
// continuable child.
|
||||
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
|
||||
if (descriptor === undefined) {
|
||||
if (descriptor === undefined || descriptor.mode !== 'continuable') {
|
||||
throw new SubagentError(
|
||||
`subagent "${childId}" has no supported continuation state and cannot be resumed; `
|
||||
+ 'do not retry send_message with this id',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The durable continuable-child descriptor: the versioned, model-hidden
|
||||
* `subagent/descriptor` session event that records a child's declared
|
||||
* composition so a known child id can be cold-resumed after its run — and its
|
||||
* process — are gone. Providers append it turn-enclosed in the child's initial
|
||||
* turn; the continuation manager folds it back on resume.
|
||||
* The durable subagent-child descriptor: the versioned, model-hidden
|
||||
* `subagent/descriptor` session event that identifies every session-backed
|
||||
* subagent and records whether it is one-shot or continuable. Continuable
|
||||
* descriptors additionally preserve the declared composition required for
|
||||
* cold resume. Providers append it turn-enclosed in the child's initial turn.
|
||||
*
|
||||
* The descriptor deliberately snapshots explicit fields rather than the
|
||||
* merge-extensible `AgentOptions` object: an unrelated extension value cannot
|
||||
@@ -23,11 +23,11 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Durable declared composition of a continuable subagent child, appended
|
||||
* once by the establishing provider inside the child's initial turn,
|
||||
* before its first request. Log-only: it carries no `surfaceOp`, never
|
||||
* enters model history, and the append-only log retains it when
|
||||
* compaction replaces surface history.
|
||||
* Durable identity and lifecycle mode of a session-backed subagent child,
|
||||
* appended once by the establishing provider inside the child's initial
|
||||
* turn, before its first request. Continuable records also carry their
|
||||
* resumable composition. Log-only: it carries no `surfaceOp`, never enters
|
||||
* model history, and survives compaction.
|
||||
*/
|
||||
'subagent/descriptor': SubagentDescriptorData
|
||||
}
|
||||
@@ -39,12 +39,14 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* Supporting another composition input is a deliberate version change, never
|
||||
* an implicit extra field.
|
||||
*/
|
||||
export const SUBAGENT_DESCRIPTOR_VERSION = 1
|
||||
export const SUBAGENT_DESCRIPTOR_VERSION = 2
|
||||
|
||||
/** The `subagent/descriptor` event payload — a continuable child's declared composition. */
|
||||
export interface SubagentDescriptorData {
|
||||
/** Fields shared by every supported `subagent/descriptor` payload. */
|
||||
interface SubagentDescriptorBase {
|
||||
/** Descriptor format version ({@link SUBAGENT_DESCRIPTOR_VERSION}). */
|
||||
readonly version: number
|
||||
/** Whether the child is a terminal one-shot run or a resumable conversation. */
|
||||
readonly mode: 'one-shot' | 'continuable'
|
||||
/** The `ctx.subagents` provider name that established the child. */
|
||||
readonly provider: string
|
||||
/**
|
||||
@@ -53,6 +55,16 @@ export interface SubagentDescriptorData {
|
||||
* replaying parent tool results or exposing the child prompt.
|
||||
*/
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
/** A session-backed subagent that cannot be cold-resumed after its run. */
|
||||
export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {
|
||||
readonly mode: 'one-shot'
|
||||
}
|
||||
|
||||
/** A session-backed subagent whose declared composition supports cold resume. */
|
||||
export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {
|
||||
readonly mode: 'continuable'
|
||||
/** Resolved child `agentOptions.provider`, when one was declared. */
|
||||
readonly agentProvider?: string
|
||||
/** Resolved child `agentOptions.model`, when one was declared. */
|
||||
@@ -63,12 +75,29 @@ export interface SubagentDescriptorData {
|
||||
readonly toolFilter?: ToolRestriction
|
||||
}
|
||||
|
||||
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
|
||||
export interface SubagentDescriptorInput {
|
||||
/** The supported durable subagent identity and optional continuation composition. */
|
||||
export type SubagentDescriptorData =
|
||||
| OneShotSubagentDescriptorData
|
||||
| ContinuableSubagentDescriptorData
|
||||
|
||||
/** Fields shared by descriptor snapshot inputs. */
|
||||
interface SubagentDescriptorInputBase {
|
||||
/** Whether the child is a terminal one-shot run or a resumable conversation. */
|
||||
readonly mode: 'one-shot' | 'continuable'
|
||||
/** The `ctx.subagents` provider name that will establish the child. */
|
||||
readonly provider: string
|
||||
/** The initial delegation's short `description`, the durable creation label. */
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
/** Input for a one-shot child's durable identity. */
|
||||
export interface OneShotSubagentDescriptorInput extends SubagentDescriptorInputBase {
|
||||
readonly mode: 'one-shot'
|
||||
}
|
||||
|
||||
/** Input for a continuable child's durable identity and resumable composition. */
|
||||
export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorInputBase {
|
||||
readonly mode: 'continuable'
|
||||
/** Requested child `agentOptions.provider`. */
|
||||
readonly agentProvider?: string
|
||||
/** Requested child `agentOptions.model`. */
|
||||
@@ -79,10 +108,20 @@ export interface SubagentDescriptorInput {
|
||||
readonly toolFilter?: ToolRestriction
|
||||
}
|
||||
|
||||
const DESCRIPTOR_KEYS = new Set([
|
||||
/** Inputs {@link snapshotSubagentDescriptor} validates and detaches. */
|
||||
export type SubagentDescriptorInput =
|
||||
| OneShotSubagentDescriptorInput
|
||||
| ContinuableSubagentDescriptorInput
|
||||
|
||||
const DESCRIPTOR_BASE_KEYS = [
|
||||
'version',
|
||||
'mode',
|
||||
'provider',
|
||||
'label',
|
||||
] as const
|
||||
const ONE_SHOT_DESCRIPTOR_KEYS = new Set(DESCRIPTOR_BASE_KEYS)
|
||||
const CONTINUABLE_DESCRIPTOR_KEYS = new Set([
|
||||
...DESCRIPTOR_BASE_KEYS,
|
||||
'agentProvider',
|
||||
'agentModel',
|
||||
'persona',
|
||||
@@ -155,7 +194,15 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
}
|
||||
if (version !== SUBAGENT_DESCRIPTOR_VERSION) return undefined
|
||||
|
||||
assertKnownKeys(value, DESCRIPTOR_KEYS, 'payload')
|
||||
const mode = value['mode']
|
||||
if (mode !== 'one-shot' && mode !== 'continuable') {
|
||||
throw new Error('persisted subagent descriptor mode must be "one-shot" or "continuable"')
|
||||
}
|
||||
assertKnownKeys(
|
||||
value,
|
||||
mode === 'one-shot' ? ONE_SHOT_DESCRIPTOR_KEYS : CONTINUABLE_DESCRIPTOR_KEYS,
|
||||
'payload',
|
||||
)
|
||||
const provider = value['provider']
|
||||
if (typeof provider !== 'string') {
|
||||
throw new Error('persisted subagent descriptor provider must be a string')
|
||||
@@ -164,6 +211,14 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
if (typeof label !== 'string') {
|
||||
throw new Error('persisted subagent descriptor label must be a string')
|
||||
}
|
||||
if (mode === 'one-shot') {
|
||||
return {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode,
|
||||
provider,
|
||||
label,
|
||||
}
|
||||
}
|
||||
const agentProvider = optionalString(value, 'agentProvider')
|
||||
const agentModel = optionalString(value, 'agentModel')
|
||||
const persona = optionalString(value, 'persona')
|
||||
@@ -172,6 +227,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
: undefined
|
||||
return {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode,
|
||||
provider,
|
||||
label,
|
||||
...agentProvider !== undefined ? { agentProvider } : {},
|
||||
@@ -190,15 +246,32 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef
|
||||
* @returns the versioned, detached descriptor payload.
|
||||
* @throws when a field is not losslessly JSON-serializable.
|
||||
*/
|
||||
export function snapshotSubagentDescriptor(
|
||||
input: OneShotSubagentDescriptorInput,
|
||||
): OneShotSubagentDescriptorData
|
||||
/**
|
||||
* Validate and detach a continuable descriptor input.
|
||||
* @param input - the caller-collected continuable composition fields.
|
||||
* @returns the versioned, detached continuable descriptor payload.
|
||||
* @throws when a field is not losslessly JSON-serializable.
|
||||
*/
|
||||
export function snapshotSubagentDescriptor(
|
||||
input: ContinuableSubagentDescriptorInput,
|
||||
): ContinuableSubagentDescriptorData
|
||||
export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): SubagentDescriptorData {
|
||||
const candidate: SubagentDescriptorData = {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: input.mode,
|
||||
provider: input.provider,
|
||||
label: input.label,
|
||||
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
|
||||
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
|
||||
...input.persona !== undefined ? { persona: input.persona } : {},
|
||||
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
|
||||
...input.mode === 'continuable'
|
||||
? {
|
||||
...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {},
|
||||
...input.agentModel !== undefined ? { agentModel: input.agentModel } : {},
|
||||
...input.persona !== undefined ? { persona: input.persona } : {},
|
||||
...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {},
|
||||
}
|
||||
: {},
|
||||
}
|
||||
const snapshot = snapshotJsonValue(candidate)
|
||||
if (snapshot === undefined) {
|
||||
@@ -214,8 +287,8 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba
|
||||
* composition.
|
||||
* @param events - the loaded child session events.
|
||||
* @returns the descriptor, or `undefined` when the log has none or its
|
||||
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child is not
|
||||
* resumable by this runtime).
|
||||
* version is not {@link SUBAGENT_DESCRIPTOR_VERSION} (the child cannot be
|
||||
* classified by this runtime).
|
||||
* @throws when a current-version persisted payload does not match its complete
|
||||
* declared schema.
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentRun,
|
||||
@@ -60,12 +61,14 @@ import type {
|
||||
} from './continuation.ts'
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
export type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
ResolvedSubagentStartRequest,
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentResult,
|
||||
@@ -79,7 +82,14 @@ export {
|
||||
snapshotSubagentDescriptor,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
} from './descriptor.ts'
|
||||
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
|
||||
export type {
|
||||
ContinuableSubagentDescriptorData,
|
||||
ContinuableSubagentDescriptorInput,
|
||||
OneShotSubagentDescriptorData,
|
||||
OneShotSubagentDescriptorInput,
|
||||
SubagentDescriptorData,
|
||||
SubagentDescriptorInput,
|
||||
} from './descriptor.ts'
|
||||
export { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
export { SubagentError } from './error.ts'
|
||||
export { settleRun } from './run-settlement.ts'
|
||||
@@ -224,11 +234,11 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the parent's direct continuable children from the live-preferred
|
||||
* session corpus without loading or resuming an Agent. Session query supplies
|
||||
* lineage, candidate order, event reads, and live state; this service
|
||||
* interprets descriptors, status, and per-child diagnostics without consulting
|
||||
* Agent registrations, Activations, or providers.
|
||||
* Enumerate the parent's direct session-backed subagents from the
|
||||
* live-preferred session corpus without loading or resuming an Agent. Session
|
||||
* query supplies lineage, candidate order, event reads, and live state; this
|
||||
* service interprets descriptor mode, activity, and per-child diagnostics
|
||||
* without consulting Agent registrations, Activations, or providers.
|
||||
*
|
||||
* The trace and exact descriptor read receive `signal`; the full event-list
|
||||
* read has no signal parameter, so the scan rechecks cancellation around
|
||||
@@ -293,7 +303,7 @@ export class SubagentService extends Service {
|
||||
* fulfills; a rejection therefore has no run for the caller to dispose and
|
||||
* emits no run lifecycle events.
|
||||
* @param name - the provider to use.
|
||||
* @param request - child prompt, parent, signal, and optional capabilities.
|
||||
* @param request - child label, prompt, parent, signal, and optional capabilities.
|
||||
* @returns the ready holder-owned run.
|
||||
*/
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
@@ -301,7 +311,13 @@ export class SubagentService extends Service {
|
||||
this.assertCapabilities(provider, request)
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
|
||||
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(request))
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
mode: 'one-shot',
|
||||
provider: name,
|
||||
label: request.label,
|
||||
})
|
||||
const resolved: ResolvedSubagentStartRequest = { ...request, descriptor }
|
||||
return observeRun(this.emitLifecycle, name, request.parent, await provider.start(resolved))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Read-only interpretation of session-query lineage as durable subagent
|
||||
* children. The module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state.
|
||||
* Agent-registry, continuation-manager, or provider state. A child's
|
||||
* descriptor distinguishes one-shot work from a continuable conversation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
@@ -31,14 +32,15 @@ export type SubagentListEntry =
|
||||
readonly id: SessionId
|
||||
/** The durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
/** Lifecycle policy declared when the child was created. */
|
||||
readonly mode: 'one-shot' | 'continuable'
|
||||
/**
|
||||
* Corpus snapshot status: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `complete` means it exists only in persistence and
|
||||
* `send_message` may materialize another Activation. Neither encodes a durable
|
||||
* outcome, and a listed `running` child may still reject delivery as an
|
||||
* ownership conflict.
|
||||
* Corpus snapshot activity: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
|
||||
* encodes a durable outcome, and a continuable child may still reject
|
||||
* delivery as an ownership conflict.
|
||||
*/
|
||||
readonly status: 'running' | 'complete'
|
||||
readonly activity: 'running' | 'inactive'
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
@@ -54,7 +56,7 @@ export type SubagentListEntry =
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one parent's direct session descendants as continuable subagents
|
||||
* Interpret one parent's direct session descendants as session-backed subagents
|
||||
* without loading or resuming an Agent.
|
||||
* @param ctx - context carrying the optional session-query service.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
@@ -108,7 +110,7 @@ async function inspectChild(
|
||||
try {
|
||||
const records = await runListingQuery(() => query.listEvents(childId), signal)
|
||||
// Only the child's own suffix: a fork seed may replay an ancestor's
|
||||
// descriptor without making the fork itself a continuable subagent.
|
||||
// descriptor without making the fork itself a subagent.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
@@ -141,7 +143,8 @@ async function inspectChild(
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
label: descriptor.label,
|
||||
status: candidate.live ? 'running' : 'complete',
|
||||
mode: descriptor.mode,
|
||||
activity: candidate.live ? 'running' : 'inactive',
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
import type { SubagentDescriptorData } from './descriptor.ts'
|
||||
|
||||
/** Identifies one accepted subagent run across its lifecycle event pair. */
|
||||
export type SubagentRunId = Branded<'SubagentRunId'>
|
||||
@@ -88,9 +89,12 @@ export interface SubagentCapabilities {
|
||||
* What a caller asks for when starting a ONE-SHOT subagent. The tool layer
|
||||
* builds this from the model's `{ description, prompt }` plus its own config;
|
||||
* the service validates {@link SubagentCapabilities} against the named provider
|
||||
* before dispatching to {@link SubagentProvider.start}.
|
||||
* and resolves the durable descriptor before dispatching to
|
||||
* {@link SubagentProvider.start}.
|
||||
*/
|
||||
export interface SubagentStartRequest {
|
||||
/** Short display label persisted with a session-backed child. */
|
||||
readonly label: string
|
||||
/** Content delivered as the child's user message. */
|
||||
readonly prompt: ContentBlock[]
|
||||
/**
|
||||
@@ -139,6 +143,15 @@ export interface SubagentStartRequest {
|
||||
readonly persona?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-facing one-shot request after {@link SubagentService.start} resolves
|
||||
* the durable child descriptor.
|
||||
*/
|
||||
export interface ResolvedSubagentStartRequest extends SubagentStartRequest {
|
||||
/** Detached descriptor a session-backed provider persists in the child log. */
|
||||
readonly descriptor: SubagentDescriptorData
|
||||
}
|
||||
|
||||
/**
|
||||
* What the continuation manager asks a provider for while materializing one
|
||||
* continuable child's FIRST activation. The manager has already reserved the
|
||||
@@ -268,13 +281,13 @@ export interface SubagentProvider {
|
||||
/**
|
||||
* Establish a ONE-SHOT child and return its handle only after publication.
|
||||
* The service has already validated that every requested start-time
|
||||
* capability is supported, so an implementation may assume e.g.
|
||||
* `request.maxDepth` is honorable when present. If setup fails or
|
||||
* `request.signal` aborts before fulfillment, the provider owns and cleans
|
||||
* all partial resources before this promise rejects. Ownership transfers to
|
||||
* the caller only on fulfillment.
|
||||
* capability is supported and resolved `request.descriptor`, so a
|
||||
* session-backed implementation appends that descriptor inside the child's
|
||||
* initial turn. If setup fails or `request.signal` aborts before fulfillment,
|
||||
* the provider owns and cleans all partial resources before this promise
|
||||
* rejects. Ownership transfers to the caller only on fulfillment.
|
||||
*/
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
|
||||
/**
|
||||
* OPTIONAL (continuable-creation capability): contribute the detached
|
||||
* creation inputs that distinguish this provider's continuable children —
|
||||
|
||||
@@ -201,7 +201,9 @@ describe('SubagentService.startContinuable', () => {
|
||||
const descriptor = loaded.events[descriptorIndex] as SessionEvent<'subagent/descriptor'>
|
||||
expect(descriptor.data).toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
agentProvider: 'mock',
|
||||
agentModel: 'mock',
|
||||
})
|
||||
@@ -294,7 +296,9 @@ describe('SubagentService.startContinuable', () => {
|
||||
|
||||
expect(descriptor?.data).toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
})
|
||||
await drainManager(ctx)
|
||||
})
|
||||
@@ -326,7 +330,9 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data)
|
||||
.toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'child task',
|
||||
toolFilter: { deny: ['noop'] },
|
||||
})
|
||||
await drainManager(ctx)
|
||||
@@ -526,8 +532,9 @@ describe('SubagentService.followup residency routing', () => {
|
||||
|
||||
it('reports an unresumable child whose persisted log has no supported descriptor', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('one shot')])
|
||||
// A ONE-SHOT child persists a log but never seeds a descriptor.
|
||||
// A one-shot child has durable identity but no supported continuation state.
|
||||
const run = await ctx.subagents.start('spawn', {
|
||||
label: 'one-shot work',
|
||||
prompt: message('one-shot work'),
|
||||
parent,
|
||||
signal: testSignal,
|
||||
@@ -774,6 +781,7 @@ describe('continuable durability and teardown', () => {
|
||||
])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const run = await ctx.subagents.start('spawn', {
|
||||
label: 'one-shot task',
|
||||
prompt: message('one-shot task'),
|
||||
parent,
|
||||
signal: testSignal,
|
||||
@@ -1374,6 +1382,7 @@ describe('continuable public surface', () => {
|
||||
it('keeps one-shot runs free of a steering capability', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('one shot')])
|
||||
const run = await ctx.subagents.start('spawn', {
|
||||
label: 'one-shot work',
|
||||
prompt: message('one-shot work'),
|
||||
parent,
|
||||
signal: testSignal,
|
||||
|
||||
@@ -98,7 +98,7 @@ function childEvents(descriptor: unknown): SessionEvent[] {
|
||||
}
|
||||
|
||||
function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) {
|
||||
return { version, provider: 'spawn', label }
|
||||
return { version, mode: 'continuable' as const, provider: 'spawn', label }
|
||||
}
|
||||
|
||||
describe('SubagentService.listChildren', () => {
|
||||
@@ -121,7 +121,7 @@ describe('SubagentService.listChildren', () => {
|
||||
child.append('subagent/descriptor', descriptorPayload('query-only child'))
|
||||
|
||||
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
|
||||
{ kind: 'child', id: childId, label: 'query-only child', status: 'running' },
|
||||
{ kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', activity: 'running' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -132,15 +132,46 @@ describe('SubagentService.listChildren', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a persisted continuable child as complete with its durable label', async () => {
|
||||
it('lists a persisted continuable child as inactive with its durable label', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'summarize the doc')
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'summarize the doc', status: 'complete' },
|
||||
{ kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable', activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lists one-shot and continuable children from the same trace', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
|
||||
const oneShot = await ctx.subagents.start('spawn', {
|
||||
label: 'one-shot child',
|
||||
prompt: [{ type: 'text', text: 'finish once' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
const oneShotId = oneShot.id
|
||||
await oneShot.result
|
||||
await oneShot.dispose()
|
||||
const continuableId = await startChild(ctx, parent, 'continuable child')
|
||||
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child',
|
||||
id: oneShotId,
|
||||
label: 'one-shot child',
|
||||
mode: 'one-shot',
|
||||
activity: 'inactive',
|
||||
})
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child',
|
||||
id: continuableId,
|
||||
label: 'continuable child',
|
||||
mode: 'continuable',
|
||||
activity: 'inactive',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a persisted (non-live) parent target after restart', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A parent that exists only in persistence — the restart shape.
|
||||
@@ -159,7 +190,7 @@ describe('SubagentService.listChildren', () => {
|
||||
}, childEvents(descriptorPayload('persisted parent case')))
|
||||
const entries = await ctx.subagents.listChildren(coldParent)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'persisted parent case', status: 'complete' },
|
||||
{ kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable', activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -197,8 +228,12 @@ describe('SubagentService.listChildren', () => {
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append('subagent/descriptor', descriptorPayload('live child'))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'child', id: settled, label: 'settled child', status: 'complete' })
|
||||
expect(entries).toContainEqual({ kind: 'child', id: liveId, label: 'live child', status: 'running' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: settled, label: 'settled child', mode: 'continuable', activity: 'inactive',
|
||||
})
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: liveId, label: 'live child', mode: 'continuable', activity: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
|
||||
@@ -217,7 +252,9 @@ describe('SubagentService.listChildren', () => {
|
||||
}, events)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({ kind: 'child', id: healthy, label: 'healthy sibling', status: 'complete' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive',
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses an invalid child event surface as corrupt', async () => {
|
||||
@@ -244,7 +281,7 @@ describe('SubagentService.listChildren', () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const malformed = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ff', {
|
||||
parentSession: parent.id,
|
||||
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 7 }))
|
||||
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
|
||||
})
|
||||
@@ -275,10 +312,15 @@ describe('SubagentService.listChildren', () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-0000000000bb', {
|
||||
parentSession: parent.id,
|
||||
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'not-mounted', label: 'orphan provider' }))
|
||||
}, childEvents({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'not-mounted',
|
||||
label: 'orphan provider',
|
||||
}))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: foreign, label: 'orphan provider', status: 'complete' },
|
||||
{ kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable', activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -393,8 +435,8 @@ describe('SubagentService.listChildren', () => {
|
||||
}, compactedEvents)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: plain, label: 'twin child', status: 'complete' },
|
||||
{ kind: 'child', id: compacted, label: 'twin child', status: 'complete' },
|
||||
{ kind: 'child', id: plain, label: 'twin child', mode: 'continuable', activity: 'inactive' },
|
||||
{ kind: 'child', id: compacted, label: 'twin child', mode: 'continuable', activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -406,7 +448,7 @@ describe('SubagentService.listChildren', () => {
|
||||
}, childEvents(descriptorPayload('grandchild')))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'child', id: childId, label: 'direct child', status: 'complete' },
|
||||
{ kind: 'child', id: childId, label: 'direct child', mode: 'continuable', activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import SubagentService, {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
SubagentError,
|
||||
assertSubagentMaxDepth,
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
type SubagentResult,
|
||||
@@ -27,6 +28,7 @@ const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false,
|
||||
|
||||
function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return {
|
||||
label: 'do a thing',
|
||||
prompt: [{ type: 'text', text: 'do a thing' }],
|
||||
parent: fakeParent(),
|
||||
signal: new AbortController().signal,
|
||||
@@ -37,7 +39,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
|
||||
class StubProvider implements SubagentProvider {
|
||||
readonly inheritsParentContext = false
|
||||
startCount = 0
|
||||
lastRequest: SubagentStartRequest | undefined
|
||||
lastRequest: ResolvedSubagentStartRequest | undefined
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
@@ -48,7 +50,7 @@ class StubProvider implements SubagentProvider {
|
||||
},
|
||||
) {}
|
||||
|
||||
async start(request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
async start(request: ResolvedSubagentStartRequest): Promise<SubagentRun> {
|
||||
this.startCount += 1
|
||||
this.lastRequest = request
|
||||
return {
|
||||
@@ -104,16 +106,23 @@ describe('SubagentService', () => {
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('borrows ordinary start requests and exposes no provider continuation operations', async () => {
|
||||
it('resolves the one-shot descriptor and exposes no provider continuation operations', async () => {
|
||||
const { subagents } = await service()
|
||||
const provider = new StubProvider('one-shot')
|
||||
subagents.registerProvider(provider)
|
||||
const request = baseRequest()
|
||||
await subagents.start('one-shot', request)
|
||||
|
||||
// One-shot start borrows the caller's exact request; the seam has no
|
||||
// provider-facing resume or steer surface to dispatch through.
|
||||
expect(provider.lastRequest).toBe(request)
|
||||
expect(provider.lastRequest).toEqual({
|
||||
...request,
|
||||
descriptor: {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'one-shot',
|
||||
provider: 'one-shot',
|
||||
label: 'do a thing',
|
||||
},
|
||||
})
|
||||
expect(provider.lastRequest).not.toBe(request)
|
||||
expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
|
||||
expect('resume' in subagents).toBe(false)
|
||||
expect('resume' in provider).toBe(false)
|
||||
@@ -299,15 +308,17 @@ describe('subagent descriptors', () => {
|
||||
|
||||
it('omits absent fields, recovers a complete payload, and rejects unsupported versions', () => {
|
||||
expect(foldSubagentDescriptor([])).toBeUndefined()
|
||||
const minimal = snapshotSubagentDescriptor({ provider: 'spawn', label: 'child work' })
|
||||
const minimal = snapshotSubagentDescriptor({ mode: 'one-shot', provider: 'spawn', label: 'child work' })
|
||||
expect(minimal).toEqual({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'one-shot',
|
||||
provider: 'spawn',
|
||||
label: 'child work',
|
||||
})
|
||||
expect(foldSubagentDescriptor([event(minimal)])).toEqual(minimal)
|
||||
const complete = {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable' as const,
|
||||
provider: 'spawn',
|
||||
label: 'complete child',
|
||||
agentProvider: 'deepseek',
|
||||
@@ -316,6 +327,7 @@ describe('subagent descriptors', () => {
|
||||
toolFilter: { allow: ['read'], deny: ['bash'] },
|
||||
}
|
||||
expect(snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: complete.provider,
|
||||
label: complete.label,
|
||||
agentProvider: complete.agentProvider,
|
||||
@@ -325,15 +337,28 @@ describe('subagent descriptors', () => {
|
||||
})).toEqual(complete)
|
||||
expect(foldSubagentDescriptor([event(complete)])).toEqual(complete)
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { allow: ['read'] } }),
|
||||
event({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: { allow: ['read'] },
|
||||
}),
|
||||
])).toMatchObject({ toolFilter: { allow: ['read'] } })
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'spawn', label: 'l', toolFilter: { deny: ['bash'] } }),
|
||||
event({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: { deny: ['bash'] },
|
||||
}),
|
||||
])).toMatchObject({ toolFilter: { deny: ['bash'] } })
|
||||
expect(foldSubagentDescriptor([
|
||||
event({ version: SUBAGENT_DESCRIPTOR_VERSION + 1, provider: 'spawn' }),
|
||||
])).toBeUndefined()
|
||||
expect(() => snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'bad',
|
||||
toolFilter: { deny: [Symbol('not-json')] as unknown as string[] },
|
||||
@@ -346,19 +371,94 @@ describe('subagent descriptors', () => {
|
||||
['array payload', [], 'payload must be an object'],
|
||||
['missing version', { provider: 'spawn' }, 'version must be a number'],
|
||||
['string version', { version: '1', provider: 'spawn' }, 'version must be a number'],
|
||||
['unknown payload field', { version: 1, provider: 'spawn', extra: true }, 'payload has unknown field "extra"'],
|
||||
['missing provider', { version: 1 }, 'provider must be a string'],
|
||||
['missing label', { version: 1, provider: 'spawn' }, 'label must be a string'],
|
||||
['invalid label', { version: 1, provider: 'spawn', label: 7 }, 'label must be a string'],
|
||||
['invalid provider', { version: 1, provider: 7 }, 'provider must be a string'],
|
||||
['invalid agent provider', { version: 1, provider: 'spawn', label: 'l', agentProvider: 7 }, 'agentProvider must be a string'],
|
||||
['invalid agent model', { version: 1, provider: 'spawn', label: 'l', agentModel: [] }, 'agentModel must be a string'],
|
||||
['invalid persona', { version: 1, provider: 'spawn', label: 'l', persona: {} }, 'persona must be a string'],
|
||||
['non-object tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: [] }, 'toolFilter must be an object'],
|
||||
['unknown tool-filter field', { version: 1, provider: 'spawn', label: 'l', toolFilter: { except: ['bash'] } }, 'toolFilter has unknown field "except"'],
|
||||
['empty tool filter', { version: 1, provider: 'spawn', label: 'l', toolFilter: {} }, 'toolFilter must declare allow and/or deny'],
|
||||
['non-array allow list', { version: 1, provider: 'spawn', label: 'l', toolFilter: { allow: 'read' } }, 'toolFilter.allow must be an array of strings'],
|
||||
['non-string deny item', { version: 1, provider: 'spawn', label: 'l', toolFilter: { deny: [7] } }, 'toolFilter.deny must be an array of strings'],
|
||||
['missing mode', { version: SUBAGENT_DESCRIPTOR_VERSION }, 'mode must be "one-shot" or "continuable"'],
|
||||
['invalid mode', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'later' }, 'mode must be "one-shot" or "continuable"'],
|
||||
['unknown one-shot field', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'one-shot',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
persona: 'reviewer',
|
||||
}, 'payload has unknown field "persona"'],
|
||||
['unknown payload field', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
extra: true,
|
||||
}, 'payload has unknown field "extra"'],
|
||||
['missing provider', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable' }, 'provider must be a string'],
|
||||
['missing label', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
}, 'label must be a string'],
|
||||
['invalid label', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 7,
|
||||
}, 'label must be a string'],
|
||||
['invalid provider', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 7,
|
||||
}, 'provider must be a string'],
|
||||
['invalid agent provider', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
agentProvider: 7,
|
||||
}, 'agentProvider must be a string'],
|
||||
['invalid agent model', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
agentModel: [],
|
||||
}, 'agentModel must be a string'],
|
||||
['invalid persona', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
persona: {},
|
||||
}, 'persona must be a string'],
|
||||
['non-object tool filter', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: [],
|
||||
}, 'toolFilter must be an object'],
|
||||
['unknown tool-filter field', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: { except: ['bash'] },
|
||||
}, 'toolFilter has unknown field "except"'],
|
||||
['empty tool filter', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: {},
|
||||
}, 'toolFilter must declare allow and/or deny'],
|
||||
['non-array allow list', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: { allow: 'read' },
|
||||
}, 'toolFilter.allow must be an array of strings'],
|
||||
['non-string deny item', {
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'continuable',
|
||||
provider: 'spawn',
|
||||
label: 'l',
|
||||
toolFilter: { deny: [7] },
|
||||
}, 'toolFilter.deny must be an array of strings'],
|
||||
])('rejects a malformed persisted descriptor: %s', (_case, data, detail) => {
|
||||
expect(() => foldSubagentDescriptor([event(data)])).toThrow(detail)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/subagent/tool-subagent-control/README.md
|
||||
README.md: 1fe0e49006d12b95e6c6e38cab7895238b3d3c98
|
||||
README.zh.md: 9d2d46888b0790548df35cb54613ee2334143db7
|
||||
README.md: 78621701cd4fe8ac9ad8b3f0985271ee6e2a7e8c
|
||||
README.zh.md: f6ed8144dcfc8df8f2e6edad89c4d42b641d666e
|
||||
|
||||
@@ -6,7 +6,7 @@ The optional, globally named `send_message` and `list_agents` tools are thin ada
|
||||
|
||||
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. The child does not reply to the sender — its transcript by that id is the source of what it did. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
`list_agents` takes no arguments, derives the parent id from the calling agent, and renders `ctx.subagents.listChildren()`'s complete entry array without a cursor. It is discovery only: durable identity comes from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
|
||||
`list_agents` takes no arguments, derives the parent id from the calling agent, and projects `ctx.subagents.listChildren()` to continuable children without a cursor. The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -42,7 +42,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One line per entry in the trace's stable order: `<id> [<status>] — <label>` for a child (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`), and `(no subagents)` for an empty result. Diagnostics never expose descriptor contents.
|
||||
One line per continuable child in the trace's stable order: `<id> [<status>] — <label>` (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
本工具不执行生命周期路由——驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的准确实时父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。子 agent 不会回复发送方——通过该 id 查看其 transcript 即是其所做工作的来源。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
`list_agents` 不接受参数,从调用 Agent 推导 parent id,并在没有 cursor 的情况下渲染 `ctx.subagents.listChildren()` 的完整条目数组。它只负责发现:持久化身份来自每个 child 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
|
||||
`list_agents` 不接受参数,会从调用它的 agent 推导 parent id,并且不使用 cursor,将 `ctx.subagents.listChildren()` 的结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent,以供 UI 等消费方使用;但这些条目无法接受 `send_message`,因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个条目按追踪结果的稳定顺序占一行:child 使用 `<id> [<status>] — <label>`(`running` 表示逻辑会话存活,`complete` 表示只存在于持久化存储中且可由 `send_message` 恢复),无法读取的候选使用 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`),空结果使用 `(no subagents)`。Diagnostic 绝不暴露描述符内容。
|
||||
按追踪结果的稳定顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>`(`running` 表示逻辑会话存活,`complete` 表示仅存在于持久化存储中,可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]`(`corrupt`、`unsupported` 或 `unavailable`)。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* `ctx.subagents.listChildren()`. It is separately loadable from the
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It is
|
||||
* separately loadable from the
|
||||
* root `send_message` plugin because it additionally requires the session
|
||||
* query service — a deployment may use `send_message` without loading session
|
||||
* query, and this plugin catches that misconfiguration at load.
|
||||
@@ -15,6 +16,19 @@ import type {} from '@deepseek-ai/dsh-subagent'
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents', 'sessionQuery']
|
||||
|
||||
type ListAgentsEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
readonly id: string
|
||||
readonly label: string
|
||||
readonly status: 'running' | 'complete'
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
readonly id: string
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and session query.
|
||||
@@ -23,7 +37,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'list_agents',
|
||||
description:
|
||||
'List your background subagents by durable id and label. Status is a snapshot of the stored '
|
||||
'List your continuable background subagents by durable id and label. Status is a snapshot of the stored '
|
||||
+ 'record: running means the subagent session is currently live in this process, complete means '
|
||||
+ 'it exists only in storage and a `send_message` starts a new turn on the same conversation. '
|
||||
+ 'The snapshot is not a delivery promise — `send_message` performs the authoritative check and '
|
||||
@@ -74,7 +88,21 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
// The registry drains started tool bodies, so the scan must observe the
|
||||
// call's signal rather than finish a slow catalog after cancellation.
|
||||
return await ctx.subagents.listChildren(parent.id, exec.signal)
|
||||
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
|
||||
const visible: ListAgentsEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
visible.push(entry)
|
||||
} else if (entry.mode === 'continuable') {
|
||||
visible.push({
|
||||
kind: 'child',
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
status: entry.activity === 'running' ? 'running' : 'complete',
|
||||
})
|
||||
}
|
||||
}
|
||||
return visible
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -90,19 +90,49 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
// Pin the render deterministically past the service: the tool is a thin
|
||||
// adapter, so its fixed text forms are what this test pins.
|
||||
const entries: SubagentListEntry[] = [
|
||||
{ kind: 'child', id: started.childId, label: 'real child', status: 'complete' },
|
||||
{
|
||||
kind: 'child',
|
||||
id: SessionId('one-shot-child'),
|
||||
label: 'finished once',
|
||||
mode: 'one-shot',
|
||||
activity: 'inactive',
|
||||
},
|
||||
{
|
||||
kind: 'child',
|
||||
id: started.childId,
|
||||
label: 'real child',
|
||||
mode: 'continuable',
|
||||
activity: 'inactive',
|
||||
},
|
||||
{
|
||||
kind: 'child',
|
||||
id: SessionId('running-child'),
|
||||
label: 'still working',
|
||||
mode: 'continuable',
|
||||
activity: 'running',
|
||||
},
|
||||
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
|
||||
]
|
||||
ctx.subagents.listChildren = () => Promise.resolve(entries)
|
||||
const result = await callTool(ctx, 'list_agents', {}, parent)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(
|
||||
`${started.childId} [complete] — real child\nbroken-child [diagnostic: corrupt]`,
|
||||
`${started.childId} [complete] — real child\n`
|
||||
+ 'running-child [running] — still working\n'
|
||||
+ 'broken-child [diagnostic: corrupt]',
|
||||
)
|
||||
})
|
||||
|
||||
it('lists a real settled child end-to-end with its durable label', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
it('lists a real settled continuable child and omits a real one-shot sibling', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('once'), textResponse('done')])
|
||||
const oneShot = await ctx.subagents.start('spawn', {
|
||||
label: 'finished once',
|
||||
prompt: [{ type: 'text', text: 'one-shot task' }],
|
||||
parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await oneShot.result
|
||||
await oneShot.dispose()
|
||||
const started = await ctx.subagents.startContinuable({
|
||||
provider: 'spawn',
|
||||
label: 'summarize the doc',
|
||||
|
||||
@@ -280,6 +280,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
|
||||
const request = {
|
||||
label: args.description,
|
||||
prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[],
|
||||
parent,
|
||||
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
|
||||
|
||||
@@ -12,6 +12,7 @@ function fakeParent(id = 'parent-1'): Agent {
|
||||
|
||||
function baseRequest(over: Partial<SubagentStartRequest> = {}): SubagentStartRequest {
|
||||
return {
|
||||
label: 'task',
|
||||
prompt: [{ type: 'text', text: 'task' }],
|
||||
parent: fakeParent(),
|
||||
signal: new AbortController().signal,
|
||||
|
||||
@@ -1007,6 +1007,7 @@ describe('depth budget configuration', () => {
|
||||
it('defaults maxDepth to 3 and forwards it in the start request', async () => {
|
||||
const { ctx, requests } = await captureSetup()
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(requests[0]?.label).toBe('d')
|
||||
expect(requests[0]?.maxDepth).toBe(3)
|
||||
expect(requests[0]?.toolFilter).toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user