feat(subagent): continuable background subagents

Implement the continuable background subagents RFC: a durable child
session with a series of Task-backed activations, each disposing its
run before the Task settles.

- dsh-subagent: rename SubagentRun.sendMessage to strict steer, drop
  run-level resume, add SubagentProvider.resume dispatch via
  SubagentService.resume, the continuation start field, and the
  versioned model-hidden subagent/descriptor session event.
- dsh-subagent-inprocess/-spawn/-fork: publish the control-allocated
  child id, append the descriptor inside the initial turn, implement
  cold resume from the child's own transcript under the live parent
  scope, and strict running-only steer.
- dsh-subagent-control (new): SubagentControlService owning stable
  child ids, descriptor snapshot/fold/authorization, Task-backed
  activation with settle-then-dispose ordering, the process-local
  active-run association, and steer-or-resume sendMessage routing.
- dsh-tool-subagent: background route branches on the provider's
  resume capability (continuable via the control service; one-shot
  task for ACP), returning both child and task ids.
- dsh-tool-subagent-control (new): the globally named send_message
  tool rendering steered/started routes.

Keyless coverage spans Task ownership and disposal ordering, running
delivery, cold follow-up, descriptor rejection and rollback, known-id
reconstruction, kill during lookup, admission races, and a new
subagent-continuable ACP snapshot scenario.
This commit is contained in:
Dudu-0223
2026-07-23 17:07:38 +08:00
committed by imccyu
parent 76ff841279
commit 99a778d63f
83 changed files with 3167 additions and 627 deletions

View File

@@ -0,0 +1,40 @@
# @deepseek-ai/dsh-tool-subagent-control
The globally named `send_message` tool: a thin adapter over `ctx.subagentControl.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers the one shared control tool, so multiple delegation tools never register duplicate global controls.
The tool performs no lifecycle routing. The control service decides between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child; the tool renders which route was taken and the relevant Task id. A control-service throw becomes an errored tool result stating the message was not delivered.
## Model Experience
### Tool schema
#### What the model sees
The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, with delivery-or-continue semantics and the `task_output` collection path described.
#### Token effect
Fixed schema cost per parent request.
#### KV Cache effect
Prefix-stable; the schema does not change at runtime.
### Delivery result
#### What the model sees
`message delivered to running task <taskId>` when the message joined the running activation, or `message started task <taskId> continuing subagent <subagent_id>` when it cold-resumed the child. Failures are errored results whose message states the message was not delivered (unknown or foreign child, ownership conflict, settlement race, no live-delivery capability).
#### Token effect
One short acknowledgement per call; the child's response enters parent history only when collected through `task_output` or injected by the task completion notice.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **A delivered message has no independent result** — its effect is reflected in the current Task's eventual result; only a started follow-up owns a fresh Task result.
- **Delivery can lose timing races** — a message racing task settlement, cancellation, or cleanup fails explicitly rather than falling through to cold resume; the model retries after the task settles.

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-control",
"description": "Globally named send_message tool over the continuable-subagent control service",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent-control": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@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-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,74 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagentControl.sendMessage()`. It performs no lifecycle routing of its
* own — steer-or-resume orchestration belongs to the control service — and it
* lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent`
* instances so multiple delegation tools share one control tool.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-subagent-control'
export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagentControl']
/**
* Register the `send_message` tool.
* @param ctx - context carrying the tool registry and the control service.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'send_message',
description:
'Send a follow-up message to a background subagent by its subagent id. If it is still working, the '
+ 'message joins its current task; if it has finished, this starts a new task that continues the same '
+ 'subagent conversation. Either way the response arrives through the returned task id — collect it '
+ 'with `task_output`. A failure means the message was NOT delivered.',
parameters: {
subagent_id: {
type: 'string',
required: true,
description: 'The subagent id returned when the background subagent was started.',
},
message: {
type: 'string',
required: true,
description: 'The message to deliver to the subagent.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
route: {
type: 'string',
required: true,
enum: ['steered', 'started'],
},
taskId: { type: 'string', required: true },
},
},
render: (args, value) => [{
type: 'text',
text: value.route === 'steered'
? `message delivered to running task ${value.taskId}`
: `message started task ${value.taskId} continuing subagent ${args.subagent_id}`,
}],
},
execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session to authorize Task access with.
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
}
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const result = ctx.subagentControl.sendMessage(parent, SessionId(args.subagent_id), message)
return Promise.resolve(result)
},
}))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-subagent-control`.
* @module @deepseek-ai/dsh-tool-subagent-control/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control'
/** Cordis companion plugin name. */
export const name = 'tool-subagent-control-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; delivery
* and activation relations are owned by the control service it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentControlService from '@deepseek-ai/dsh-subagent-control'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalTaskService)
await ctx.plugin(ToolTasks, {})
await ctx.plugin(SubagentControlService)
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
let calls = 0
function callTool(ctx: Context, name: string, args: unknown, agent?: unknown) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++calls}`),
name,
arguments: args,
...agent !== undefined ? { agent: agent as never } : {},
})
}
describe('dsh-tool-subagent-control', () => {
it('registers send_message once, globally, with the two required parameters', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
expect(schemas[0]!.description).toContain('task_output')
})
it('cold-resumes a settled child and renders the started route with its task id', async () => {
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
const started = ctx.subagentControl.startContinuable({
provider: 'spawn',
label: 'work',
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
})
await ctx.tasks.wait(started.taskId, 5_000, parent)
const result = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'and then?',
}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`)
const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent)
expect(text(collected)).toBe('second answer\n[status: completed]')
})
it('renders the steered route when the child is still running', async () => {
// Script the child's single turn as two steps: the steer joins mid-turn.
const { ctx, parent } = await setup([])
let steered: string | undefined
// Reach past the tool into the control service to fake a running route
// deterministically: the tool is a thin adapter, so its steered wording is
// what this test pins.
ctx.subagentControl.sendMessage = (agent, _childId, message) => {
steered = (message[0] as { text: string }).text
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
}
const result = await callTool(ctx, 'send_message', {
subagent_id: 'some-child',
message: 'also consider Y',
}, parent)
expect(result.isError).toBe(false)
expect(steered).toBe('also consider Y')
expect(text(result)).toBe('message delivered to running task subagent-9')
})
it('reports a control-service failure as an errored, not-delivered result', async () => {
const { ctx, parent } = await setup([])
const result = await callTool(ctx, 'send_message', {
subagent_id: 'no-such-child',
message: 'hello?',
}, parent)
// Unknown ids start a Task whose failure carries the unavailable detail;
// synchronous rejections (ownership conflicts) become isError results.
if (result.isError) {
expect(text(result)).toContain('not delivered')
} else {
const taskId = text(result).match(/task (\S+) /)?.[1]
expect(taskId).toBeDefined()
const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
expect(snapshot.status).toBe('failed')
expect(snapshot.detail).toContain('unavailable')
}
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
it('unregisters with its plugin fiber (HMR safety)', async () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(LocalTaskService)
await ctx.plugin(SubagentControlService)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-control')
expect(tool.inject).toEqual(['tools', 'subagentControl'])
expect(typeof tool.apply).toBe('function')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../subagent-control"
},
{
"path": "../../support/invariants"
}
]
}