refactor(subagent): merge continuation control service

This commit is contained in:
Dudu
2026-07-27 00:00:14 +08:00
committed by imccyu
parent 0a95ad8cc0
commit 88f913a9ae
71 changed files with 543 additions and 673 deletions

View File

@@ -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/README.md
README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1
README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a
README.md: 9d60363602a9825730984700a7fe987d911e1cac
README.zh.md: 5964c38bd847c1c14cac9decdd913ca65c39e8f3

View File

@@ -10,7 +10,7 @@ Each plugin instance binds one `provider` to one `toolName`; the model receives
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
With `run_in_background: true`, the route follows the provider's continuation capability and returns canonical `{ kind: 'background', taskId, subagentId? }`. A resumable provider (spawn, fork) delegates to `ctx.subagentControl.startContinuable()`, which owns the durable child id, descriptor snapshot, Task registration, and settle-then-dispose ordering; the result includes `subagentId`, renders as `started subagent <childId> as task <taskId>`, and accepts follow-up messages through the global `send_message` tool. A one-shot provider (ACP) keeps the plain parent-owned task, omits `subagentId`, and renders as `started background subagent task <id>`. Either way a task-owned signal covers pending startup and the child after the starting call returns; `task_kill` and owner disposal abort it, settlement awaits startup rollback or child disposal, and completed final text, abort to `killed`, and other failures to `failed` map identically. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md) and the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md).
With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports resume. `continuable` requires `provider.resume`, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'background', taskId, subagentId }`, rendered as `started subagent <childId> as task <taskId>`. The optional global `send_message` tool is not required to start continuable work. Either route uses a Task-owned signal, settles only after startup rollback or run disposal, and maps completed final text, abort → `killed`, and other failures → `failed`. Generic task tools own later status, collection, cancellation, and notices. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
`toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
@@ -21,6 +21,7 @@ With `run_in_background: true`, the route follows the provider's continuation ca
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
| `backgroundMode` | Background lifecycle policy, default `one-shot`. `continuable` requires provider resume support and returns a durable child id; it does not require the follow-up tool. |
| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
@@ -64,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Start returns exactly `started subagent <childId> as task <taskId>` on a resumable provider, or `started background subagent task <id>` on a one-shot provider. The generic task surface provides later status, final output, cancellation responses, and notices; `send_message` (from `dsh-tool-subagent-control`) delivers follow-ups to a continuable child.
Start returns exactly `started subagent <childId> as task <taskId>` in configured continuable mode, or `started background subagent task <id>` in configured one-shot mode. The generic task surface provides later status, final output, cancellation responses, and notices; an independently loaded `send_message` tool delivers follow-ups to a continuable child.
#### Token effect

View File

