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:
@@ -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/subagent-inprocess/README.md
|
||||
README.md: 980bc18de088c41dfe2f57a5ff0882a60892fc9f
|
||||
README.zh.md: 1ceb628371c3ae9cee6d8afa6bc1d95ba4cda8ae
|
||||
README.md: 7587b6dfc44bef90756c9f2aba96d54872935fee
|
||||
README.zh.md: 751e745c6c7a64831debd2df58ed8c3d7861f84d
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation, optional child customization, result reading, cancellation, and disposal—has one implementation here.
|
||||
This package is the shared run driver for the two in-process providers. Spawn passes no session seed; fork passes the parent's completed-turn prefix. Everything else—depth, child creation and cold resume, optional child customization, result reading, cancellation, strict steering, and disposal—has one implementation here.
|
||||
|
||||
## Start contract
|
||||
|
||||
@@ -11,21 +11,27 @@ This package is the shared run driver for the two in-process providers. Spawn pa
|
||||
The driver follows this sequence:
|
||||
|
||||
1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/pre-step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records.
|
||||
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
|
||||
|
||||
## Cold resume
|
||||
|
||||
`resumeInProcessRun(request): Promise<SubagentRun>` reconstructs a persisted continuable child under the live parent's scope: `parent.ctx.agents.resume` loads the child's own transcript through persistence (a fork child's log already contains its seed prefix, so resume never re-forks current parent history), the descriptor's persona and tool filter are reapplied in the unpublished setup window, and the descriptor's `agentProvider`/`agentModel` become the runtime options. The persisted header stays authoritative for lineage and the delegation-depth floor. The activation's result boundary is the resumed log length: only this follow-up turn's output becomes the run result. Publication, abort handoff, and disposal follow the same contract as start.
|
||||
|
||||
## Cancellation and ownership
|
||||
|
||||
The required request signal covers both startup and the live run. Before publication, `AgentCreationTransaction` observes it, rolls back, and rejects. The factory detaches that creation-only listener before returning; the driver immediately checks the signal once more before installing a minimal live-run listener, closing the handoff race. After publication, abort cancels the child.
|
||||
|
||||
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.
|
||||
|
||||
## Spawn and fork inputs
|
||||
|
||||
`InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output.
|
||||
@@ -110,5 +116,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
|
||||
- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建、可选的子 agent 定制、结果读取、取消和 dispose(资源释放),都在此共用同一套实现。
|
||||
本包是两个进程内提供方共用的运行驱动器。spawn 不传入会话初始内容;fork 传入父 agent(智能体)已完成轮次的前缀。其余机制,包括深度、子 agent 创建与冷恢复、可选的子 agent 定制、结果读取、取消、严格 steering(中途引导)和 dispose(资源释放),都在此共用同一套实现。
|
||||
|
||||
## 启动契约
|
||||
|
||||
@@ -11,21 +11,27 @@
|
||||
驱动器按以下顺序运行:
|
||||
|
||||
1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。
|
||||
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。
|
||||
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。
|
||||
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
|
||||
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会安装一次性的 `agent/pre-step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。
|
||||
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。
|
||||
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的零步骤轮次。
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
|
||||
|
||||
## 冷恢复
|
||||
|
||||
`resumeInProcessRun(request): Promise<SubagentRun>` 会在当前父级作用域下重建持久化的可继续子 agent:`parent.ctx.agents.resume` 通过持久化层加载子 agent 自身的 transcript(文本记录;fork 子 agent 的日志已经包含初始前缀,因此恢复绝不会再次 fork 当前父级历史),在未发布的设置窗口中重新应用描述符中的 persona 和工具过滤器,并把描述符中的 `agentProvider` / `agentModel` 作为运行时选项。持久化 header 对谱系和委派深度下限保持权威性。activation 的结果边界是恢复后日志的长度:只有此次后续轮次的输出会成为运行结果。发布、中止交接和 dispose 遵循与启动相同的契约。
|
||||
|
||||
## 取消与所有权
|
||||
|
||||
必需的请求信号同时覆盖启动阶段和实时运行。发布前,`AgentCreationTransaction` 会观察该信号、回滚并拒绝。工厂返回前会移除仅用于创建阶段的监听器;驱动器随即再次检查信号,然后安装最小化的实时运行监听器,从而消除交接竞态。发布后,中止会取消子 agent。
|
||||
|
||||
兑现后,调用方拥有该运行。提供方插件卸载不会撤销它。`dispose()` 会移除实时中止监听器、记录取消,并委托给返回的 `AgentHandle.dispose()`;后者通过可复用的完全停稳事务停止循环、移除 agent 和会话,并展开有作用域的注册。取消决定所有尚未完成的进行中结果,并将其报告为 `aborted`;已经完成的轮次仍保持完成状态。
|
||||
|
||||
运行公开严格的 `steer` 功能:同步的 `AgentStatus.running` 检查与 `Agent.steer()` 调用位于同一个调用栈帧中,因此消息要么加入观察到的轮次,要么抛错。运行不会触达 Agent 层在空闲时排队并启动新轮次的 fallback;否则会在运行结果读取后启动一个未被跟踪的轮次。
|
||||
|
||||
## Spawn 与 fork 输入
|
||||
|
||||
`InProcessRunOptions` 的形态为 `{ seed?: SessionEvent[] }`。spawn 省略该值。fork 提供平衡的已完成轮次前缀,并记录其长度,确保结果读取器不会把作为初始内容的父 agent 消息误认为子 agent 输出。
|
||||
@@ -110,5 +116,4 @@ When you have your final answer, you MUST report it by calling the `structured_o
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **运行不公开 `sendMessage`/`resume`**:进程内运行不具备这些可选运行时能力。
|
||||
- **结构化捕获只接受 `defineTool` schema 子集**:不支持的 JSON Schema 构造会在子 agent 创建前失败;需要更广 schema 词汇的提供方必须采用不同的运行时。
|
||||
|
||||
@@ -9,11 +9,18 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubagentDescriptorData,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
// to the policy services when composed — the driver consumes both
|
||||
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
|
||||
@@ -65,10 +72,27 @@ function prePublicationAbort(): Error {
|
||||
return new Error('subagent request was aborted before child publication')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the one-shot child-scoped contribution that appends the durable
|
||||
* `subagent/descriptor` event. `agent/step` is the first serial seam
|
||||
* inside the child's initial turn, so the append lands after `turn/start` and
|
||||
* before the first request, and reaches persistence with that turn's flush.
|
||||
*/
|
||||
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
|
||||
let appended = false
|
||||
childCtx.on('agent/step', (agent) => {
|
||||
if (appended) return
|
||||
appended = true
|
||||
agent.session.append('subagent/descriptor', descriptor)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish and drive one in-process child. Fulfillment means the agent is
|
||||
* already published in the registry; rejection means the agent factory's
|
||||
* creation transaction and any partially-created child have reached quiescence.
|
||||
* A `request.continuation` publishes exactly its stable child id and appends
|
||||
* its descriptor inside the child's initial turn.
|
||||
* @param request - the trusted typed start request, including its required signal.
|
||||
* @param options - the optional fork seed.
|
||||
* @returns a ready holder-owned run.
|
||||
@@ -88,7 +112,9 @@ export async function startInProcessRun(
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
|
||||
const childId = SessionId(randomUUID())
|
||||
// A continuable delegation names the durable conversation up front; the
|
||||
// provider publishes exactly that id instead of allocating one internally.
|
||||
const childId = request.continuation?.sessionId ?? SessionId(randomUUID())
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = parent.session.header
|
||||
const parentProvider = parent.options.provider
|
||||
@@ -123,9 +149,11 @@ export async function startInProcessRun(
|
||||
if (request.outputSchema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, request.outputSchema)
|
||||
}
|
||||
if (request.continuation !== undefined) {
|
||||
attachDescriptorAppend(childCtx, request.continuation.descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
const handle = await parent.ctx.agents.create({
|
||||
sessionId: childId,
|
||||
meta: {
|
||||
@@ -140,36 +168,84 @@ export async function startInProcessRun(
|
||||
signal: request.signal,
|
||||
setup,
|
||||
})
|
||||
return driveTurn(handle, request.signal, request.prompt, childId, seedLength, structured)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a persisted continuable child under the live parent's scope and
|
||||
* drive one follow-up turn. The resumed session's own transcript is the seed
|
||||
* (loaded through the parent's persistence-backed registry `resume`), so a
|
||||
* fork child never re-forks current parent history; the persisted header
|
||||
* remains authoritative for lineage and the delegation-depth floor.
|
||||
* @param request - the fully resolved resume request from the low-level service.
|
||||
* @returns a fresh ready holder-owned run for this activation.
|
||||
*/
|
||||
export async function resumeInProcessRun(request: SubagentResumeRequest): Promise<SubagentRun> {
|
||||
if (request.signal.aborted) throw prePublicationAbort()
|
||||
const descriptor = request.descriptor
|
||||
const agentOptions: AgentOptions = {
|
||||
...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
|
||||
...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
|
||||
}
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (descriptor.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: descriptor.persona })
|
||||
}
|
||||
if (descriptor.toolFilter !== undefined) childCtx.tools.restrict(descriptor.toolFilter)
|
||||
}
|
||||
|
||||
const handle = await request.parent.ctx.agents.resume({
|
||||
resumeSessionId: request.sessionId,
|
||||
agentOptions,
|
||||
signal: request.signal,
|
||||
setup,
|
||||
})
|
||||
// The result boundary is this activation's own work: everything already in
|
||||
// the resumed transcript belongs to earlier turns.
|
||||
const resumePoint = handle.agent.session.events.length
|
||||
return driveTurn(handle, request.signal, request.prompt, request.sessionId, resumePoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one activation turn on a published child and wrap it as a run. The
|
||||
* caller has already created or resumed the agent; this owns the
|
||||
* signal-handoff race, the live abort listener, result collection past
|
||||
* `boundary`, strict steering, and disposal.
|
||||
*/
|
||||
function driveTurn(
|
||||
handle: AgentHandle,
|
||||
signal: AbortSignal,
|
||||
prompt: ContentBlock[],
|
||||
childId: SessionId,
|
||||
boundary: number,
|
||||
structured?: StructuredAttachment,
|
||||
): SubagentRun | Promise<never> {
|
||||
const child = handle.agent
|
||||
// Agent creation detaches its creation-only abort listener before returning.
|
||||
// Close the narrow handoff race before installing the live-run listener.
|
||||
// Static analysis does not model the abort that may land between the
|
||||
// factory's listener detachment and this continuation.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (request.signal.aborted) {
|
||||
flags.cancelled = true
|
||||
await handle.dispose()
|
||||
throw prePublicationAbort()
|
||||
if (signal.aborted) {
|
||||
return handle.dispose().then(() => { throw prePublicationAbort() })
|
||||
}
|
||||
|
||||
const flags = { cancelled: false }
|
||||
const onAbort = (): void => {
|
||||
flags.cancelled = true
|
||||
child.cancel({ kind: 'parent' })
|
||||
}
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
child.followup(createUserMessage({ content: request.prompt, source: { kind: 'user' } }))
|
||||
child.followup(createUserMessage({ content: prompt, source: { kind: 'user' } }))
|
||||
await child.whenIdle()
|
||||
return readResult(
|
||||
child,
|
||||
seedLength,
|
||||
boundary,
|
||||
flags.cancelled,
|
||||
structured ? { captured: structured.captured() } : undefined,
|
||||
)
|
||||
} finally {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -178,21 +254,31 @@ export async function startInProcessRun(
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
flags.cancelled = true
|
||||
return handle.dispose()
|
||||
},
|
||||
steer(content: ContentBlock[]): void {
|
||||
// Strict live delivery: the synchronous running check and 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`)
|
||||
}
|
||||
child.steer(createUserMessage({ content, source: { kind: 'user' } }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one settled child's result from events after its optional fork seed. */
|
||||
/** Read one settled child's result from events after its activation boundary. */
|
||||
function readResult(
|
||||
child: Agent,
|
||||
seedLength: number,
|
||||
boundary: number,
|
||||
cancelled: boolean,
|
||||
structured?: { captured?: { value: unknown } | undefined },
|
||||
): SubagentResult {
|
||||
const own = child.session.events.slice(seedLength)
|
||||
const own = child.session.events.slice(boundary)
|
||||
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
|
||||
const lastEnd = findLastMessageTurnEnd(own)
|
||||
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
|
||||
|
||||
Reference in New Issue
Block a user