fix(sdk): mount task controls for subagents

This commit is contained in:
Dudu-0223
2026-07-24 16:33:44 +08:00
committed by imccyu
parent 43151ed9c0
commit 0a95ad8cc0
5 changed files with 19 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-21-continuable-background-subagents.md: 67388825d93bd4f6f39a11f6deec0aeb42c2ed2a
2026-07-21-continuable-background-subagents.zh.md: 8a8fe6f0abf7998c4d34a21ffb8db57651968fdc
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md
2026-07-21-continuable-background-subagents.md: 2ee8a7ce19bba3f44a5bd58429e323e0eb818d36
2026-07-21-continuable-background-subagents.zh.md: 53a4797dbafb7c69a45b99646be6bb58b465469b

View File

@@ -35,7 +35,7 @@ Every later turn creates another Task. Its producer resources cover only that ac
Opening a child session in a human-facing adapter reads its persisted transcript and does not resume an Agent merely to display it. Human input starts or joins the same Task-backed activation used by parent input through the control service. A human-started Task retains the exact currently loaded parent Agent as its notification target, and `task_output` remains the single result path. The existing completion listener injects at most one unsolicited notice while the Task is unreported; `kill`, a terminal read, or a terminal wait may mark it reported and suppress that notice. Human interaction is therefore permitted only while that parent instance remains live. A user-owned conversation that may outlive the parent and explicitly merge a conclusion back belongs to [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md), not this Task-owned lifecycle.
`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations.
`TaskService.start()` rejects producers when no Task control surface is attached. A human-facing adapter that accepts child input must therefore attach a Task control surface, or run in a deployment that loads `@deepseek-ai/dsh-tool-tasks`; loading the Task service alone is insufficient. SDK-generated spawn and fork compositions mount `@deepseek-ai/dsh-tasks` and `@deepseek-ai/dsh-tool-tasks` with the subagent control pair. This dependency is the cost of using the same Task result, cancellation, and notification path for parent- and human-started activations.
Cancellation always targets the whole current activation. If human and parent messages have joined one turn, either caller's cancellation aborts that turn, disposes its run, and settles its Task as `killed`; the messages do not have independent results or cancellation rights. Independent cancellation requires a later message to start a separate turn instead of steering the current one.
@@ -110,6 +110,7 @@ Task records and active-run associations are process-local. Persistence makes th
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` pins the continuable durability boundary: a permanent flush failure rejects with `DURABILITY_FAILED` and its cause, a transient loop-checkpoint failure can succeed on the final confirmation, cancellation owns either final-checkpoint outcome, resume also confirms durability, and foreground runs remain best-effort. `packages/subagent/subagent-control/tests/subagent-control.spec.ts` drives the real stack (agent loop, JSONL persistence, spawn/fork providers, Task service and surface, control service) keylessly: initial and resumed activations create fresh Tasks and dispose their runs before terminal; the descriptor event is turn-enclosed, model-hidden, versioned, and durable under the control-allocated child id; `task_kill` during a run, a final durability checkpoint, or cold-resume lookup settles `killed` after quiescence with no child work; steering joins the running Task without a second Task and retains the caller source; cold follow-ups accumulate turns in one durable transcript with their source and declared composition reconstructed; fork resume keeps the persisted seed boundary and never re-forks newer parent history; resumed depth uses the persisted header floor; foreign-parent, descriptor-less, and unmaterialized ids fail their started Task with the id unavailable; ownership conflicts and steering-settlement races report not-delivered without cold-resume fallthrough; competing sends during resume load are admitted once.
- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` pins the `send_message` schema, coordinator attribution, both route renderings, the not-delivered failure, the no-agent rejection, and HMR disposal.
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` covers the capability-branched background route: a resumable provider returns both ids through the control service and advertises `send_message`, a one-shot provider keeps the plain task acknowledgement, and a resumable provider without the control service fails loud.
- `packages/sdk/helper/tests/project.spec.ts` pins the Task service and model-facing Task controls in generated spawn and fork compositions.
- The keyless ACP snapshot scenario `subagent-continuable` (examples/acp-agent) pins the model-visible transcript: the two-id acknowledgement, a final durability-confirmation failure rendered through `task_output` without unconfirmed child output, and a `send_message` follow-up whose started Task fails with the id unavailable.
## Consequences

View File

@@ -35,7 +35,7 @@ durable child Session
用户界面适配器打开 child 会话时,只读取持久化 transcript不会仅为展示而恢复 agent。用户输入通过控制服务启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。
如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 控制插件对的同时,也会挂载 `@deepseek-ai/dsh-tasks``@deepseek-ai/dsh-tool-tasks`这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次任一调用方发起取消都会中止该轮次、dispose 其 run并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。
@@ -110,6 +110,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界flush 持续失败时会以 `DURABILITY_FAILED` 拒绝并保留失败原因循环检查点的瞬时失败可在最终确认成功后继续完成发生取消时最终检查点无论成功还是失败都由取消优先决定结果resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent-control/tests/subagent-control.spec.ts` 以无密钥方式驱动真实栈agent loop、JSONL 持久化、spawnfork 提供方、Task 服务与控制面、控制服务):初始及恢复后的激活都会创建新 Task并在进入终态前 dispose 各自的 run描述符事件位于轮次内、对模型隐藏、带版本并在控制服务分配的 child id 下持久化;在 run 运行期间、最终持久性检查点执行期间或 cold resume 查找期间执行 `task_kill`,会在完全停稳后结算为 `killed`,且不产生任何 child 工作steering 会加入运行中的 Task不创建第二个 Task并保留调用方来源cold follow-up 会在一份持久化 transcript 中累积轮次,并重建其来源和声明的组合配置;恢复 fork 会保持持久化 seed 边界,绝不重新 fork parent 更新后的历史;恢复后的深度以持久化 header 为下界;外来 parent、无描述符及 unmaterialized 的 id 会带着「id 不可用」使其已启动的 Task 失败;所有权冲突和 steering 与结算的竞态会报告未送达且不改用从持久化存储恢复路径resume 加载期间竞争的发送只准入一次。
- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR热模块替换dispose。
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖按功能分支的后台路由:可恢复的提供方会通过控制服务返回两个 id 并公开 `send_message`,一次性提供方保持普通的 task 确认消息,而缺少控制服务的可恢复提供方会明确失败。
- `packages/sdk/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。
- 无密钥 ACP 快照场景 `subagent-continuable`examples/acp-agent固定模型可见的 transcript双 id 确认消息、最终持久性确认失败(该失败通过 `task_output` 呈现,且不包含未经确认的 child 输出),以及一次 `send_message` 后续操作——其已启动的 Task 会带着「id 不可用」失败。
## 影响

View File

@@ -212,6 +212,8 @@ config:
// The control pair rides every resumable in-process option: background
// delegation on spawn/fork is continuable and advertises send_message.
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks' },
{ kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' },
{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' },
{ kind: 'npm-cordis-config-entry', id: 'subagent-control', package: '@deepseek-ai/dsh-subagent-control' },
{ kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' },

View File

@@ -204,6 +204,16 @@ describe('SdkProject and ProjectEditSession', () => {
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
})
it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => {
const project = await createCommitted([selection('subagent', [option])])
expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks')
expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks')
expect(project.packageManifest().dependencies).toMatchObject({
'@deepseek-ai/dsh-tasks': '^0.0.1',
'@deepseek-ai/dsh-tool-tasks': '^0.0.1',
})
})
it('round-trips embed app projects without a front-door Cordis config entry', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
temporary.push(root)