@@ -10,7 +10,7 @@
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。
设置 `run_in_background: true` 后,路由遵循提供方的继续功能,并返回规范值 `{ kind: 'background', taskId, subagentId? }`。可恢复提供方(spawn、fork)会委派给 `ctx.subagentControl.startContinuable()`,由它拥有持久化子 agent ID、描述符快照、Task 注册和先结算后 dispose(资源释放)的顺序;结果包含 `subagentId`,渲染为 `started subagent <childId> as task <taskId>`,并通过全局 `send_message` 工具接收后续消息。一次性提供方 ACP(Agent Client Protocol)保留普通的父级所有任务,省略 `subagentId`,并渲染为 `started background subagent task <id>`。两条路径中,任务拥有的信号都会覆盖待处理的启动阶段和启动调用返回后的子 agent;`task_kill` 和所有者 dispose 会中止它,结算会等待启动回滚或子 agent dispose,然后把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。任务不提供增量读取;通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.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)。
设置 `run_in_background: true` 后,由 `backgroundMode` 选择路由。`one-shot` 会注册普通的父级所有 Task,并返回规范值 `{ kind: 'background', taskId }`;即使提供方支持恢复,也会渲染为 `started background subagent task <id>`。`continuable` 要求 `provider.resume`,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent <childId> as task <taskId>`。启动可继续工作不要求加载可选的全局 `send_message` 工具。两条路由都使用 Task 所有的信号,只在启动回滚或 run dispose(资源释放)之后结算,并把完成的最终文本映射为完成、中止映射为 `killed`、其他失败映射为 `failed`。通用任务工具负责后续状态、收集、取消和通知。见[后台 subagent Agent Note(agent 决策记录)](../../../.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/simplification/2026-07-26-merge-subagent-control-service.md)。
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
@@ -21,6 +21,7 @@
| `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 |
| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 |
| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 |
| `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`continuable` 要求提供方支持恢复并返回持久化子 agent ID;它不要求加载后续消息工具。 |
| `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 |
| `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 |
| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 |
@@ -64,7 +65,7 @@
#### 模型看到的内容
对于可恢复提供方,启动时精确返回 `started subagent <childId> as task <taskId>`;对于一次性提供方,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。
在已配置的 continuable 模式下,启动时精确返回 `started subagent <childId> as task <taskId>`;在已配置的 one-shot 模式下,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;独立加载的 `send_message` 工具会把后续消息交付给可继续子 agent。
#### Token 影响

View File

@@ -31,7 +31,6 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -48,7 +47,6 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-control": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",

View File

@@ -1,11 +1,10 @@
/**
* Model-facing delegation through one configured `ctx.subagents` provider.
* Provider lifecycle controls tool registration and context-sensitive schema
* wording. Foreground calls always dispose the run after collection. A
* background call's route follows the provider's continuation capability:
* a provider with `resume` delegates to `ctx.subagentControl`, which owns the
* durable child id, its descriptor, and the Task-backed activation lifecycle;
* a provider without it (ACP) keeps the one-shot background task.
* wording. Foreground calls always dispose the run after collection.
* Background policy is selected by this plugin's configuration: one-shot
* calls own a plain Task, while continuable calls use
* `ctx.subagents.startContinuable()`.
* @module @deepseek-ai/dsh-tool-subagent
*/
@@ -15,9 +14,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent'
import { settleRun } from '@deepseek-ai/dsh-subagent-control'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
export const name = 'tool-subagent'
@@ -37,6 +35,12 @@ export interface Config {
* parameter and reject forced background calls.
*/
enableRunInBackground?: boolean
/**
* Background execution policy (default `one-shot`). `continuable` requires
* a provider with persisted resume support and returns both child and Task
* ids; follow-up adapters remain independently optional.
*/
backgroundMode?: 'one-shot' | 'continuable'
/**
* Agent options applied to every child; omitted fields use child-loop defaults.
*/
@@ -73,6 +77,7 @@ export const Config: z<Config> = z.object({
provider: z.string().required(),
toolName: z.string().default('subagent'),
enableRunInBackground: z.boolean().default(true),
backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'),
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
agentOptions: z.object({
provider: z.string(),
@@ -191,17 +196,19 @@ export function apply(ctx: Context, config: Config): void {
}
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
// The provider's continuation capability decides the background route: a
// resumable provider starts durable, follow-up-able children through the
// control service, while a one-shot provider (ACP) keeps the plain task.
const continuable = provider.resume !== undefined
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
if (continuable && provider.resume === undefined) {
throw new Error(
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
)
}
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description + (backgroundEnabled
? continuable
? ' Set `run_in_background: true` to start a continuable background subagent: you receive its'
+ ' subagent id and a task id; collect the result with `task_output`, stop it with `task_kill`,'
+ ' and send follow-up messages with `send_message`.'
+ ' stable subagent id and current task id; collect the result with `task_output` and stop it with'
+ ' `task_kill`.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
@@ -220,7 +227,7 @@ export function apply(ctx: Context, config: Config): void {
type: 'boolean' as const,
description: continuable
? 'Run as a continuable background subagent and return its subagent and task ids; '
+ 'collect with task_output, stop with task_kill, follow up with send_message.'
+ 'collect with task_output or stop with task_kill.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
@@ -281,23 +288,7 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
}
if (continuable) {
const control = ctx.get('subagentControl')
if (control === undefined) {
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-subagent-control and @deepseek-ai/dsh-tool-tasks')
}
// The schema above tells the model to follow up with
// `send_message`; starting a durable child the model cannot
// continue would make that advertisement false. Sibling load order
// is undetermined at mount, so the check lives at the operation,
// and it resolves in the CALLER's scope so a restriction that
// removes send_message from this agent also blocks the start.
if (ctx.tools.get('send_message', parent) === undefined) {
throw new Error('continuable background subagents unavailable: load @deepseek-ai/dsh-tool-subagent-control (the advertised send_message tool is not registered)')
}
// The control service owns the durable child id, descriptor
// snapshot, Task registration, and settle-then-dispose ordering; a
// synchronous validation failure rejects the call with no Task.
const started = control.startContinuable({
const started = ctx.subagents.startContinuable({
provider: config.provider,
label: args.description,
request,

View File

@@ -15,9 +15,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as mock from './scripted-provider.ts'
@@ -70,6 +68,21 @@ function text(result: { content: { type: string; text?: string }[] }): string {
}
describe('dsh-tool-subagent', () => {
it('rejects continuable background policy when the configured provider cannot resume', async () => {
let failure: unknown
try {
await setup({
provider: 'mock',
backgroundMode: 'continuable',
})
} catch (error: unknown) {
failure = error
}
expect(String(failure)).toContain(
'provider "mock" does not support `backgroundMode: continuable`',
)
})
it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
@@ -655,6 +668,47 @@ describe('dsh-tool-subagent background mode', () => {
return ctx
}
it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
let resumeCalls = 0
ctx.subagents.registerProvider({
name: 'resumable',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: async request => ({
id: SessionId('one-shot-child'),
localAgent: undefined,
result: Promise.resolve({
output: [{ type: 'text', text: 'one-shot answer' }],
stopReason: request.signal.aborted ? 'aborted' : 'completed',
}),
dispose: () => Promise.resolve(),
}),
resume: async () => {
resumeCalls += 1
throw new Error('one-shot policy must not resume')
},
})
tool.apply(ctx, {
provider: 'resumable',
toolName: 'subagent_resumable',
backgroundMode: 'one-shot',
maxDepth: 'provider-managed',
})
const started = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('resumable-one-shot'),
name: 'subagent_resumable',
arguments: { description: 'work', prompt: 'go', run_in_background: true },
agent: parent,
})
expect(text(started)).toBe('started background subagent task subagent-1')
expect(resumeCalls).toBe(0)
})
it('returns a task id immediately and the answer is collected through task_output', async () => {
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
const parent = ownerAgent(ctx, 'sess-parent')
@@ -825,8 +879,8 @@ describe('dsh-tool-subagent continuable background mode', () => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */
async function continuableSetup(options: { controlTool?: boolean } = {}) {
/** Boot the real continuable stack without any model-facing follow-up adapter. */
async function continuableSetup() {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
@@ -837,9 +891,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
if (options.controlTool !== false) await ctx.plugin(ToolSubagentControl)
await ctx.plugin(tool, { provider: 'spawn' })
await ctx.plugin(tool, { provider: 'spawn', backgroundMode: 'continuable' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
textResponse('continuable answer'),
]))
@@ -847,10 +899,10 @@ describe('dsh-tool-subagent continuable background mode', () => {
return { ctx, parent }
}
it('a resumable provider advertises send_message and returns both ids', async () => {
it('starts a continuable child and returns both ids without send_message', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('send_message')
expect(schema.description).not.toContain('send_message')
const started = await callSubagent(
ctx,
@@ -869,56 +921,6 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
it('fails loud when the provider is resumable but the control service is not loaded', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
// A resumable provider without ctx.subagentControl.
ctx.subagents.registerProvider({
name: 'resumable',
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
inheritsParentContext: false,
start: () => { throw new Error('unreachable') },
resume: () => { throw new Error('unreachable') },
})
await ctx.plugin(tool, { provider: 'resumable', maxDepth: 'provider-managed' })
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-subagent-control')
})
it('fails loud when the advertised send_message tool is not registered', async () => {
// The schema tells the model to follow up with send_message; starting a
// durable child the model cannot continue would make that false.
const { ctx, parent } = await continuableSetup({ controlTool: false })
const result = await callSubagent(
ctx,
{ description: 'd', prompt: 'p', run_in_background: true },
{ agent: parent },
)
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
// Nothing was started: no Task exists for the parent.
expect(ctx.tasks.list(parent)).toEqual([])
})
it('resolves send_message availability in the CALLER scope, not the global registry', async () => {
// A scoped restriction that keeps this delegation tool but removes
// send_message means this agent cannot execute the promised follow-up;
// the availability check must see the caller's surface.
const { ctx, parent } = await continuableSetup()
parent.ctx.tools.restrict({ deny: ['send_message'] })
const result = await callSubagent(
ctx,
{ description: 'd', prompt: 'p', run_in_background: true },
{ agent: parent },
)
expect(result.isError).toBe(true)
expect(text(result)).toContain('load @deepseek-ai/dsh-tool-subagent-control')
expect(ctx.tasks.list(parent)).toEqual([])
})
})
describe('background preflight failure (no orphaned child, by construction)', () => {

View File

@@ -29,9 +29,6 @@
{
"path": "../subagent"
},
{
"path": "../subagent-control"
},
{
"path": "../../tasks/tasks"
},