feat(subagent): default continuable delegation to background

This commit is contained in:
Dudu-0223
2026-08-11 21:52:59 +08:00
parent 43b66c0a74
commit 8344d64363
73 changed files with 491 additions and 286 deletions
@@ -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: d4c472d54f87d6006aef96079050cf14885ed8fb
README.zh.md: 36f79ac879067dc74fcc6b96122088bb68d53321
README.md: 40ece8bbd6f0f8929b3cb112c0b9b0de20299b8b
README.zh.md: 3f19740ef7abe961d6528e24a5063b3975fc1452
+5 -5
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 whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics.
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 continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The parent is not left guessing when to look, though: the continuation service delivers one settlement notice to it whenever a continuable child's Activation ends, which is why the schema tells the model it will be told and must not poll. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task <id>`, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent <childId>`. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.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,7 +21,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r
| `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 the provider's `prepareContinuable` capability and returns a durable child id; it does not require the follow-up tool. |
| `backgroundMode` | Background lifecycle policy, default `one-shot`. `one-shot` defaults calls to foreground; `continuable` defaults them to background, requires the provider's `prepareContinuable` capability, and returns a durable child id without requiring 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. |
@@ -37,11 +37,11 @@ Foreground and background calls are concurrency-safe: sibling delegations in one
#### What the model sees
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`, and continuable mode describes starting a background subagent that keeps its conversation, returns its subagent id, and reports its own completion — so the model is told never to poll or wait on it — while one-shot mode describes a background task id collected with `task_output` and stopped with `task_kill`.
The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the task id collected with `task_output` or stopped with `task_kill`. A `tool:<toolName>` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result.
#### Token effect
Fixed schema cost per parent request; each provider instance adds one schema.
Fixed schema cost per parent request; each provider instance adds one schema, and each continuable instance adds one short system-prompt section.
#### KV Cache effect
@@ -77,6 +77,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries its closing message, but it is not this call's return value and cannot be awaited here.
- **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here.
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.
+5 -5
View File
@@ -10,7 +10,7 @@
前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。
设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。不过父级无需猜测何时查看:每当可继续子 agent 的 Activation 结束,继续执行服务都会向父级投递一条结算通知——正因如此,schema 才告诉模型它会被通知,且不得轮询。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task <id>`,即使提供方支持可继续子 agent 也不例外通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent <childId>`路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。
`toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
@@ -21,7 +21,7 @@
| `provider`(必填) | 提供方名称(`spawn``fork``acp` 等)。 |
| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 |
| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 |
| `backgroundMode` | 后台生命周期策略,默认 `one-shot``continuable` 要求提供方具备 `prepareContinuable` 能力并返回持久化子 agent ID;它不要求加载后续消息工具。 |
| `backgroundMode` | 后台生命周期策略,默认 `one-shot``one-shot` 默认前台调用;`continuable` 默认后台调用,要求提供方具备 `prepareContinuable` 能力并返回持久化子 agent ID,且不要求加载后续消息工具。 |
| `agentOptions` | 传给具体提供方的子 agent `provider``model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 |
| `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 |
| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 |
@@ -37,11 +37,11 @@
#### 模型看到的内容
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述启用后台模式会添加 `run_in_background`可继续模式描述为启动一个保留其对话、返回子 agent id 并会自行报告完成的后台子 agent——因此模型被告知绝不要轮询或等待它——而一次性模式描述为返回一个`task_output` 收集`task_kill` 停止的后台任务 id
当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述启用后台模式会添加 `run_in_background`可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及`task_output` 收集`task_kill` 停止的 task id。一个 `tool:<toolName>` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台
#### Token 影响
每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema。
每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section
#### KV Cache 影响
@@ -77,6 +77,6 @@
## 已知限制与暂缓事项
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带其收尾消息,但它不是本次调用的返回值,也无法在此等待。
- **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。
- **等待中实例的重复名称发现较晚**(`TODO(subagent-dup-toolname)`):若要阻止提供方注册回滚,需要一份预期名称注册表。
- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
+66 -26
View File
@@ -17,9 +17,13 @@ import type { JsonValue } from '@deepseek-ai/dsh-session'
import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-system-prompt'
export const name = 'tool-subagent'
export const inject = ['tools', 'subagents']
export const inject = ['tools', 'subagents', 'systemPrompt']
/** Prompt order after bounded delegation policy and before child reporting. */
const SUBAGENT_SECTION_ORDER = 116
/** Config: which registered provider this tool delegates to, plus child defaults. */
export interface Config {
@@ -36,9 +40,10 @@ export interface Config {
*/
enableRunInBackground?: boolean
/**
* Background execution policy (default `one-shot`). `continuable` requires a
* provider with the `prepareContinuable` capability and returns the durable
* child id; follow-up adapters remain independently optional.
* Background execution policy (default `one-shot`). `one-shot` defaults calls
* to foreground; `continuable` defaults them to background, requires a provider
* with the `prepareContinuable` capability, and returns the durable child id.
* Follow-up adapters remain independently optional.
*/
backgroundMode?: 'one-shot' | 'continuable'
/**
@@ -208,10 +213,10 @@ function providerWording(inheritsConversation: boolean): { description: string;
return {
description:
'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
+ 'completed turns so far (it does not see the current in-flight turn). Use this when the subtask '
+ 'builds on this conversation\'s context — a follow-up analysis, '
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
+ 'You receive only its final answer, not its intermediate steps.',
+ 'You receive its result, not its intermediate steps.',
promptDescription:
'The task for the subagent. It already sees this conversation\'s completed turns, so build on them '
+ 'freely and state only what is new.',
@@ -220,9 +225,9 @@ function providerWording(inheritsConversation: boolean): { description: string;
return {
description:
'Delegate a self-contained task to a subagent (a separate agent that works in its own context) '
+ 'and return its final result. Use this to offload focused, independent work — research, a scoped '
+ 'to offload focused, independent work — research, a scoped '
+ 'implementation, an analysis — so it does not consume this conversation\'s context. The subagent '
+ 'runs to completion and you receive only its final answer, not its intermediate steps. Give it a '
+ 'returns its result, not its intermediate steps. Give it a '
+ 'complete, standalone prompt: it does not see this conversation.',
promptDescription:
'The complete, self-contained task for the subagent. It does not share this '
@@ -230,6 +235,35 @@ function providerWording(inheritsConversation: boolean): { description: string;
}
}
interface DelegationRunRequest {
readonly run_in_background?: boolean
}
interface DelegationRunSpec {
readonly runInBackground: boolean
}
/** Resolve the model's optional scheduling request into one execution route. */
function resolveDelegationRun(
request: DelegationRunRequest,
options: { readonly backgroundEnabled: boolean; readonly continuable: boolean },
): DelegationRunSpec {
if (!options.backgroundEnabled) {
// The validator permits undeclared keys, so schema omission also needs
// execution-time enforcement.
if (request.run_in_background === true) {
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
}
return { runInBackground: false }
}
return {
// Continuable work is independently scheduled unless the caller explicitly
// needs the result before its next action. One-shot policy keeps its existing
// foreground default because its background result requires Task collection.
runInBackground: request.run_in_background ?? options.continuable,
}
}
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).
@@ -238,6 +272,9 @@ export function apply(ctx: Context, config: Config): void {
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
}
const backgroundEnabled = config.enableRunInBackground !== false
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
const toolName = config.toolName ?? 'subagent'
// Mirror provider lifecycle because sibling load order and HMR replacement
// can change provider availability while this fiber remains active.
let disposeTool: (() => void) | undefined
@@ -252,25 +289,21 @@ export function apply(ctx: Context, config: Config): void {
)
}
const wording = providerWording(provider.inheritsParentContext)
const backgroundEnabled = config.enableRunInBackground !== false
const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable'
if (continuable && provider.prepareContinuable === undefined) {
throw new Error(
`tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``,
)
}
disposeTool = ctx.tools.register(defineTool({
name: config.toolName ?? 'subagent',
name: toolName,
description: wording.description + (backgroundEnabled
// The completion notice is the continuation service's own behavior, not
// a separately installed capability, so this promise holds whenever the
// continuable background path is reachable at all.
? continuable
? ' Set `run_in_background: true` to start a background subagent that keeps its conversation:'
+ ' this call returns only its subagent id, and the subagent works on its own from there. You'
+ ' are told when it finishes, so never poll or wait on it; `send_message` sends it more work.'
: ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ''),
? ' This tool uses its continuable background route by default and immediately returns a durable subagent id. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` to use a foreground run only when you must receive its result before taking your next action.'
: ' This call waits for the result by default. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
: ' This call waits for the subagent and returns its result.'),
parameters: {
description: {
type: 'string',
@@ -286,9 +319,8 @@ export function apply(ctx: Context, config: Config): void {
run_in_background: {
type: 'boolean' as const,
description: continuable
? 'Run as a background subagent that keeps its conversation and return only its subagent id. '
+ 'This call does not wait for it; you are told when it finishes. Send it more work with send_message.'
: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
? 'Whether to use the continuable background route. Defaults to true. Set false to run in the foreground and wait only when your next action depends on its result.'
: 'Whether to run as a background task and return its id. Defaults to false; collect with task_output or stop with task_kill.',
},
} : {},
},
@@ -352,12 +384,8 @@ export function apply(ctx: Context, config: Config): void {
...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)')
}
const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable })
if (runSpec.runInBackground) {
if (continuable) {
// Resolves at inbox acceptance: the child owns its own turns from
// there, so this call neither waits for nor collects a result.
@@ -422,4 +450,16 @@ export function apply(ctx: Context, config: Config): void {
// A backend fiber may activate later; a misspelled provider remains visible in this log.
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
}
if (backgroundEnabled && continuable) {
// The section follows provider availability without its own manual
// lifecycle: empty text is omitted from rendered prompts while the tool is
// absent, and the registration itself stays owned by this plugin fiber.
ctx.systemPrompt.section({
name: `tool:${toolName}`,
order: SUBAGENT_SECTION_ORDER,
text: () => ctx.subagents.getProvider(config.provider) === undefined
? ''
: `Use ${toolName}'s continuable background route by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when you cannot take your next action without that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`,
})
}
}
@@ -7,7 +7,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
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 { assembleContextFor, 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'
@@ -313,6 +313,22 @@ describe('dsh-tool-subagent', () => {
expect(text(result)).toBe('late but fine')
})
it('keeps continuable guidance empty while its provider is absent', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SubagentService)
tool.apply(ctx, {
provider: 'later-continuable',
backgroundMode: 'continuable',
maxDepth: 'provider-managed',
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(section => section.name === 'tool:subagent')?.text).toBe('')
expect(ctx.tools.schemas().some(schema => schema.name === 'subagent')).toBe(false)
})
it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -563,7 +579,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// No SubagentService mounted. The tool injects ['tools','subagents'] so its
// No SubagentService mounted. The tool injects its three required services so its
// apply never runs; the tool is absent rather than half-registered.
let booted = true
try {
@@ -578,19 +594,19 @@ describe('dsh-tool-subagent', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
// Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
// Postmortem 0001 guard: this plugin HAS an explicit `inject`, so
// a stray `export default apply` would collapse the module via
// `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
// load with "cannot get property … without inject". Guard the shape directly.
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent')
expect(tool.inject).toEqual(['tools', 'subagents'])
expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
expect(unwrapped).toBe(tool)
expect(unwrapped.name).toBe('tool-subagent')
expect(unwrapped.inject).toEqual(['tools', 'subagents'])
expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt'])
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
@@ -985,21 +1001,31 @@ describe('dsh-tool-subagent continuable background mode', () => {
signal: testToolSignal,
callId: CallId('subagent-continuable'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
arguments: { description: 'do work', prompt: 'Reply OK' },
})).toEqual({ kind: 'parallel' })
})
it('starts a continuable child and returns only its durable id, creating no Task', async () => {
it('defaults continuable delegation to background and returns only its durable id', async () => {
const { ctx, parent } = await continuableSetup()
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
// Continuable delegation has no Task, so the schema promises no collection.
expect(schema.description).not.toContain('task_output')
expect(schema.description).not.toContain('task_kill')
expect(schema.description).toContain('send_message')
expect(schema.description).toContain('continuable background route by default')
const properties = (schema.parameters as {
properties: Record<string, { description?: string }>
}).properties
expect(properties.run_in_background?.description).toContain('Defaults to true')
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(parent))
const guidance = assembly.sections.find(section => section.name === 'tool:subagent')
expect(guidance?.text).toContain("Use subagent's continuable background route by default")
expect(guidance?.text).toContain('runtime sends you a notice containing its outcome')
expect(guidance?.text).not.toContain('do not poll')
const started = await callSubagent(
ctx,
{ description: 'continuable work', prompt: 'dig in', run_in_background: true },
{ description: 'continuable work', prompt: 'dig in' },
{ agent: parent },
)
expect(started.isError).toBe(false)
@@ -1018,6 +1044,20 @@ describe('dsh-tool-subagent continuable background mode', () => {
expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
})
it('waits for a continuable provider only when run_in_background is explicitly false', async () => {
const { ctx, parent } = await continuableSetup()
const result = await callSubagent(
ctx,
{ description: 'blocking work', prompt: 'dig in', run_in_background: false },
{ agent: parent },
)
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected foreground subagent success')
expect(result.value).toMatchObject({ kind: 'foreground' })
expect(text(result)).toBe('continuable answer')
expect(ctx.tasks.list(parent)).toEqual([])
})
it('isolates a cancelled continuable preparation from a concurrent sibling', async () => {
const { ctx, parent } = await continuableSetup()
const bothPreparing = Promise.withResolvers<undefined>()