Merge latest native Windows CI base into coverage follow-up

This commit is contained in:
Tianyi Cui
2026-08-08 20:44:36 +08:00
86 changed files with 3289 additions and 232 deletions

View File

@@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
@@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'subagent.list': return this.api.subagents.list(request)
case 'subagent.history': return this.api.subagents.history(request)
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

View File

@@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient {
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
accepted: true as const,
}))),
}
readonly host: IApiClient['host'] = {

View File

@@ -281,17 +281,22 @@ export class Session implements SessionFace {
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
@@ -300,7 +305,9 @@ export class Session implements SessionFace {
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}

View File

@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -639,7 +639,7 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {

View File

@@ -17,6 +17,7 @@ export const zh = {
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.commands': '命令',
@@ -159,6 +160,7 @@ export const en = {
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.commands': 'Commands',

View File

@@ -83,11 +83,15 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// A continuable child without its live parent cannot accept human input,
// but its primary Stop below stays available while it runs.
const continuable = subagent?.address.mode === 'continuable'
const parentOffline = continuable && !subagent.parentAvailable
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live || blocked !== undefined
// inert no-workspace state, the machine faces absent (no session), or a
// parent-offline continuable child. An owner block also disables input;
// adjudicating and submitting render read-only so the draft stays visible.
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
@@ -350,8 +354,9 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const ordinary = subagent === null
const stopping = running && ordinary
// Ordinary sessions and continuable children stop through the same primary
// action; one-shot children stay send-and-stop-free (read-only takeover).
const stopping = running && (subagent === null || continuable)
const primaryLabel = stopping ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (stopping) {
@@ -478,9 +483,11 @@ export function InputBar({
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
placeholder={placeholder ?? (parentOffline
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}

View File

@@ -143,7 +143,8 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const stopping = over?.running === true && over.subagent === undefined
const stopping = over?.running === true
&& (over.subagent === undefined || over.subagent.address.mode === 'continuable')
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
)!
@@ -250,8 +251,8 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
it('running continuable subagent turns the same primary into Stop while typing stays free', () => {
const { button, textarea, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
@@ -263,23 +264,54 @@ describe('running and lock semantics (queue cut 1)', () => {
parentAvailable: true,
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
// One primary action only: Send switched to Stop, no side-by-side interrupt.
expect(button.getAttribute('aria-label')).toBe('停止生成')
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
expect(sink).not.toHaveBeenCalled()
// Enter still queues the follow-up while the primary offers Stop.
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(stop).not.toHaveBeenCalled()
})
const empty = bench({
it('parent-offline running continuable locks the input but keeps the same primary Stop usable', () => {
const { button, textarea, stop, view } = bench({
running: true,
draft: '',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: false,
},
})
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('停止生成')
expect(button.disabled).toBe(false)
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running one-shot subagent never exposes Stop', () => {
const { button, stop } = bench({
running: true,
draft: '不可停止',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'one-shot',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(stop).not.toHaveBeenCalled()
})
it('keeps both running subagent Enter gestures on Queue transport', () => {

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/client/ui-subagent/README.md
README.md: cb210b219a8c66985eb4e1370468372eed9614b4
README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2
README.md: de55963bbe356e18a4e2b653be823cc5015c1bb7
README.zh.md: ce4244db094bb8b602b854289ec000477bf45f0b

View File

@@ -6,7 +6,7 @@ Web subagent feature owner: contributes the lazily expandable catalog tree to `c
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input is disabled but whose primary Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing stays free while the child runs because every follow-up joins the child's FIFO inbox, and the single primary action follows the ordinary convention — Send while idle, Stop while running, routed through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md).
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child.
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.

View File

@@ -6,7 +6,7 @@ Web subagent 功能 owner向 `conversation.session.header.actions` 贡献可
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态和由日志支撑的可选 title尾随列则在上行显示提供方的持久化 token 用量总计在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome其会话通过 `subagent.prompt` 路由child 运行期间输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间selector 会让位给普通编辑器——其输入区被禁用,但 primary Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入保持自由,因为每条后续消息都会进入 child 的 FIFO inbox而唯一的 primary 操作遵循普通约定——空闲时为 Send运行时为 Stop经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md) 规定。
普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript文本记录
## 已知限制与暂缓事项
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份,也不公开符合授权边界的取消按钮
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

View File

@@ -43,7 +43,11 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
const subagent = owner.session?.subagent
if (subagent === undefined || subagent === null) return null
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
return subagent.parentAvailable ? null : { reason: 'parent-unavailable' }
if (subagent.parentAvailable) return null
// A RUNNING parent-offline continuable child keeps the default composer:
// its input is disabled there, but the same primary Stop stays available so
// the child can be interrupted. Once it stops, this takeover returns.
return owner.session?.running === true ? null : { reason: 'parent-unavailable' }
}
/**

View File

@@ -161,19 +161,26 @@ describe('apply', () => {
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
const owner = (
subagent: ConversationSnapshot['subagent'] | undefined,
running = false,
): ComposerChainProps => ({
interactions: [],
session: subagent === undefined
? undefined
: ({ subagent } as unknown as ConversationSnapshot),
: ({ subagent, running } as unknown as ConversationSnapshot),
})
expect(select(owner(undefined))).toBeNull()
expect(select(owner(null))).toBeNull()
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
.toEqual({ reason: 'one-shot' })
// One-shot stays read-only even while running: it has no stop action.
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
.toEqual({ reason: 'one-shot' })
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
expect(select(owner({ address, parentAvailable: false })))
.toEqual({ reason: 'parent-unavailable' })
// A RUNNING parent-offline continuable yields the default composer, whose
// disabled input still carries the primary Stop; stopped, it takes back over.
expect(select(owner({ address, parentAvailable: false }, true))).toBeNull()
})
})

View File

@@ -928,6 +928,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */',
},
{
signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void',
jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Unclaimed pending inbox work, the Activation,\n * and published descendants are preserved; claimed work is not requeued.\n * Once the interrupted driver is idle, a waking send resumes the parked FIFO\n * queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */',
},
{
signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise<MessageId>',
jsDoc: '/**\n * Deliver selected content from one live continuable child to its durable\n * direct parent. The child is the authority credential; callers cannot name a\n * recipient. Reporting does not conclude the child\'s turn or Activation.\n * @param child - exact live reporting child.\n * @param content - selected model-facing content.\n * @param options - parent scheduling and pre-acceptance cancellation.\n * @returns the stable identity of the parent-accepted message.\n * @throws when continuation services are unavailable, sender authorization\n * fails, or the direct parent is not live.\n */',
@@ -944,6 +948,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */',
},
{
signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>',
jsDoc: '/**\n * Enumerate the root\'s complete session-backed subagent tree in stable\n * pre-order from one live-preferred corpus, without loading or resuming an\n * Agent. Ordinary sessions and one-shot children remain traversal nodes so\n * continuable descendants below them are discovered; each returned entry\n * adds its durable `parentId` and root-relative `depth`. Identity resolution,\n * diagnostics, optional persistence, and cancellation follow the same\n * projection-backed contract as {@link listChildren}.\n * @param rootSessionId - session whose complete descendant tree is listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-candidate diagnostics with tree position, in\n * stable pre-order.\n * @throws {@link SubagentError} under the same conditions as {@link listChildren}.\n */',
},
{
signature: 'registerProvider(provider: SubagentProvider): () => void',
jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */',
@@ -2779,6 +2787,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
},
{
name: 'SubagentDescendantListEntry',
declaration: 'export type SubagentDescendantListEntry = SubagentListEntry & {\n readonly parentId: SessionId;\n readonly depth: number;\n};',
},
{
name: 'SubagentDescriptorData',
declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;',
@@ -2787,6 +2799,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentFollowupOptions',
declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SubagentInterruptAuthority',
declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};',
},
{
name: 'SubagentListEntry',
declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};',

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
@@ -49,6 +49,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect(bash?.sources.bash).toBe('packages/bash/tool-bash/src/index.ts')
const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control')
expect(control?.sources).toEqual({
interrupt_agent: 'packages/subagent/tool-subagent-control/src/index.ts',
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
})

View File

@@ -2013,6 +2013,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return subagentPromptError(request, error, signal)
}
},
// Deliberately no catalog, history, persistence, or parent Agent lookup:
// the core primitive alone authorizes the durable address against the
// live Activation, which is what keeps a live child interruptible while
// its parent Agent is offline. Absent targets are accepted no-ops there.
interrupt(request) {
const { parentSessionId, childSessionId } = request.payload
try {
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
return Promise.resolve(err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
}))
}
return Promise.resolve(err(request, {
code: 'internal',
message: 'subagent interrupt failed',
details: {},
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {

View File

@@ -42,7 +42,8 @@ export type {
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'

View File

@@ -36,6 +36,7 @@ export interface RpcMethodMap {
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'subagent.interrupt': SubagentsApi['interrupt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
/** subagent.interrupt request payload. */
export const subagentInterruptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
/** subagent.interrupt response value. */
export const subagentInterruptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */

View File

@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Uniform acknowledgement that one interrupt request was admitted. */
export interface SubagentInterruptReceipt {
accepted: true
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -94,4 +99,17 @@ export interface SubagentsApi {
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
/**
* Interrupts a live continuable child's current turn under the address's
* durable direct-parent authority, without requiring a live parent Agent,
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
* acknowledges the admitted cancel signal, not target quiescence, so the
* child may remain visibly running briefly. Unclaimed queued follow-ups are
* kept and parked; an absent, idle, or already-completed target is likewise
* `accepted`.
*/
interrupt(
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
): Promise<RpcResponse<SubagentInterruptReceipt>>
}

View File

@@ -58,6 +58,7 @@ import {
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentInterruptValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
@@ -96,6 +97,7 @@ export interface IApiClient {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -171,6 +173,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'subagent.interrupt': subagentInterruptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -407,6 +410,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -60,6 +60,7 @@ import {
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentInterruptRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
@@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -90,7 +97,7 @@ function bench(options: {
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -63,6 +63,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -248,6 +249,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

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/subagent/README.md
README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902
README.md: cd25bace16c91e8b44331dc8e5eab0987607e83a
README.zh.md: 05b1e4dd4c74b6d62df8b0a310534fc9476f3ade

View File

@@ -18,10 +18,12 @@ The [subagent family overview](../README.md) maps implementations and model-faci
| `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. |
| `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. |
| `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. |
| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. An absent target is an accepted no-op; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. |
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. |
| `listDescendants(rootSessionId, signal?)` | Flatten the root's complete session tree in stable pre-order from the same live-preferred corpus, adding each subagent entry's durable `parentId` and root-relative `depth`. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered. Identity, diagnostics, dependencies, and cancellation follow `listChildren()`. |
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
@@ -76,7 +78,7 @@ Run events are scoped to the delegating parent. Every listener is independently
Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order.
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content.
When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start``turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
@@ -84,7 +86,7 @@ When `ctx.sessionProjections` is available, the service registers two projection
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry (`running`/`idle`/`complete`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
@@ -99,8 +101,9 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children.
- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability.
- **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability.
- **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn.
- **Wake gap during cancellation convergence** — a waking follow-up accepted after the interrupt signal is issued but before the active driver becomes idle remains queued until another waking send. Issue #1838 owns the agent-loop wake latch, which also affects ordinary session cancellation.
- **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol.
- **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically.
- **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt.

View File

@@ -18,10 +18,12 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
| `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 |
| `startContinuable(spec)` | 建立一个持久化可继续子 agent并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 |
| `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 |
| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入同步完成、生效异步进行:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 与已发布的后代均保持不变;已被领取进入中断轮次的工作不会重新入队。目标不存在时接受为 no-op错误的 parent 地址以及过期、指向自身或非祖先调用方以 `UNAUTHORIZED` 拒绝。 |
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent包括其 `one-shot``continuable` 模式、`running``inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化持久化缺席时仅枚举在线 child并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 |
| `listDescendants(rootSessionId, signal?)` | 从同一份实时优先语料按稳定 pre-order 展平根的完整会话树,并为每个 subagent 条目附加持久 `parentId` 与相对根的 `depth`。普通会话与一次性 child 仍作为遍历节点因此其下的可继续后代仍可发现。身份、diagnostic、依赖与取消契约均沿用 `listChildren()`。 |
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation因此调用方后续取消既不会取消已接受的轮次也不会 dispose资源释放子 agent。
@@ -76,7 +78,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
提供方新增和移除还会发出 `subagent/provider-added``subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代,因为停止一个轮次是幂等的,且不投递任何内容。
`ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start``turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since``active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。
@@ -84,7 +86,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task其通用状态、收集和取消工具负责后续交互并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnosticinspect 失败是瞬时的 `unavailable`下次列表重试运行中而暂无身份值的候选整行省略描述符尚未追加的创建窗口。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running``complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.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)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task其通用状态、收集和取消工具负责后续交互并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnosticinspect 失败是瞬时的 `unavailable`下次列表重试运行中而暂无身份值的候选整行省略描述符尚未追加的创建窗口。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态(`running``idle``complete`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.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)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`
可继续 Activation 会等待 best-effort 的最终会话 flush但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
@@ -99,8 +101,9 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
## 已知限制与暂缓事项
- **ACP 子 agent 仍为一次性,且无法通过追踪枚举**ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。
- **无 host-user 继续执行**`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。
- **无 host-user 继续执行**`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。
- **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。
- **取消收敛期间存在唤醒缺口**:中断信号发出后、活动 driver 进入 idle 前被接受的唤醒型 follow-up 会保持排队直到另一条唤醒发送到达。Issue #1838 负责 agent-loop 的唤醒锁存;普通会话取消也受此影响。
- **驻留仅限进程内**Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。
- **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent但丢失的消息不会自动重放。
- **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。

View File

@@ -103,6 +103,15 @@ export interface ContinuableStart {
readonly messageId: MessageId
}
/**
* Authority under which one interrupt request is admitted. `user` carries the
* durable direct-parent address a human client presented; `ancestor` carries
* the exact live Agent object whose recorded lineage must contain the caller.
*/
export type SubagentInterruptAuthority =
| { readonly kind: 'user'; readonly parentSessionId: SessionId }
| { readonly kind: 'ancestor'; readonly agent: Agent }
/** Options for following up with one continuable child. */
export interface SubagentFollowupOptions {
/** Durable attribution retained on the delivered message; it grants no authority. */
@@ -412,6 +421,69 @@ export class SubagentContinuationManager {
}
}
/**
* Interrupt one live continuable child's current turn. Admission is
* synchronous and the effect is asynchronous: this authorizes the caller,
* requests `Agent.cancel(cause, { keepInbox: true })` on the target, and
* returns without waiting for the target to observe the signal or reach
* quiescence. The Activation, its handle, accepted unclaimed inbox work, and
* already-published descendants are untouched; work already claimed into the
* interrupted turn is not requeued. Once the interrupted driver is idle, a
* waking send resumes the parked queue.
*
* An absent target is an accepted no-op, which uniformly covers natural
* completion races, repeated requests, one-shot ids, and unknown ids without
* consulting the durable catalog. A target whose disposal transaction is
* already open is likewise an accepted no-op after authorization.
* @param targetSessionId - the durable child session id to interrupt.
* @param authority - the human parent address or exact live ancestor Agent.
* @throws {SubagentError} `UNAUTHORIZED` when the presented authority does
* not own the live target: a stale or self-targeting ancestor caller, a
* parent address that is not the live target's durable direct parent, or
* an ancestor outside the target's recorded live lineage.
*/
interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void {
if (authority.kind === 'ancestor') {
const caller = authority.agent
// A stale caller is rejected even when the target is absent, so a
// replaced same-id Agent can never probe this manager's state.
if (this.ctx.agents.get(caller.id) !== caller) {
throw new SubagentError(
`interrupting "${targetSessionId}" requires the exact live ancestor agent`,
'UNAUTHORIZED',
)
}
if (caller.id === targetSessionId) {
throw new SubagentError(
`agent "${caller.id}" cannot interrupt itself`,
'UNAUTHORIZED',
)
}
}
const activation = this.activations.get(targetSessionId)
if (activation === undefined) return
if (authority.kind === 'user') {
if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) {
throw new SubagentError(
`subagent "${targetSessionId}" belongs to another parent session`,
'UNAUTHORIZED',
)
}
} else if (!activation.ancestry.has(authority.agent)) {
throw new SubagentError(
`subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`,
'UNAUTHORIZED',
)
}
// Disposal already stopped the target with a whole-Activation teardown;
// a second cancel would be a redundant signal on a closing handle.
if (activation.disposal !== undefined) return
activation.handle.agent.cancel(
authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' },
{ keepInbox: true },
)
}
/**
* Deliver explicitly selected content from one resident continuable child to
* its durable direct parent. Sender authorization, parent resolution, and

View File

@@ -19,9 +19,9 @@
* resident. Continuable children never become a {@link SubagentRun}: the
* continuation manager holds their `AgentHandle` directly and orders every turn
* through the child's own inbox, so providers contribute only the detached
* creation spec and see no handle, turn, or teardown. Direct-child discovery
* reads the live session store and optional session persistence directly and
* does not require that continuation runtime.
* creation spec and see no handle, turn, or teardown. Child and descendant
* discovery read the live session store and optional session persistence
* directly and do not require that continuation runtime.
*
* Same-process providers are trusted typed collaborators. Requests, provider
* descriptors, results, and lifecycle payloads are borrowed immutable values;
@@ -58,12 +58,13 @@ import type {
ContinuableStart,
ContinuableStartSpec,
SubagentFollowupOptions,
SubagentInterruptAuthority,
SubagentReportOptions,
} from './continuation.ts'
import SubagentActivationSetupRegistry from './activation-setup-registry.ts'
import type { ContinuableSetupContribution } from './activation-setup-registry.ts'
import { listChildren as listSubagentChildren } from './list-children.ts'
import type { SubagentListEntry } from './list-children.ts'
import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts'
import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
import { snapshotSubagentDescriptor } from './descriptor.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
@@ -111,12 +112,13 @@ export type {
ContinuableStartSpec,
CoordinatorMessageSource,
SubagentFollowupOptions,
SubagentInterruptAuthority,
SubagentReportDelivery,
SubagentReportMessageSource,
SubagentReportOptions,
} from './continuation.ts'
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
export type { SubagentListEntry } from './list-children.ts'
export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
@@ -231,6 +233,25 @@ export class SubagentService extends Service {
return this.requireContinuations().followup(parent, childId, content, options)
}
/**
* Interrupt one live continuable child's current turn under a human parent
* address or an exact live ancestor Agent. Fire-and-return: the cancel
* signal is issued before this returns, but the target may keep running
* until it observes the signal. Unclaimed pending inbox work, the Activation,
* and published descendants are preserved; claimed work is not requeued.
* Once the interrupted driver is idle, a waking send resumes the parked FIFO
* queue. An absent target — including a one-shot or unknown id —
* is an accepted no-op, as is a manager-less composition, which cannot own a
* live Activation.
* @param targetSessionId - the durable child session id to interrupt.
* @param authority - the human parent address or exact live ancestor Agent.
* @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the
* live target.
*/
interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void {
this.continuations?.interrupt(targetSessionId, authority)
}
/**
* Deliver selected content from one live continuable child to its durable
* direct parent. The child is the authority credential; callers cannot name a
@@ -315,6 +336,25 @@ export class SubagentService extends Service {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
/**
* Enumerate the root's complete session-backed subagent tree in stable
* pre-order from one live-preferred corpus, without loading or resuming an
* Agent. Ordinary sessions and one-shot children remain traversal nodes so
* continuable descendants below them are discovered; each returned entry
* adds its durable `parentId` and root-relative `depth`. Identity resolution,
* diagnostics, optional persistence, and cancellation follow the same
* projection-backed contract as {@link listChildren}.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation forwarded to persistence reads
* and observed around every read await.
* @returns children and per-candidate diagnostics with tree position, in
* stable pre-order.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]> {
return listSubagentDescendants(this.ctx, rootSessionId, signal)
}
/**
* Register a provider under its name. Registration is effect-scoped and HMR
* safe; removing a provider blocks new starts but does not revoke runs that

View File

@@ -1,18 +1,16 @@
/**
* Read-only enumeration of one parent's durable subagent children straight
* from the live session store and optional session persistence — no query
* seam. Candidates are the live-preferred merge of both listings filtered to
* durable `origin: 'subagent'` under the parent; each child's mode/label is
* the registered `subagent` projection unit's value, resolved down a
* three-rung ladder: the registry's watermark cache for a live child, a
* durable projection-cache row when it serves an own-suffix identity (the
* Read-only enumeration of durable subagent children and descendant trees
* straight from the live session store and optional session persistence — no
* query seam. Candidates come from one live-preferred corpus; each child's
* mode/label is the registered `subagent` projection unit's value, resolved
* down a three-rung ladder: the registry's watermark cache for a live child,
* a durable projection-cache row when it serves an own-suffix identity (the
* seq gate), and one persistence inspection folded through the registry
* otherwise, validated against the enumerated lifecycle. The projection
* fold is the single
* classification authority — this module parses no descriptor itself. Absent
* persistence, enumeration is live-only: a cold child is unreachable for
* resume anyway, so its absence is capability absence, not an error. The
* module owns no catalog state and does not consult Activation,
* otherwise, validated against the enumerated lifecycle. The projection fold
* is the single classification authority — this module parses no descriptor
* itself. Absent persistence, enumeration is live-only: a cold child is
* unreachable for resume anyway, so its absence is capability absence, not an
* error. The module owns no catalog state and does not consult Activation,
* Agent-registry, continuation-manager, or provider state.
*
* @module @deepseek-ai/dsh-subagent
@@ -88,6 +86,34 @@ export type SubagentListEntry =
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/**
* One entry of a descendant listing: the interpreted subagent facts plus its
* position in the complete session tree. `parentId` is the durable direct
* parent from the enumerated header, and `depth` counts edges from the root.
*/
export type SubagentDescendantListEntry = SubagentListEntry & {
/** Durable direct parent of this candidate in the enumerated tree. */
readonly parentId: SessionId
/** Edge distance from the requested root; direct children are `1`. */
readonly depth: number
}
type CorpusRecord = { readonly header: SessionHeader; readonly live: Session | undefined }
interface ListingRuntime {
readonly projections: SessionProjectionRegistry
readonly persistence: SessionPersistence | undefined
readonly cache: SessionProjectionCache | undefined
readonly corpus: ReadonlyMap<SessionId, CorpusRecord>
readonly subagentParents: ReadonlySet<SessionId>
}
interface PositionedCandidate {
readonly record: CorpusRecord
readonly parentId: SessionId
readonly depth: number
}
/**
* Enumerate one parent's origin-classified direct children from the
* live-preferred merge of `ctx.sessions` and optional session persistence,
@@ -110,6 +136,55 @@ export async function listChildren(
parentSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentListEntry[]> {
const listing = await prepareListing(ctx, signal)
const candidates = [...listing.corpus.values()]
.filter(record => record.header.parentSession === parentSessionId
&& record.header.origin === 'subagent')
.sort(compareCorpusRecords)
const rows = await resolveCandidateRows(candidates, listing, signal)
return rows.filter((row): row is SubagentListEntry => row !== undefined)
}
/**
* Enumerate every session-backed subagent below one root in stable pre-order.
* Ordinary sessions and one-shot children remain traversal nodes, so a
* continuable child below either is still discovered. Classification uses the
* same projection-backed runtime as {@link listChildren}; no Agent is loaded or
* resumed.
* @see SubagentService.listDescendants for the public cancellation and failure contract.
* @param ctx - context carrying the session store, projection registry, and optional persistence/cache.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation observed around every persistence read.
* @returns interpreted subagents with durable direct-parent and root-relative depth.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
*/
export async function listDescendants(
ctx: Context,
rootSessionId: SessionId,
signal?: AbortSignal,
): Promise<SubagentDescendantListEntry[]> {
const listing = await prepareListing(ctx, signal)
const positioned = descendantCandidates(listing.corpus, rootSessionId)
const rows = await resolveCandidateRows(
positioned.map(candidate => candidate.record),
listing,
signal,
)
const entries: SubagentDescendantListEntry[] = []
positioned.forEach((position, index) => {
const row = rows[index]
if (row !== undefined) {
entries.push({ ...row, parentId: position.parentId, depth: position.depth })
}
})
return entries
}
/** Resolve listing services once and build one live-preferred session corpus. */
async function prepareListing(
ctx: Context,
signal: AbortSignal | undefined,
): Promise<ListingRuntime> {
const projections = ctx.get('sessionProjections')
// Checked before any read, even with zero candidates: mode/label are the
// row's strong contract, so a missing fold capability is a deterministic
@@ -150,7 +225,7 @@ export async function listChildren(
}
// Live-preferred merge without header reconciliation: a live record wins
// its id wholesale, exactly as a live-preferred corpus would serve it.
const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>()
const corpus = new Map<SessionId, CorpusRecord>()
for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
for (const session of sessions.list()) {
corpus.set(session.header.id, { header: session.header, live: session })
@@ -161,12 +236,16 @@ export async function listChildren(
subagentParents.add(record.header.parentSession)
}
}
const candidates = [...corpus.values()]
.filter(record => record.header.parentSession === parentSessionId
&& record.header.origin === 'subagent')
.sort((a, b) => a.header.createdAt - b.header.createdAt
|| a.header.id.localeCompare(b.header.id))
return { projections, persistence, cache, corpus, subagentParents }
}
/** Resolve projection-backed rows for aligned candidates with bounded cold reads. */
async function resolveCandidateRows(
candidates: readonly CorpusRecord[],
listing: ListingRuntime,
signal: AbortSignal | undefined,
): Promise<(SubagentListEntry | undefined)[]> {
const { projections, persistence, cache, subagentParents } = listing
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
const coldReads: { index: number; header: SessionHeader }[] = []
candidates.forEach((candidate, index) => {
@@ -212,7 +291,48 @@ export async function listChildren(
))
}
assertListingNotCancelled(signal)
return rows.filter((row): row is SubagentListEntry => row !== undefined)
return rows
}
/** Build origin-classified candidates from the complete tree without recursion. */
function descendantCandidates(
corpus: ReadonlyMap<SessionId, CorpusRecord>,
rootSessionId: SessionId,
): PositionedCandidate[] {
const children = new Map<SessionId, CorpusRecord[]>()
for (const record of corpus.values()) {
const parentId = record.header.parentSession
if (parentId === undefined) continue
const siblings = children.get(parentId)
if (siblings === undefined) children.set(parentId, [record])
else siblings.push(record)
}
for (const siblings of children.values()) siblings.sort(compareCorpusRecords)
const positioned: PositionedCandidate[] = []
const stack: PositionedCandidate[] = (children.get(rootSessionId) ?? [])
.map(record => ({ record, parentId: rootSessionId, depth: 1 }))
.reverse()
const visited = new Set<SessionId>([rootSessionId])
while (stack.length > 0) {
// The length guard proves one frame exists.
// oxlint-disable-next-line typescript/no-non-null-assertion
const position = stack.pop()!
const id = position.record.header.id
if (visited.has(id)) continue
visited.add(id)
if (position.record.header.origin === 'subagent') positioned.push(position)
const descendants = children.get(id) ?? []
for (const record of [...descendants].reverse()) {
stack.push({ record, parentId: id, depth: position.depth + 1 })
}
}
return positioned
}
/** Compare siblings by durable creation time, then id. */
function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number {
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
}
/**

View File

@@ -1717,3 +1717,223 @@ describe('continuable errors', () => {
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
})
describe('SubagentService.interrupt', () => {
it('aborts the current turn durably, parks accepted follow-ups, and resumes them only on a waking send', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('first'), gate: releaseFirst.promise },
{ chunks: textResponse('second') },
{ chunks: textResponse('third') },
{ chunks: textResponse('fourth') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
await followup(ctx, parent, started.childId, message('parked B'))
await followup(ctx, parent, started.childId, message('parked C'))
const cancelSpy = vi.spyOn(child, 'cancel')
ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
expect(cancelSpy).toHaveBeenCalledTimes(1)
expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
// Cancellation is cooperative: the held model call observes it on release.
releaseFirst.resolve(undefined)
await child.whenIdle()
// Parked, not resumed: no second model request follows the abort, the
// accepted follow-ups stay pending, and the same Activation stays resident.
expect(adapter.requests).toHaveLength(1)
expect(child.inbox.nextTurn).toHaveLength(2)
expect(child.status).toBe('idle')
expect(ctx.agents.get(started.childId)).toBe(child)
// Only an explicit waking send restores the driver; the parked items then
// run before it in the existing FIFO order.
await followup(ctx, parent, started.childId, message('waking D'))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D'])
const turnEnds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => (event).data.reason.kind)
expect(turnEnds).toEqual(['aborted', 'completed', 'completed', 'completed'])
})
it('interrupts only the target while its resident descendant keeps running', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
const childCancel = vi.spyOn(child, 'cancel')
const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel')
ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
expect(childCancel).toHaveBeenCalledTimes(1)
releaseChild.resolve(undefined)
await child.whenIdle()
// The target parks as a waiting owner; the published descendant was never
// signalled and keeps its own turn open.
expect(grandchildCancel).not.toHaveBeenCalled()
expect(ctx.agents.get(started.childId)).toBe(child)
expect(ctx.agents.get(grandchild.childId)).toBe(grandchildAgent)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(grandchild.childId)
const turnEnds = loaded.events
.filter(event => event.type === 'turn/end')
.map(event => (event).data.reason.kind)
expect(turnEnds).toEqual(['completed'])
})
it('authorizes the human address against the live target\'s durable direct parent', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const cancelSpy = vi.spyOn(child, 'cancel')
expect(() => { ctx.subagents.interrupt(started.childId, {
kind: 'user',
parentSessionId: SessionId('stranger'),
}) }).toThrow(/belongs to another parent session/)
expect(cancelSpy).not.toHaveBeenCalled()
ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
hold.resolve(undefined)
await waitNoActivation(ctx, started.childId)
})
it('lets a deep exact live ancestor interrupt its descendant with the parent cause', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable(startSpec(child))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
const childCancel = vi.spyOn(child, 'cancel')
const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel')
// Deep ancestor: the top-level parent interrupts the grandchild.
ctx.subagents.interrupt(grandchild.childId, { kind: 'ancestor', agent: parent })
expect(grandchildCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true })
// Direct ancestor: the same authority kind covers the immediate parent.
ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent })
expect(childCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true })
releaseChild.resolve(undefined)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('rejects self, sibling, stale, and unrelated ancestor callers without touching the target', async () => {
const releaseA = Promise.withResolvers<undefined>()
const releaseB = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('a'), gate: releaseA.promise },
{ chunks: textResponse('b'), gate: releaseB.promise },
])
const { ctx, parent } = await setupWith(adapter)
const targetStart = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const target = ctx.agents.get(targetStart.childId)!
const siblingStart = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const sibling = ctx.agents.get(siblingStart.childId)!
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
const stale = { ...parent, id: parent.id } as unknown as Agent
const cancelSpy = vi.spyOn(target, 'cancel')
expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: target }) })
.toThrow(/cannot interrupt itself/)
expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: sibling }) })
.toThrow(/not a live descendant/)
expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stranger }) })
.toThrow(/not a live descendant/)
expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stale }) })
.toThrow(/exact live ancestor/)
// A stale caller is rejected before target lookup, even for an absent id.
expect(() => { ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: stale }) })
.toThrow(/exact live ancestor/)
expect(cancelSpy).not.toHaveBeenCalled()
releaseA.resolve(undefined)
releaseB.resolve(undefined)
await waitNoActivation(ctx, targetStart.childId)
await waitNoActivation(ctx, siblingStart.childId)
})
it('accepts absent and one-shot ids as no-ops without touching the one-shot Agent', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
ctx.subagents.interrupt(SessionId('missing'), { kind: 'user', parentSessionId: parent.id })
ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: parent })
const run = await ctx.subagents.start('spawn', {
label: 'one-shot work',
prompt: message('one-shot work'),
parent,
signal: testSignal,
})
const oneShot = run.localAgent!
const cancelSpy = vi.spyOn(oneShot, 'cancel')
ctx.subagents.interrupt(run.id, { kind: 'user', parentSessionId: parent.id })
ctx.subagents.interrupt(run.id, { kind: 'ancestor', agent: parent })
expect(cancelSpy).not.toHaveBeenCalled()
await run.result
await run.dispose()
})
it('accepts an interrupt after natural completion', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent })
})
it('accepts an interrupt that lost the race with disposal without signalling twice', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable(startSpec(parent))
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const cancelSpy = vi.spyOn(child, 'cancel')
// Scoped teardown opens the disposal transaction synchronously and issues
// its own whole-Activation cancel before this call returns.
const drained = ctx.subagents.drainContinuableDescendants([parent])
expect(cancelSpy).toHaveBeenCalledTimes(1)
// Interrupt after the cutoff: accepted no-op, no second signal, no waiting.
ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id })
expect(cancelSpy).toHaveBeenCalledTimes(1)
hold.resolve(undefined)
await drained
})
})

View File

@@ -972,3 +972,243 @@ describe('SubagentService.listChildren', () => {
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
})
})
describe('SubagentService.listDescendants', () => {
it('flattens the complete tree in stable pre-order with verified parent and depth', async () => {
const { ctx, parent } = await setup([])
const childA = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('branch a')))
const grandchild = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa2', {
parentSession: childA,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under a')))
const childB = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaa3', {
parentSession: parent.id,
createdAt: 3,
origin: 'subagent',
}, childEvents(descriptorPayload('branch b')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{
kind: 'child', id: childA, label: 'branch a', mode: 'continuable',
activity: 'inactive', hasChildren: true, parentId: parent.id, depth: 1,
},
{
kind: 'child', id: grandchild, label: 'under a', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: childA, depth: 2,
},
{
kind: 'child', id: childB, label: 'branch b', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: parent.id, depth: 1,
},
])
})
it('returns an empty result when the root has no descendants', async () => {
const { ctx, parent } = await setup([])
await ctx.sessions.flush(parent.session)
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([])
})
it('omits a live creation-window candidate while continuing through its subtree', async () => {
const { ctx, parent } = await setup([])
const bareId = SessionId('live-creation-window')
const bare = ctx.sessions.create(bareId, {
meta: { createdAt: 1, parentSession: parent.id, origin: 'subagent' },
})
bare.append('turn/start', { turn: 1 })
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000aaaf', {
parentSession: bareId,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the creation window')))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: below, label: 'below the creation window', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: bareId, depth: 2,
}])
})
it('contains a corrupt parent cycle without revisiting the requested root', async () => {
const { ctx } = await setup([])
const rootId = SessionId('cycle-root')
const nodeId = SessionId('cycle-node')
await authorChild(ctx, rootId, {
parentSession: nodeId,
createdAt: 2,
}, childEvents(descriptorPayload('ordinary cycle root')))
await authorChild(ctx, nodeId, {
parentSession: rootId,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('cycle child')))
await expect(ctx.subagents.listDescendants(rootId)).resolves.toEqual([{
kind: 'child', id: nodeId, label: 'cycle child', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: rootId, depth: 1,
}])
})
it('walks a deeply nested ordinary-session chain without consuming the call stack', async () => {
const { ctx, parent } = await setup([])
const depth = 10_000
let parentId = parent.id
for (let level = 1; level < depth; level += 1) {
const session = ctx.sessions.create(SessionId(`deep-ordinary-${level}`), {
meta: { createdAt: level, parentSession: parentId },
})
parentId = session.id
}
const leafId = SessionId('deep-subagent-leaf')
const leaf = ctx.sessions.create(leafId, {
meta: { createdAt: depth, parentSession: parentId, origin: 'subagent' },
})
leaf.append('turn/start', { turn: 1 })
leaf.append('subagent/descriptor', descriptorPayload('deep leaf'))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([{
kind: 'child', id: leafId, label: 'deep leaf', mode: 'continuable',
activity: 'running', hasChildren: false, parentId, depth,
}])
})
it('discovers continuable descendants below ordinary and one-shot intermediates', async () => {
const { ctx, parent } = await setup([textResponse('one shot')])
// An ordinary fork has no descriptor: omitted itself, subtree still walked.
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
await ctx.sessions.flush(fork)
const underFork = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb1', {
parentSession: fork.header.id,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('under the fork')))
// A real one-shot child, then a continuable authored below it.
const oneShot = await ctx.subagents.start('spawn', {
label: 'one-shot intermediate',
prompt: [{ type: 'text', text: 'one-shot task' }],
parent,
signal: testSignal,
})
await oneShot.result
await ctx.sessions.flush(oneShot.localAgent!.session)
const oneShotId = oneShot.id
await oneShot.dispose()
const underOneShot = await authorChild(ctx, '00000000-0000-4000-8000-00000000bbb2', {
parentSession: oneShotId,
createdAt: 9_999_999_999_999,
origin: 'subagent',
}, childEvents(descriptorPayload('under the one-shot')))
const entries = await ctx.subagents.listDescendants(parent.id)
// The fork is absent (descriptor-less); the one-shot is present with its
// mode so a caller can see the lineage it walked through.
expect(entries.map(entry => entry.id)).not.toContain(fork.header.id)
expect(entries).toContainEqual({
kind: 'child', id: underFork, label: 'under the fork', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: fork.header.id, depth: 2,
})
expect(entries).toContainEqual(expect.objectContaining({
kind: 'child', id: oneShotId, mode: 'one-shot', parentId: parent.id, depth: 1,
}))
expect(entries).toContainEqual({
kind: 'child', id: underOneShot, label: 'under the one-shot', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: oneShotId, depth: 2,
})
// Pre-order: every child appears after its own parent entry.
const position = new Map(entries.map((entry, index) => [entry.id, index]))
expect(position.get(underOneShot)!).toBeGreaterThan(position.get(oneShotId)!)
})
it('diagnoses a settled descriptor-less node while walking its subtree', async () => {
const { ctx, parent } = await setup([])
// A settled origin-marked candidate without an identity is corrupt under
// the projection contract, but its subtree remains independently visible.
const bare = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000eee2', {
parentSession: bare,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the bare node')))
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: bare, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the bare node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: bare, depth: 2,
},
])
})
it('keeps traversing below a corrupt intermediate and positions its diagnostic', async () => {
const { ctx, parent } = await setup([])
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('unsupported descriptor', 999)))
const below = await authorChild(ctx, '00000000-0000-4000-8000-00000000ccc2', {
parentSession: corrupt,
createdAt: 2,
origin: 'subagent',
}, childEvents(descriptorPayload('below the corrupt node')))
const entries = await ctx.subagents.listDescendants(parent.id)
expect(entries).toEqual([
{ kind: 'diagnostic', id: corrupt, reason: 'corrupt', parentId: parent.id, depth: 1 },
{
kind: 'child', id: below, label: 'below the corrupt node', mode: 'continuable',
activity: 'inactive', hasChildren: false, parentId: corrupt, depth: 2,
},
])
})
it('verifies a cold candidate still belongs to its enumerated lifecycle', async () => {
const { ctx, parent } = await setup([])
const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000ddd1', {
parentSession: parent.id,
createdAt: 1,
origin: 'subagent',
}, childEvents(descriptorPayload('lineage checked')))
const realInspect = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
const inspected = await realInspect(sessionId, signal)
// The exact read reports a different durable parent than enumeration did.
return { ...inspected, meta: { ...inspected.meta, parentSession: SessionId('someone-else') } }
}
await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
{ kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 },
])
})
it('a pre-aborted signal stops the descendant scan before persistence reads', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never read')
const list = vi.spyOn(ctx.sessionPersistence, 'list')
const controller = new AbortController()
controller.abort()
await expect(ctx.subagents.listDescendants(parent.id, controller.signal)).rejects.toThrow(
expect.objectContaining({ code: 'CANCELLED' }) as Error,
)
expect(list).not.toHaveBeenCalled()
})
it('fails loud when the projection registry is not mounted', async () => {
const { ctx, parent } = await setup([], { sessionProjections: false })
await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow(
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
)
})
})

View File

@@ -133,6 +133,16 @@ describe('SubagentService', () => {
await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined()
})
it('treats interrupt as an accepted no-op when no manager was bound', async () => {
const { subagents } = await service()
// Without a continuation manager no live Activation can exist, so there is
// nothing to stop and nothing to authorize against.
expect(() => { subagents.interrupt(SessionId('child'), {
kind: 'user',
parentSessionId: SessionId('parent-1'),
}) }).not.toThrow()
})
it('rejects continuable operations when their runtime services are absent', async () => {
const { subagents } = await service()
await expect(subagents.startContinuable({

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-control/README.md
README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1
README.md: 4d9991b720bbbff8862e693b6aaed332f414af0e
README.zh.md: dc1c81fc3313a8dfa277f33ae745702ed5b9d34b

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
The optional, globally named `send_message`, `interrupt_agent`, and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and `interrupt_agent` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents` and declares `subagents` plus `agents` as load-time dependencies. Its catalog reads additionally require the session store and projection registry at call time, but no query service. A deployment can keep the root tools while omitting the list tool. No tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered.
`list_agents` takes no arguments, derives the parent id from the calling agent, and projects `ctx.subagents.listChildren()` to continuable children without a cursor. The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain `send_message`'s.
`interrupt_agent(agent_id)` passes `exec.agent` as the exact live ancestor authority for `ctx.subagents.interrupt()`: the target may be a direct child or a deeper descendant, and the service — never this tool — verifies the caller against the target Activation's recorded lineage. Only the target's current turn stops (`keepInbox`): queued messages stay parked until a later `send_message`, published descendants keep running, and the child stays available for follow-ups. The call returns as soon as the stop request is accepted, without waiting for target quiescence; an absent or already-settled target is an accepted no-op, while self, sibling, stale, and non-ancestor callers become errored results.
`list_agents` takes one optional `scope` argument, derives the root id from the calling agent, and projects the service catalog to continuable children without a cursor. The default `children` scope reads `ctx.subagents.listChildren()`; `descendants` reads `ctx.subagents.listDescendants()`, whose one-corpus walk crosses ordinary sessions and one-shot children and renders surviving rows in stable pre-order with `parent=<id> depth=<n>`. The `parent` annotation is the durable direct-parent session id and may name an ordinary session omitted from the output. For the calling agent, only depth-1 child entries are `send_message` candidates; deeper child entries are `interrupt_agent` candidates only. Status comes from the live Agent registry: `running` (active driver), `idle` (resident between turns, possibly waiting on agents it started), `complete` (storage only). The service result also contains one-shot session-backed subagents for consumers such as a UI, but those entries are omitted from this model tool because they cannot accept `send_message`. Diagnostics remain visible, with positions in the descendants scope. Durable identity and mode come from each child's descriptor, while delivery-time authority and Activation ownership checks remain the service's.
## Model Experience
@@ -14,7 +16,7 @@ The tool performs no lifecycle routing — residency and cold resume belong to t
#### What the model sees
The generated [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered.
The generated [schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control): `send_message` takes `subagent_id` and `message`, describing that the message becomes the subagent's next turn, that this call returns no answer from the subagent, and that a failure means the message was not delivered; `interrupt_agent` takes `agent_id`, describing that only the current turn stops, queued messages park, descendants keep running, and acceptance precedes the actual stop; `list_agents` takes the optional `scope` enum.
#### Token effect
@@ -24,6 +26,20 @@ Fixed schema cost per parent request.
Prefix-stable; the schema does not change at runtime.
### Interrupt result
#### What the model sees
`interrupt requested for agent <agent_id>` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; an absent or settled target still renders the acceptance line.
#### Token effect
One short acknowledgement per call; the interrupted turn's abort is visible only in the child's own transcript.
#### KV Cache effect
Append-only; each result follows the reusable request prefix.
### Delivery result
#### What the model sees
@@ -42,11 +58,11 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
One line per continuable child in the trace's stable order: `<id> [<status>] — <label>` (`running` = the logical session is live, `complete` = persisted only and resumable by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
One line per continuable child in stable catalog order: `<id> [<status>] — <label>` (`running` = active driver, `idle` = resident between turns, `complete` = storage only; a direct child in that state can be resumed by `send_message`), plus `<id> [diagnostic: <reason>]` for a candidate that could not be read (`corrupt`, `unsupported`, or `unavailable`). The `descendants` scope inserts ` parent=<id> depth=<n>` before the label dash on every line, in pre-order. One-shot children are intentionally absent; `(no subagents)` means no continuable child or diagnostic survived the projection. Diagnostics never expose descriptor contents.
#### Token effect
Grows linearly with the parent's direct continuable children; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
Grows linearly with the listed continuable children — the whole tree under the `descendants` scope; there is no cursor or cap, so long-lived parents with many persisted children pay the full list each call.
#### KV Cache effect
@@ -56,5 +72,5 @@ Append-only; each result follows the reusable request prefix.
- **A queued message has no independent result** — acceptance returns only its inbox `messageId`; the child's work lands in the durable child Session and is never collected through this tool. A child granted `report` may send selected content back separately, but that message is not this call's result.
- **No steering of the current turn** — every message opens a later FIFO turn, so a message sent while the child is working runs only after its current turn finishes and cannot redirect it.
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease.
- **Listing is a snapshot, not a delivery promise** — it may race publication, disposal, or a later message, and another process may activate a child this process reports as `complete`; cross-process accuracy requires a shared lease. `interrupt_agent` performs the authoritative live-lineage check itself, so discovery staleness cannot grant authority.
- **No pagination or deletion** — the complete stably ordered set is returned, and persisted children remain listed for as long as their sessions remain in persistence; a service-level bound or delete operation is a later product decision.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
可选的全局具名 `send_message``list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message`省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
可选的全局具名 `send_message``interrupt_agent``list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message``interrupt_agent`,且只要求 `subagents`可单独加载的 `./list-agents` 插件注册 `list_agents`,并将 `subagents``agents` 声明为加载时依赖。其目录读取在调用时还要求会话存储与投影注册表,但不要求任何查询服务。部署可保留根插件工具并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent智能体的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript文本记录才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
`list_agents` 不接受参数,会从调用它的 agent 推导 parent id并且不使用 cursor`ctx.subagents.listChildren()` 的结果投影为可继续 child。服务结果还包含由会话支撑的一次性 subagent以供 UI 等消费方使用;但这些条目无法接受 `send_message`因此会从这个模型工具中排除。diagnostic 仍然可见。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归 `send_message` 负责
`interrupt_agent(agent_id)``exec.agent` 作为 `ctx.subagents.interrupt()` 的确切在线 ancestor 授权传入:目标可以是直接 child 或更深的后代,由服务——而不是本工具——依据目标 Activation 记录的 lineage 校验调用方。只有目标的当前轮次会停止(`keepInbox`):已排队的消息保持暂停直到之后的 `send_message`已发布的后代继续运行child 也仍可接受后续消息。调用在停止请求被接受后立即返回,不等待目标静止;目标不存在或已结算是被接受的 no-op而 self、sibling、过期与非 ancestor 调用方会成为出错结果
`list_agents` 接受一个可选的 `scope` 参数,会从调用它的 agent 推导根 id并且不使用 cursor将服务目录投影为可继续 child。默认的 `children` scope 读取 `ctx.subagents.listChildren()``descendants` 读取 `ctx.subagents.listDescendants()`,其单份语料的遍历会穿过普通会话与一次性 child并按稳定 pre-order 以 `parent=<id> depth=<n>` 渲染保留下来的条目。`parent` 注释是持久化直接 parent 会话 id可能指向输出中省略的普通会话。对于调用本工具的 agent只有 depth-1 child 条目可作为 `send_message` 候选;更深的 child 条目只能作为 `interrupt_agent` 候选。状态来自在线 Agent 注册表:`running`driver 活跃)、`idle`(驻留但处于轮次之间,可能在等待它启动的 agent`complete`(仅存于存储)。服务结果还包含由会话支撑的一次性 subagent以供 UI 等消费方使用;但这些条目无法接受 `send_message`因此会从这个模型工具中排除。diagnostic 仍然可见,并在 descendants scope 中带有位置。持久化身份和模式来自每个子 agent 的描述符,消息送达时的鉴权和 Activation 所有权检查仍归服务负责。
## 模型体验
@@ -14,7 +16,7 @@
#### 模型看到的内容
已生成的 [`send_message` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control):包含 `subagent_id``message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达。
已生成的 [schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent-control)`send_message` 包含 `subagent_id``message`,说明消息会成为子 agent 的下一个轮次、本次调用不会返回子 agent 的回答,以及失败即表示消息未送达`interrupt_agent` 包含 `agent_id`,说明只有当前轮次会停止、已排队消息保持暂停、后代继续运行,以及接受先于实际停止;`list_agents` 包含可选的 `scope` 枚举
#### Token 影响
@@ -24,6 +26,20 @@
前缀保持稳定schema 不会在运行时改变。
### 中断结果
#### 模型看到的内容
接受时返回 `interrupt requested for agent <agent_id>`。未授权的调用方——self、sibling、过期或非 ancestor——会成为指明拒绝原因的出错结果目标不存在或已结算仍渲染接受行。
#### Token 影响
每次调用产生一条简短确认消息;被中断轮次的中止只在 child 自己的 transcript 中可见。
#### KV Cache 影响
仅追加;每个结果都位于可复用请求前缀之后。
### 投递结果
#### 模型看到的内容
@@ -42,11 +58,11 @@
#### 模型看到的内容
追踪结果的稳定顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>``running` 表示逻辑会话存活,`complete` 表示仅存在于持久化存储中,可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`)。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
按稳定目录顺序,每个可继续 child 占一行:渲染为 `<id> [<status>] — <label>``running` 表示 driver 活跃,`idle` 表示驻留但处于轮次之间,`complete` 表示仅存于存储;处于该状态的直接 child 可通过 `send_message` 恢复),另为无法读取的候选项渲染 `<id> [diagnostic: <reason>]``corrupt``unsupported``unavailable`)。`descendants` scope 会在每行 label 破折号之前插入 ` parent=<id> depth=<n>`,按 pre-order 排列。一次性 child 会被有意排除;`(no subagents)` 表示投影后没有留下可继续 child 或 diagnostic。诊断信息绝不会暴露描述符内容。
#### Token 影响
parent 的直接可继续 child 数量线性增长;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
所列可继续 child 数量线性增长——`descendants` scope 下为整棵树;没有 cursor 或上限,因此长期存活且有许多持久化 child 的 parent 每次调用都会承担完整列表成本。
#### KV Cache 影响
@@ -56,5 +72,5 @@
- **已排队的消息没有独立结果**:接受时只返回其 inbox `messageId`;子 agent 的工作会落入持久化子 agent 会话,绝不会通过本工具收集。获得 `report` 的子 agent 可以单独发回选定内容,但该消息不是本次调用的结果。
- **不对当前轮次进行 steering中途引导**:每条消息都会开启后续 FIFO 轮次,因此在子 agent 工作时发送的消息只会在其当前轮次结束后运行,无法将其重定向。
- **列表是快照,而非投递承诺**它可能与发布、dispose资源释放或后续消息发生竞态另一个进程也可能激活当前进程报告为 `complete` 的 child跨进程准确性需要共享租约。
- **列表是快照,而非投递承诺**它可能与发布、dispose资源释放或后续消息发生竞态另一个进程也可能激活当前进程报告为 `complete` 的 child跨进程准确性需要共享租约。`interrupt_agent` 自己执行权威的在线 lineage 检查,因此过期的发现结果不会授予权限。
- **没有分页或删除**:系统返回完整且稳定排序的集合;只要 child 会话仍在持久化存储中,它就会继续出现在列表中,服务级上限或删除操作留待后续产品决策。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-subagent-control",
"description": "Globally named send_message and list_agents tools over ctx.subagents continuations",
"description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,9 +1,11 @@
/**
* The globally named `send_message` tool: a thin model-facing adapter over
* `ctx.subagents.followup()`. It performs no lifecycle routing of its own —
* residency and cold resume belong to the subagent service — and it lives apart
* from the provider-bound `@deepseek-ai/dsh-tool-subagent` instances so multiple
* delegation tools share one control tool.
* The globally named `send_message` and `interrupt_agent` tools: thin
* model-facing adapters over `ctx.subagents.followup()` and
* `ctx.subagents.interrupt()`. They perform no lifecycle routing of their own —
* residency, cold resume, and interrupt authorization belong to the subagent
* service — and they live apart from the provider-bound
* `@deepseek-ai/dsh-tool-subagent` instances so multiple delegation tools share
* one control surface.
* @module @deepseek-ai/dsh-tool-subagent-control
*/
@@ -17,7 +19,7 @@ export const name = 'tool-subagent-control'
export const inject = ['tools', 'subagents']
/**
* Register the `send_message` tool.
* Register the `send_message` and `interrupt_agent` tools.
* @param ctx - context carrying the tool registry and subagent service.
*/
export function apply(ctx: Context): void {
@@ -73,4 +75,46 @@ export function apply(ctx: Context): void {
return { messageId }
},
}))
ctx.tools.register(defineTool({
name: 'interrupt_agent',
description:
'Request cancellation of a background agent\'s current turn by its agent id. The target may be your '
+ 'direct child or a deeper agent created under you. Only the current turn stops: messages already '
+ 'queued for the agent stay parked until a later send_message, agents it started keep running, and '
+ 'the agent itself stays available for follow-ups. This call returns as soon as the stop request is '
+ 'accepted, so the target may keep running briefly; interrupting an agent that already finished is '
+ 'an accepted no-op.',
parameters: {
agent_id: {
type: 'string',
required: true,
description: 'The agent id of the running agent to interrupt.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
accepted: { type: 'boolean', required: true },
},
},
render: (args, _value) => [{
type: 'text',
text: `interrupt requested for agent ${args.agent_id}`,
}],
},
execute(args, exec) {
const caller = exec.agent
if (!caller) {
// Ancestor authority requires an exact live calling agent.
throw new Error('interrupt_agent requires a calling agent (exec.agent was undefined)')
}
// The service authorizes the exact live caller against the target's
// recorded lineage; the tool adds no authority of its own.
ctx.subagents.interrupt(SessionId(args.agent_id), { kind: 'ancestor', agent: caller })
return Promise.resolve({ accepted: true })
},
}))
}

View File

@@ -1,46 +1,95 @@
/**
* The globally named `list_agents` tool: a thin model-facing adapter over
* the continuable projection of `ctx.subagents.listChildren()`. It stays
* separately loadable from the root `send_message` plugin so a deployment
* can register `send_message` without exposing the list tool.
* the continuable projection of `ctx.subagents.listChildren()` and, for the
* `descendants` scope, `ctx.subagents.listDescendants()`. It stays separately
* loadable from the root `send_message` plugin so a deployment can register
* continuation delivery without exposing discovery.
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentDescendantListEntry, SubagentListEntry } from '@deepseek-ai/dsh-subagent'
export const name = 'tool-subagent-list-agents'
export const inject = ['tools', 'subagents']
export const inject = ['tools', 'subagents', 'agents']
type ListAgentsEntry =
| {
readonly kind: 'child'
readonly id: string
readonly label: string
readonly status: 'running' | 'complete'
readonly status: 'running' | 'idle' | 'complete'
readonly parent?: string
readonly depth?: number
}
| {
readonly kind: 'diagnostic'
readonly id: string
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
readonly parent?: string
readonly depth?: number
}
/**
* Refine one candidate's status through the live Agent registry: `running`
* for an active driver, `idle` for a resident Agent between turns (possibly
* waiting on agents it started), and `complete` when no live Agent remains.
*/
function statusOf(agents: { get(id: SessionId): Agent | undefined }, id: SessionId): 'running' | 'idle' | 'complete' {
const agent = agents.get(id)
if (agent === undefined) return 'complete'
return agent.status === 'running' ? 'running' : 'idle'
}
/** Project one service row into the model-facing entry, or omit a one-shot child. */
function project(
agents: { get(id: SessionId): Agent | undefined },
entry: SubagentListEntry,
position?: Pick<SubagentDescendantListEntry, 'parentId' | 'depth'>,
): ListAgentsEntry | undefined {
const at = position === undefined ? {} : { parent: position.parentId as string, depth: position.depth }
if (entry.kind === 'diagnostic') {
return { kind: 'diagnostic', id: entry.id, reason: entry.reason, ...at }
}
// One-shot children cannot be continued by send_message, so the model
// never selects them; discovery still traversed them for descendants.
if (entry.mode !== 'continuable') return undefined
return {
kind: 'child',
id: entry.id,
label: entry.label,
status: statusOf(agents, entry.id),
...at,
}
}
/**
* Register the `list_agents` tool.
* @param ctx - context carrying the tool registry and subagent service.
* @param ctx - context carrying the tool registry, subagent service, and live Agent registry.
*/
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'list_agents',
description:
'List your continuable background subagents by durable id and label. Status is a snapshot of the stored '
+ 'record: running means the subagent session is currently live in this process, complete means '
+ 'it exists only in storage and a `send_message` starts a new turn on the same conversation. '
+ 'The snapshot is not a delivery promise — `send_message` performs the authoritative check and '
+ 'may still fail. Children that could not be read are reported as diagnostics instead of being '
+ 'silently dropped.',
parameters: {},
'List your continuable background subagents by durable id and label. Status comes from the live '
+ 'registry: running means the agent is working right now, idle means it is loaded but between turns '
+ '(it may be waiting on agents it started), and complete means it exists only in storage — a '
+ 'direct child remains a `send_message` candidate in every status. The snapshot is not a delivery '
+ 'promise — `send_message` performs the authoritative check and may still fail. Children that could '
+ 'not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` '
+ 'walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent '
+ 'session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are '
+ 'candidates for `interrupt_agent` only.',
parameters: {
scope: {
type: 'string',
enum: ['children', 'descendants'],
description: 'children (default) lists direct children only; descendants walks the complete tree below you.',
},
},
output: {
schema: {
type: 'array',
@@ -53,7 +102,9 @@ export function apply(ctx: Context): void {
kind: { type: 'string', required: true, enum: ['child'] },
id: { type: 'string', required: true },
label: { type: 'string', required: true },
status: { type: 'string', required: true, enum: ['running', 'complete'] },
status: { type: 'string', required: true, enum: ['running', 'idle', 'complete'] },
parent: { type: 'string' },
depth: { type: 'number' },
},
},
{
@@ -63,21 +114,31 @@ export function apply(ctx: Context): void {
kind: { type: 'string', required: true, enum: ['diagnostic'] },
id: { type: 'string', required: true },
reason: { type: 'string', required: true, enum: ['corrupt', 'unsupported', 'unavailable'] },
parent: { type: 'string' },
depth: { type: 'number' },
},
},
],
},
},
render: (_args, entries) => [{
render: (args, entries) => [{
type: 'text',
text: entries.length === 0
? '(no subagents)'
: entries.map(entry => entry.kind === 'child'
? `${entry.id} [${entry.status}] — ${entry.label}`
: `${entry.id} [diagnostic: ${entry.reason}]`).join('\n'),
: entries.map((entry) => {
// A descendants row always carries its position; children rows
// never render it. String() spans the schema-optional shape
// without a dead fallback branch.
const at = args.scope === 'descendants'
? ` parent=${String(entry.parent)} depth=${String(entry.depth)}`
: ''
return entry.kind === 'child'
? `${entry.id} [${entry.status}]${at}${entry.label}`
: `${entry.id} [diagnostic: ${entry.reason}]${at}`
}).join('\n'),
}],
},
async execute(_args, exec) {
async execute(args, exec) {
const parent = exec.agent
if (!parent) {
// Non-agent callers have no session whose children could be listed.
@@ -85,21 +146,16 @@ export function apply(ctx: Context): void {
}
// The registry drains started tool bodies, so the scan must observe the
// call's signal rather than finish a slow catalog after cancellation.
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
const visible: ListAgentsEntry[] = []
for (const entry of entries) {
if (entry.kind === 'diagnostic') {
visible.push(entry)
} else if (entry.mode === 'continuable') {
visible.push({
kind: 'child',
id: entry.id,
label: entry.label,
status: entry.activity === 'running' ? 'running' : 'complete',
})
}
if (args.scope === 'descendants') {
const entries = await ctx.subagents.listDescendants(parent.id, exec.signal)
return entries
.map(entry => project(ctx.agents, entry, entry))
.filter(entry => entry !== undefined)
}
return visible
const entries = await ctx.subagents.listChildren(parent.id, exec.signal)
return entries
.map(entry => project(ctx.agents, entry))
.filter(entry => entry !== undefined)
},
}))
}

View File

@@ -12,9 +12,37 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/list-agents.ts'
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<undefined>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const testToolSignal = new AbortController().signal
const roots: string[] = []
@@ -22,7 +50,7 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
async function setupWith(adapter: MockAdapter | GatedAdapter) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
@@ -33,9 +61,13 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(tool)
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
return { ctx, parent, adapter }
}
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
return setupWith(new MockAdapter(script))
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -67,13 +99,19 @@ async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void>
}
describe('dsh-tool-subagent-control/list-agents', () => {
it('registers list_agents once, globally, with no parameters', async () => {
it('registers list_agents once, globally, with only the optional scope parameter', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props)).toEqual([])
const parameters = schemas[0]!.parameters as {
properties?: Record<string, { enum?: string[] }>
required?: string[]
}
expect(Object.keys(parameters.properties ?? {})).toEqual(['scope'])
expect(parameters.properties?.scope?.enum).toEqual(['children', 'descendants'])
expect(parameters.required ?? []).toEqual([])
expect(schemas[0]!.description).toContain('send_message')
expect(schemas[0]!.description).toContain('interrupt_agent')
})
it('renders the empty result as (no subagents)', async () => {
@@ -84,7 +122,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
expect(text(result)).toBe('(no subagents)')
})
it('renders children and diagnostics in array order with the fixed text forms', async () => {
it('renders children and diagnostics in array order with registry-derived statuses', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
@@ -94,7 +132,8 @@ describe('dsh-tool-subagent-control/list-agents', () => {
})
await waitNoActivation(ctx, started.childId)
// Pin the render deterministically past the service: the tool is a thin
// adapter, so its fixed text forms are what this test pins.
// adapter, so its fixed text forms are what this test pins. Status comes
// from the live Agent registry, stubbed per candidate id.
const entries: SubagentListEntry[] = [
{
kind: 'child',
@@ -120,14 +159,28 @@ describe('dsh-tool-subagent-control/list-agents', () => {
activity: 'running',
hasChildren: true,
},
{
kind: 'child',
id: SessionId('waiting-child'),
label: 'waiting on descendants',
mode: 'continuable',
activity: 'running',
hasChildren: true,
},
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
]
ctx.subagents.listChildren = () => Promise.resolve(entries)
const agents = new Map<string, { status: 'running' | 'idle' }>([
['running-child', { status: 'running' }],
['waiting-child', { status: 'idle' }],
])
vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
const result = await callTool(ctx, 'list_agents', {}, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [complete] — real child\n`
+ 'running-child [running] — still working\n'
+ 'waiting-child [idle] — waiting on descendants\n'
+ 'broken-child [diagnostic: corrupt]',
)
})
@@ -186,7 +239,101 @@ describe('dsh-tool-subagent-control/list-agents', () => {
it('has the namespace-plugin export shape', () => {
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-subagent-list-agents')
expect(tool.inject).toEqual(['tools', 'subagents'])
expect(tool.inject).toEqual(['tools', 'subagents', 'agents'])
expect(typeof tool.apply).toBe('function')
})
it('walks the complete descendant tree in pre-order with parent and depth annotations', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'waiting branch',
request: { prompt: [{ type: 'text', text: 'branch work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'nested leaf',
request: { prompt: [{ type: 'text', text: 'leaf work' }], parent: child },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
// The branch finishes its own turn but stays resident waiting on the
// grandchild it owns: the live-registry `idle` status.
releaseChild.resolve(undefined)
await vi.waitFor(() => {
expect(ctx.agents.get(started.childId)?.status).toBe('idle')
}, { timeout: 5_000 })
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
`${started.childId} [idle] parent=${parent.id} depth=1 — waiting branch\n`
+ `${grandchild.childId} [running] parent=${started.childId} depth=2 — nested leaf`,
)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('omits one-shot intermediates from descendants output while surfacing what they own', async () => {
const { ctx, parent } = await setup([])
// Deterministic service rows: a one-shot intermediate owning a continuable
// leaf, plus a positioned diagnostic. The tool filters only the one-shot.
ctx.subagents.listDescendants = () => Promise.resolve([
{
kind: 'child',
id: SessionId('one-shot-mid'),
label: 'one-shot intermediate',
mode: 'one-shot',
activity: 'inactive',
hasChildren: true,
parentId: parent.id,
depth: 1,
},
{
kind: 'child',
id: SessionId('deep-leaf'),
label: 'deep leaf',
mode: 'continuable',
activity: 'inactive',
hasChildren: false,
parentId: SessionId('one-shot-mid'),
depth: 2,
},
{
kind: 'diagnostic',
id: SessionId('broken-node'),
reason: 'unavailable',
parentId: parent.id,
depth: 1,
},
])
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(
'deep-leaf [complete] parent=one-shot-mid depth=2 — deep leaf\n'
+ `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
)
})
it('forwards the tool cancellation signal to descendant enumeration', async () => {
const { ctx, parent } = await setup([])
const signal = new AbortController().signal
const listDescendants = vi.spyOn(ctx.subagents, 'listDescendants').mockResolvedValue([])
const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent, signal)
expect(result.isError).toBe(false)
expect(listDescendants).toHaveBeenCalledWith(parent.id, signal)
})
})

View File

@@ -11,9 +11,37 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as tool from '../src/index.ts'
/** One scripted response that may wait on a caller-released gate before streaming. */
interface GatedEntry {
chunks: StreamChunk[]
gate?: Promise<undefined>
}
/** Adapter whose entries can hold a model call open until the test releases it. */
class GatedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private script: GatedEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('GatedAdapter: script exhausted')
if (entry.gate) await entry.gate
for (const chunk of entry.chunks) {
if (options.signal?.aborted) throw new Error('aborted')
yield chunk
}
}
}
const testToolSignal = new AbortController().signal
const roots: string[] = []
@@ -21,7 +49,7 @@ afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
async function setupWith(adapter: MockAdapter | GatedAdapter) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
@@ -32,12 +60,15 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(tool)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent, adapter }
}
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
return setupWith(new MockAdapter(script))
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
@@ -177,8 +208,10 @@ describe('dsh-tool-subagent-control', () => {
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
await fiber.dispose()
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
})
it('has the namespace-plugin export shape (no stray default)', () => {
@@ -188,3 +221,168 @@ describe('dsh-tool-subagent-control', () => {
expect(typeof tool.apply).toBe('function')
})
})
describe('dsh-tool-subagent-control interrupt_agent', () => {
it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
const { ctx } = await setup([])
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
expect(schemas).toHaveLength(1)
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
expect(Object.keys(props)).toEqual(['agent_id'])
expect(schemas[0]!.description).toContain('current turn')
expect(schemas[0]!.description).toContain('send_message')
})
it('interrupts a running direct child with the parent cause, parking its queue', async () => {
const releaseFirst = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('held'), gate: releaseFirst.promise },
{ chunks: textResponse('parked answer') },
{ chunks: textResponse('waking answer') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'long work',
request: { prompt: [{ type: 'text', text: 'long work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const queued = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'parked follow-up',
}, parent)
expect(queued.isError).toBe(false)
const cancelSpy = vi.spyOn(child, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(result.isError).toBe(false)
expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseFirst.resolve(undefined)
await child.whenIdle()
// Parked, not resumed: the queued follow-up waits for a waking send.
expect(adapter.requests).toHaveLength(1)
expect(child.inbox.nextTurn).toHaveLength(1)
const waking = await callTool(ctx, 'send_message', {
subagent_id: started.childId,
message: 'wake up',
}, parent)
expect(waking.isError).toBe(false)
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
const prompts = loaded.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])
})
it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
const releaseChild = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('child'), gate: releaseChild.promise },
{ chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)!
const grandchild = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'grandchild',
request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const grandchildAgent = ctx.agents.get(grandchild.childId)!
const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
expect(result.isError).toBe(false)
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
releaseChild.resolve(undefined)
releaseGrandchild.resolve(undefined)
await waitNoActivation(ctx, grandchild.childId)
await waitNoActivation(ctx, started.childId)
})
it('rejects self, sibling, and unrelated callers without touching the target', async () => {
const releaseA = Promise.withResolvers<undefined>()
const releaseB = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('a'), gate: releaseA.promise },
{ chunks: textResponse('b'), gate: releaseB.promise },
])
const { ctx, parent } = await setupWith(adapter)
const target = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'target',
request: { prompt: [{ type: 'text', text: 'a' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const sibling = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'sibling',
request: { prompt: [{ type: 'text', text: 'b' }], parent },
signal: testToolSignal,
})
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
const targetAgent = ctx.agents.get(target.childId)!
const siblingAgent = ctx.agents.get(sibling.childId)!
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
const cancelSpy = vi.spyOn(targetAgent, 'cancel')
const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
expect(self.isError).toBe(true)
expect(text(self)).toContain('cannot interrupt itself')
const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
expect(fromSibling.isError).toBe(true)
expect(text(fromSibling)).toContain('not a live descendant')
const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
expect(fromStranger.isError).toBe(true)
expect(text(fromStranger)).toContain('not a live descendant')
expect(cancelSpy).not.toHaveBeenCalled()
releaseA.resolve(undefined)
releaseB.resolve(undefined)
await waitNoActivation(ctx, target.childId)
await waitNoActivation(ctx, sibling.childId)
})
it('accepts an absent target as a no-op without cold-resuming it', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const started = await ctx.subagents.startContinuable({
provider: 'spawn',
label: 'settled child',
request: { prompt: [{ type: 'text', text: 'child work' }], parent },
signal: testToolSignal,
})
await waitNoActivation(ctx, started.childId)
const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
expect(settled.isError).toBe(false)
expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
expect(unknown.isError).toBe(false)
// No cold resume: the settled target never rematerialized.
expect(ctx.agents.get(started.childId)).toBeUndefined()
})
it('fails loud when invoked without a calling agent', async () => {
const { ctx } = await setup([])
const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('requires a calling agent')
})
})