diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml index d8bdceb531..3b5eaed10d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.i18n.yaml @@ -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 -2026-07-21-continuable-background-subagents.md: 25ae582b129b2e2dc4a34c6fb3c0247aa644677a -2026-07-21-continuable-background-subagents.zh.md: f7a09ce0519874dad8b32835d0b43914a37350c8 +2026-07-21-continuable-background-subagents.md: abb8a89bd6ec0fbe4a36e7f82c1356fb96b38390 +2026-07-21-continuable-background-subagents.zh.md: 30207dfd757eeada3e8ba961b67c79db3c98aad6 diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md index 25ae582b12..abb8a89bd6 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md @@ -49,7 +49,7 @@ For a continuable initial activation, the control service allocates the stable c Every continuable child turn is admitted through this Task-backed path. A non-terminal Task is the only supported live activation; when no activation exists, its run has already been disposed and the durable child is resumable. Before routing any by-id operation, the control service synchronously compares its association with `ctx.agents.get(childId)`. A registry Agent with no association, or a registry Agent different from the associated `run.localAgent`, is an ownership conflict: the control service fails rather than adopting an idle Agent or attaching an untracked turn. When neither exists, cold resume may proceed; a competing publication after that check still loses at the Agent registry collision boundary. -Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability by synchronously requiring `AgentStatus.running` before calling `Agent.steer()`; the check and call contain no asynchronous boundary. Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. +Routing follows the Task association. A running Task accepts live delivery through the run's optional strict `SubagentRun.steer` capability. An absent Task starts a fresh Task and cold-resumes the child. In-process spawn and fork implement this capability with synchronous checks that share one frame with the `Agent.steer()` call: the child must be `running`, its turn must still be open in the log (status stays `running` through a closed turn's durability flush, where the loop strands drained steering), and no structured capture may have committed (its terminal stop makes the loop discard late steering). Providers must not expose the Agent-level idle fallback as strict steering, because that fallback may start an untracked turn after the observed run has ended. If the Task settles between association lookup and this strict check, `steer()` fails, `send_message` reports the message as not delivered, and that call does not fall through to cold resume; a later retry after Task terminal may start the next activation. The control service does not serialize two callers that race a stopped child through paths outside it, nor does it model a separate settling phase between result production and disposal. The synchronous association install before the producer's first await admits one activation per child in this process — a competing `sendMessage` during resume load observes the pending activation and fails explicitly — while a bypassing publication still loses at the Agent registry's same-session collision boundary. Delivery racing startup, cancellation, completion, or cleanup may also fail. These limitations are explicit rather than hidden behind a larger lifecycle abstraction. diff --git a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md index f7a09ce051..30207dfd75 100644 --- a/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.zh.md @@ -49,7 +49,7 @@ durable child Session 每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,控制服务会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:控制服务会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。 -系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 通过以下方式实现该功能:调用 `Agent.steer()` 前同步要求 `AgentStatus.running`,检查与调用之间不存在异步边界。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 +系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且严格的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 用与 `Agent.steer()` 调用共享同一同步帧的检查来实现该功能:child 必须处于 `running` 状态,其轮次在日志中必须仍然打开(已关闭轮次的持久化 flush 期间状态仍是 `running`,此时循环会丢弃排空的 steering 消息),且不得已有结构化捕获提交(其终止性 stop 会让循环丢弃迟到的 steering)。提供方不得将 Agent 层的 idle fallback 暴露为严格 steering(中途引导),因为观察到的 run 结束后,该 fallback 可能启动一个未受 Task 跟踪的轮次。如果 Task 在查找关联与执行这项严格检查之间进入结算,`steer()` 会失败,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。 控制服务不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `sendMessage` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index ec69056602..b5ec558351 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -240,10 +240,11 @@ interface SubagentRun { /** * OPTIONAL (strict live-steering capability): deliver additional content to * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously require the child to - * be running with no asynchronous boundary before delivery, and must not - * fall back to a queue path that could start a new, untracked turn after - * this run has settled. Throws when the child is not running. A run + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 7587b6dfc4..f0660b1a5b 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -30,7 +30,7 @@ The required request signal covers both startup and the live run. Before publica After fulfillment, the caller owns the run. Provider-plugin unload does not revoke it. `dispose()` removes the live abort listener, records cancellation, and delegates to the returned `AgentHandle.dispose()`, whose memoized quiescence transaction stops the loop, removes the agent and session, and unwinds scoped registrations. Cancellation owns every non-completed in-flight outcome and reports `aborted`; an already-completed turn remains completed. -Runs expose the strict `steer` capability: a synchronous `AgentStatus.running` check and `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. +Runs expose the strict `steer` capability: the synchronous checks and the `Agent.steer()` call share one frame, so delivery joins the observed turn or throws. Delivery requires `AgentStatus.running`, an open turn in the child log (status stays `running` through a closed turn's durability flush, where the loop would strand the message), and no committed structured capture (whose terminal stop makes the loop discard late steering). The Agent-level idle fallback (queue and start a new turn) is deliberately not reachable through the run — that would start an untracked turn after the run's result was read. ## Spawn and fork inputs diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 695283a027..176acb4d70 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -259,13 +259,30 @@ function driveTurn( return handle.dispose() }, steer(content: ContentBlock[]): void { - // Strict live delivery: the synchronous running check and Agent.steer() + // Strict live delivery: the synchronous checks and the Agent.steer() // call share one frame, so delivery joins the observed turn or throws. // Agent.steer()'s own idle fallback would instead QUEUE the message and // start a new, untracked turn after this run's result was read. if (child.status !== 'running') { throw new Error(`subagent child "${childId}" is not running; the message was not delivered`) } + // The status stays `running` through the closed turn's durability flush, + // and the loop DISCARDS terminal-stopped steering drained after turn + // close instead of recording it. Requiring an open turn keeps + // acknowledged delivery honest. + const lastBoundary = child.session.events.findLast( + event => event.type === 'turn/start' || event.type === 'turn/end', + ) + if (lastBoundary?.type !== 'turn/start') { + throw new Error(`subagent child "${childId}" turn has already closed; the message was not delivered`) + } + // A committed structured capture makes the pending `agent/turn-stop` + // checkpoint terminal, and the loop then discards late steering. The + // capture is synchronously observable, so reject rather than + // acknowledge a message the run is about to drop. + if (structured?.captured() !== undefined) { + throw new Error(`subagent child "${childId}" already reported its structured result; the message was not delivered`) + } child.steer(createUserMessage({ content, source: { kind: 'user' } })) }, } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index ff6db01285..62ef25e095 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -120,6 +120,33 @@ describe('in-process structured output', () => { await run.dispose() }) + it('strict steer rejects delivery once the structured result is captured', async () => { + // Hold the capture's tool result open so the child is observably running + // with a committed capture: the pending agent/turn-stop checkpoint is + // terminal, and the loop would DISCARD a steering message, so an + // acknowledged delivery here would be a lie. + let releaseResult: (() => void) | undefined + const { ctx, parent } = await setup([ + toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), + ]) + ctx.on('agent/post-step', (agent) => { + if (agent.session.header.parentSession === undefined || releaseResult !== undefined) return + return new Promise((resolve) => { releaseResult = resolve }) + }) + const run = await ctx.subagents.start('spawn', structuredRequest(parent)) + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseResult !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(() => { run.steer!([{ type: 'text', text: 'one more thing' }]) }) + .toThrow(/already reported its structured result; the message was not delivered/) + releaseResult!() + const result = await run.result + expect(result.structured).toEqual({ answer: 7 }) + await run.dispose() + }) + it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => { // One model response carrying structured_output FIRST and a side-effecting // call after it: the continuation veto only fires at step end, so without diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index fb631ee10e..1de7329ecc 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -245,4 +245,45 @@ describe('startInProcessRun', () => { expect(ctx.agents.list()).toHaveLength(beforeAgents) expect(ctx.sessions.list()).toHaveLength(beforeSessions) }) + + it('strict steer rejects a settled child instead of queueing an untracked turn', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const run = await startInProcessRun(request(parent), {}) + await run.result + // The child is idle after its turn: Agent.steer() would silently QUEUE. + expect(() => { run.steer!([{ type: 'text', text: 'late' }]) }) + .toThrow(/not running; the message was not delivered/) + const child = ctx.agents.get(run.id)! + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) + + it('strict steer rejects the closed-turn flush window where the loop discards steering', async () => { + // Hold the turn-end durability flush open: the turn has closed in the log + // and status is still `running`, exactly the window where the loop would + // discard a drained steering message instead of recording it. + const { ctx, parent } = await setup([textResponse('quick')]) + let releaseFlush: (() => void) | undefined + ctx.on('session/flush', (session) => { + if (session.header.parentSession === undefined || releaseFlush !== undefined) return + const lastEnd = session.events.findLast(event => event.type === 'turn/end') + if (lastEnd === undefined) return + return new Promise((resolve) => { releaseFlush = resolve }) + }) + const run = await startInProcessRun(request(parent), {}) + const child = ctx.agents.get(run.id)! + // Wait until the child's turn has closed while the flush keeps it running. + await new Promise((resolve) => { + const timer = setInterval(() => { + if (releaseFlush !== undefined) { clearInterval(timer); resolve() } + }, 5) + }) + expect(child.status).toBe('running') + expect(() => { run.steer!([{ type: 'text', text: 'into the void' }]) }) + .toThrow(/turn has already closed; the message was not delivered/) + releaseFlush!() + await run.result + expect(child.session.events.some(event => event.type === 'steering/message')).toBe(false) + await run.dispose() + }) }) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1537b50aa8..8cc88eb0c2 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,10 +219,11 @@ export interface SubagentRun { /** * OPTIONAL (strict live-steering capability): deliver additional content to * the actively running child turn. STRICT means delivery joins the observed - * turn or fails — the implementation must synchronously require the child to - * be running with no asynchronous boundary before delivery, and must not - * fall back to a queue path that could start a new, untracked turn after - * this run has settled. Throws when the child is not running. A run + * turn or fails — the implementation must synchronously verify, with no + * asynchronous boundary before delivery, that the child is running and its + * turn can still record the message, and must not fall back to a queue path + * that could start a new, untracked turn or silently drop the message after + * this run has settled. Throws when delivery cannot join the turn. A run * represents one disposable activation, so it has no cold-resume operation; * resuming a settled child goes through {@link SubagentProvider.resume}. */ diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 1b8f2e4cbf..51e97f10d1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -285,6 +285,13 @@ export function apply(ctx: Context, config: Config): void { 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. + if (ctx.tools.get('send_message') === 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. diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 41b383bf9e..9c3ddb461f 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -17,6 +17,7 @@ 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' @@ -825,7 +826,7 @@ describe('dsh-tool-subagent continuable background mode', () => { }) /** Boot the real continuable stack: loop, persistence, spawn, tasks, control. */ - async function continuableSetup() { + async function continuableSetup(options: { controlTool?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) @@ -837,6 +838,7 @@ describe('dsh-tool-subagent continuable background mode', () => { 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' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([ textResponse('continuable answer'), @@ -886,6 +888,21 @@ describe('dsh-tool-subagent continuable background mode', () => { 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([]) + }) }) describe('background preflight failure (no orphaned child, by construction)', () => {