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

@@ -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: 20bb6b9c59a13f23301368faefe18849ccc0b1e9
README.zh.md: 8da57896359f5ac47d0ec076c3395d2e7fb1e02a
README.md: 7d32da3c974361eb5e58cdb2ee5be756383ad3d1
README.zh.md: eadc168fd07701b3e3d9600b3fe69bd8b22e235a

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 tool registers the parent-owned task before starting the provider and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`. 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, then maps completed final text, abort to `killed`, and other failures to `failed`. 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).
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).
`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).
@@ -64,7 +64,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Start returns exactly `started background subagent task <id>`. The generic task surface provides later status, final output, cancellation responses, and notices.
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.
#### 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 }`,渲染为 `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)。
设置 `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)。
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
@@ -64,7 +64,7 @@
#### 模型看到的内容
启动时原样返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知。
对于可恢复提供方,启动时精确返回 `started subagent <childId> as task <taskId>`;对于一次性提供方,则返回 `started background subagent task <id>`。通用任务接口提供后续状态、最终输出、取消响应和通知;`send_message`(来自 `dsh-tool-subagent-control`)会把后续消息交付给可继续子 agent。
#### Token 影响

View File

@@ -31,6 +31,7 @@
"@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"
@@ -43,7 +44,12 @@
"@deepseek-ai/dsh-agent": "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-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",

View File

@@ -1,20 +1,23 @@
/**
* 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; background
* calls use an independent cancellation signal and settle a final-output task
* only after child disposal.
* 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.
* @module @deepseek-ai/dsh-tool-subagent
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
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 type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } 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'
@@ -85,18 +88,6 @@ export const Config: z<Config> = z.object({
maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3),
})
/**
* Flatten a child's final output blocks to text for the tool result. The child
* may return non-text blocks; this path returns only text. Structured results
* use `outputSchema`.
*/
function outputText(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
.map(b => b.text)
.join('')
}
/** Render text blocks from the canonical JSON block array without trusting arbitrary values. */
function outputValueText(values: JsonValue[]): string {
return values
@@ -107,6 +98,17 @@ function outputValueText(values: JsonValue[]): string {
.join('')
}
/** Settle pending startup without rejecting the task producer contract. */
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
try {
return await settleRun(await start)
} catch (error: unknown) {
return signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
}
/** A non-`completed` stop reason means the child did not finish cleanly. */
function stopReasonError(result: SubagentResult): string | undefined {
switch (result.stopReason) {
@@ -127,50 +129,6 @@ function stopReasonError(result: SubagentResult): string | undefined {
}
}
/**
* Map a child result to the task outcome: completed carries final text,
* aborted is killed, and every other reason is failed without partial output.
* @param result - child terminal result.
* @returns outcome for the `ctx.tasks` registration.
*/
export function runOutcome(result: SubagentResult): TaskOutcome {
switch (result.stopReason) {
case 'completed':
return { status: 'completed', output: outputText(result.output) }
case 'aborted':
return { status: 'killed' }
case 'error':
case 'max-tokens':
case 'refusal':
return { status: 'failed', detail: result.stopReason }
// Merge-extensible reasons remain failures with their raw detail.
default:
return { status: 'failed', detail: String(result.stopReason) }
}
}
/**
* Await the child result, dispose the run, then return its task outcome. Result
* and disposal failures become `failed`; when both fail, both details survive.
* @param run - live run to settle and release.
* @returns outcome after child resources are released.
*/
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
let outcome: TaskOutcome
try {
outcome = runOutcome(await run.result)
} catch (error: unknown) {
outcome = { status: 'failed', detail: String(error) }
}
try {
await run.dispose()
} catch (error: unknown) {
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
}
return outcome
}
/**
* Model-facing wording from the provider's conversation-history descriptor
* ({@link SubagentProvider.inheritsParentContext}).
@@ -210,30 +168,6 @@ function providerWording(inheritsConversation: boolean): { description: string;
}
}
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
return {
prompt: [{ type: 'text', text: prompt }],
parent,
signal,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
}
/** Settle pending startup without rejecting the task producer contract. */
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
try {
return await settleRun(await start)
} catch (error: unknown) {
return signal.aborted
? { status: 'killed' }
: { status: 'failed', detail: String(error) }
}
}
export function apply(ctx: Context, config: Config): void {
// Direct apply() bypasses Schemastery's numeric constraints. A direct-apply
// omission stays capless (the schema default only runs through the loader).
@@ -257,10 +191,18 @@ 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
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
description: wording.description + (backgroundEnabled
? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
? 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`.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
parameters: {
description: {
@@ -276,7 +218,10 @@ export function apply(ctx: Context, config: Config): void {
...backgroundEnabled ? {
run_in_background: {
type: 'boolean' as const,
description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
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.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
},
} : {},
},
@@ -289,6 +234,7 @@ export function apply(ctx: Context, config: Config): void {
properties: {
kind: { type: 'string', required: true, const: 'background' },
taskId: { type: 'string', required: true },
subagentId: { type: 'string' },
},
},
{
@@ -305,7 +251,9 @@ export function apply(ctx: Context, config: Config): void {
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? `started background subagent task ${value.taskId}`
? value.subagentId === undefined
? `started background subagent task ${value.taskId}`
: `started subagent ${value.subagentId} as task ${value.taskId}`
: outputValueText(value.output),
}],
},
@@ -316,27 +264,54 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
}
const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined
const request = {
prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[],
parent,
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
...maxDepth !== undefined ? { maxDepth } : {},
}
if (args.run_in_background === true) {
// The validator permits undeclared keys, so schema omission also needs
// execution-time enforcement.
if (!backgroundEnabled) {
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 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({
provider: config.provider,
label: args.description,
request,
})
return {
kind: 'background' as const,
taskId: started.taskId,
subagentId: started.childId,
}
}
const tasks = ctx.get('tasks')
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// Task preflight finishes before the starter can spawn a child.
// One-shot background child: task preflight finishes before the
// starter can spawn, and the task-owned signal covers startup.
const id = tasks.start({
kind: 'subagent',
label: args.description,
owner: parent,
run: () => {
const controller = new AbortController()
const start = ctx.subagents.start(
config.provider,
startRequest(config, args.prompt, parent, controller.signal),
)
const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal })
return {
cancel: (reason?: string) => {
controller.abort(reason ?? 'background subagent task killed')
@@ -349,14 +324,10 @@ export function apply(ctx: Context, config: Config): void {
return { kind: 'background' as const, taskId: id }
}
const request = startRequest(
config,
args.prompt,
parent,
exec.signal,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
const run: SubagentRun = await ctx.subagents.start(config.provider, {
...request,
signal: exec.signal,
})
try {
const result = await run.result

View File

@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
@@ -6,13 +9,18 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
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 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'
import * as tool from '../src/index.ts'
import { runOutcome, settleRun } from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
@@ -808,58 +816,75 @@ describe('dsh-tool-subagent background mode', () => {
expect(text(killed)).toBe('(no new output)\n[status: killed]')
})
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
const output = [{ type: 'text' as const, text: 'partial' }]
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
// Merge-extensible: an unknown reason is failed-with-detail, never success.
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
})
describe('dsh-tool-subagent continuable background mode', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
it('settleRun disposes the run before reporting, on both result paths', async () => {
const order: string[] = []
const completed = await settleRun({
id: SessionId('child-1'),
localAgent: undefined,
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
dispose() { order.push('dispose'); return Promise.resolve() },
})
order.push('reported')
expect(completed).toEqual({ status: 'completed', output: 'ok' })
expect(order).toEqual(['dispose', 'reported'])
/** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */
async function continuableSetup() {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-'))
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, { provider: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
textResponse('continuable answer'),
]))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
// An infrastructure rejection still disposes and reports failed.
let disposed = false
const failed = await settleRun({
id: SessionId('child-2'),
localAgent: undefined,
result: Promise.reject(new Error('transport gone')),
dispose() { disposed = true; return Promise.resolve() },
})
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
expect(disposed).toBe(true)
it('a resumable provider advertises send_message and returns both ids', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
expect(schema.description).toContain('send_message')
const disposeFailed = await settleRun({
id: SessionId('child-3'),
localAgent: undefined,
result: Promise.resolve({ output: [], stopReason: 'completed' }),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
const started = await callSubagent(
ctx,
{ description: 'continuable work', prompt: 'dig in', run_in_background: true },
{ agent: parent },
)
expect(started.isError).toBe(false)
const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started))
expect(match).not.toBeNull()
const [, childId, taskId] = match!
const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
expect(snapshot.status).toBe('completed')
expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer')
// The child id names a durable session that outlives the settled Task.
const loaded = await ctx.sessionPersistence.load(SessionId(childId!))
expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
})
const bothFailed = await settleRun({
id: SessionId('child-4'),
localAgent: undefined,
result: Promise.reject(new Error('result failed')),
dispose: () => Promise.reject(new Error('reap failed')),
})
expect(bothFailed).toEqual({
status: 'failed',
detail: 'Error: result failed; dispose failed: Error: reap failed',
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')
})
})

View File

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