Remove unused public subagent service methods

This commit is contained in:
Dudu-0223
2026-10-23 12:46:04 +08:00
parent 0b1899534b
commit 86d4c75516
40 changed files with 227 additions and 338 deletions
@@ -88,7 +88,7 @@ async function exercise(ctx, config) {
// A transitive repository package the registry served, absent from npm at this version.
const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version
const provider = name.endsWith('-codex') ? 'codex' : 'claude-code'
const providerEnabled = ctx.subagents.list().includes(provider)
const providerEnabled = ctx.subagents.getProvider(provider) !== undefined
const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name])
const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}`
const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName)
+1 -1
View File
@@ -712,7 +712,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac
const productCtx = await bootSelected(installed)
const spawn = vi.spyOn(productCtx.subprocess, 'spawn')
try {
expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort())
expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort())
.toEqual([...installed].sort())
const expected = installed.map(toolName).sort()
for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) {
+7 -4
View File
@@ -76,10 +76,13 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
expect(resolved.agent.session.snapshotEvents()
.filter(event => event.type === 'agent-preset/selected')
.map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : [])
expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([
{ id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' },
{ id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' },
])
{
using observed = await scaffold.ctx.sessionQuery.observeSession(id)
expect(observed.projections?.values.subagentCatalog).toEqual([
{ id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' },
{ id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' },
])
}
const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd')
await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' })
+11 -13
View File
@@ -307,25 +307,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
expect(await scaffold.ctx.subagents.listChildren(parent.id)).toMatchObject([
{
id: childId, mode: 'continuable', label: LABEL,
},
{
id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL,
},
])
{
using observed = await scaffold.ctx.sessionQuery.observeSession(parent.id)
expect(observed.projections?.values.subagentCatalog).toMatchObject([
{
id: childId, mode: 'continuable', label: LABEL,
},
{
id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL,
},
])
}
{
using observed = await scaffold.ctx.sessionQuery.observeSession(childId)
expect(observed.source).toBe('prepared')
expect(observed.projections?.values.subagentCatalog).toEqual([
{ id: grandchildId, createdAt: authoredAt, mode: 'continuable', label: NESTED_LABEL },
])
expect(await scaffold.ctx.subagents.listChildren(childId)).toMatchObject([
{
id: grandchildId, mode: 'continuable', label: NESTED_LABEL,
},
])
}
await waitForCacheRow(scaffold, childHeader)
const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name)
@@ -81,7 +81,12 @@ async function run(root: string, mode: string): Promise<CatalogReport | { seeded
await seed(ctx)
return { seeded: true }
}
const readCatalog = () => ctx.subagents.listChildren(PARENT_ID)
const readCatalog = async () => {
using observation = await ctx.sessionQuery.observeSession(PARENT_ID)
const catalog = observation.projections?.values.subagentCatalog
if (catalog === undefined) throw new Error('child-catalog projection is unavailable')
return catalog
}
const cpuStart = process.cpuUsage()
const start = performance.now()
const first = await readCatalog()
+1 -1
View File
@@ -3413,7 +3413,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
- `inject`: `workingDirectory` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/subagent/subagent/src/index.ts:187`](../packages/subagent/subagent/src/index.ts)
- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts)
```ts config-catalog
/** Host configuration for continuable subagent capacity. */
+1 -1
View File
@@ -3415,7 +3415,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
- `inject`: `workingDirectory` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/subagent/subagent/src/index.ts:187`](../packages/subagent/subagent/src/index.ts)
- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts)
```ts config-catalog
/** Host configuration for continuable subagent capacity. */
+4 -4
View File
@@ -70,10 +70,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:167`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+4 -4
View File
@@ -72,10 +72,10 @@
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:167`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+7 -7
View File
@@ -12,17 +12,17 @@
en: 6f87231d18d82916
zh: 9f064b23058f8766
/subagents/messages-and-interruption:
en: 6ff748d66293c367
zh: b9f8a7183a27e7a1
en: 9befacb931520842
zh: c214b4fffa779b06
/subagents/durable-enumeration:
en: 30c3607a969696ce
zh: e6359cdf22b1e235
en: e02e1703b183594c
zh: 130d475a4304f453
/subagents/results-and-backend-handles:
en: 69ea5bed7d9fb64a
zh: b187a822b813e80a
/subagents/the-provider-contract-subagentprovider:
en: 226ba8094a8cf96c
zh: 719678662905d1eb
/subagents/durable-enumeration-listchildren-listdescendants-and-their-entries:
en: d8ce7969b65ad86e
zh: d39dac6592681bf2
/subagents/durable-enumeration-listdescendants-and-catalog-entries:
en: 8000138f84fd15fa
zh: 32fa91d3aebd3d01
+4 -33
View File
@@ -170,7 +170,7 @@ interface ContinuableCreateSpec {
## Messages and interruption
`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` and `drainChildren` await owned teardown.
`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` awaits whole-tree teardown; an activation receipt’s `dispose()` releases only that execution.
```ts type-equiv
/** Durable attribution for one model-authored message between Agents. */
@@ -223,7 +223,7 @@ type SubagentInterruptAuthority =
## Durable enumeration
`listChildren` reads the parent-owned catalog of all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes direct continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents).
The parent-owned `subagentCatalog` projection lists all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes the root and reachable continuable descendants with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents).
## Results and backend handles
@@ -369,11 +369,11 @@ interface SubagentProvider {
}
```
## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries
## Durable enumeration: `listDescendants()` and catalog entries
The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed.
`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
Direct-child entries are available as `observation.projections?.values.subagentCatalog` through `ctx.sessionQuery.observeSession(parentSessionId, { signal })`; callers release the observation with `using`. Entries follow parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection means discovery is unavailable. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
`SubagentRuntime.listDescendants(rootSessionId)` recursively calls the same catalog reader in stable pre-order, preserving each parent's event order. External entries are leaves without local Sessions. One-shot and unknown-mode entries remain traversal nodes; unknown modes produce `unsupported` diagnostics. An unreadable child catalog produces `corrupt` or `unavailable` and stops only that branch. Root read failures, missing services or projections, and cancellation reject the listing. Each reachable catalog is observed once and released before the next read; repeated ids and cycles are skipped. Sessions absent from reachable catalogs are not discovered, including ordinary Session forks and any subagents below those forks. Each row carries its catalog parent and root-relative depth:
@@ -491,29 +491,6 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo
*/
async drainDescendants(parents: readonly Agent[]): Promise<void>
/**
* Release selected resident direct children of one exact live
* parent. Every unselected member, including their descendants, stays resident.
* Absent targets and a manager-less composition are accepted no-ops.
* @param parent - exact live direct parent authorizing the selected release.
* @param childIds - durable direct-child ids to release when resident.
* @returns once every selected activation released its execution handle.
* @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a
* different parent or the supplied parent identity is stale.
*/
async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
@@ -580,12 +557,6 @@ registerProvider(provider: SubagentProvider): () => void
* @returns the provider, or undefined when absent.
*/
getProvider(name: string): SubagentProvider | undefined
/**
* List registered provider names in insertion order.
* @returns the registered names.
*/
list(): string[]
```
Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
+4 -33
View File
@@ -170,7 +170,7 @@ interface ContinuableCreateSpec {
## 消息与中断
`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 和 `drainChildren` 等待所属资源释放。
`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 等待整棵树释放;activation 回执的 `dispose()` 仅释放那一次执行。
```ts type-equiv
/** Durable attribution for one model-authored message between Agents. */
@@ -223,7 +223,7 @@ type SubagentInterruptAuthority =
## 持久枚举
`listChildren` 读取父级拥有的目录,其中包含所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示可继续的直接子级,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。
父级拥有的 `subagentCatalog` 投影列出所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示根和可达的可继续后代,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。
## 结果与后端句柄
@@ -369,11 +369,11 @@ interface SubagentProvider {
}
```
## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目
## 持久化枚举:`listDescendants()` 与目录条目
模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
通过 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,可从 `observation.projections?.values.subagentCatalog` 获取直接子级条目;调用方使用 `using` 释放观察对象。条目遵循父会话事件顺序,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影意味着发现功能不可用。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
`SubagentRuntime.listDescendants(rootSessionId)` 以稳定前序递归调用同一目录读取函数,保留每个父级的事件顺序。外部条目是没有本地 Session 的叶节点。一次性和未知模式条目仍是遍历节点;未知模式产生 `unsupported` 诊断。无法读取的子级目录产生 `corrupt` 或 `unavailable`,只停止该分支。根读取失败、服务或投影缺失、取消会使整个查询失败。每个可达目录只观察一次,并在下一次读取前释放;重复 id 和循环引用会被跳过。可达目录中不存在的 Session 不会被发现,包括普通 Session fork 及其下的子 agent。每行携带目录中的父级和相对根的深度:
@@ -493,29 +493,6 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo
*/
async drainDescendants(parents: readonly Agent[]): Promise<void>
/**
* Release selected resident direct children of one exact live
* parent. Every unselected member, including their descendants, stays resident.
* Absent targets and a manager-less composition are accepted no-ops.
* @param parent - exact live direct parent authorizing the selected release.
* @param childIds - durable direct-child ids to release when resident.
* @returns once every selected activation released its execution handle.
* @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a
* different parent or the supplied parent identity is stale.
*/
async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
@@ -582,12 +559,6 @@ registerProvider(provider: SubagentProvider): () => void
* @returns the provider, or undefined when absent.
*/
getProvider(name: string): SubagentProvider | undefined
/**
* List registered provider names in insertion order.
* @returns the registered names.
*/
list(): string[]
```
Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [MessageId](llm-streaming.zh.md) · [SessionId](core.zh.md)
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md
/:
en: 6955b11d6f698e4c
zh: 8a8d4d6edd5dea0c
/subagent-service-method-removal:
en: 102f43348f7c8eb0
zh: be09ff117c31ada1
/subagent-service-method-removal/change:
en: 8cd7f93df401c417
zh: 27313127c2059e6b
/subagent-service-method-removal/migration:
en: f0f0f921f284efec
zh: 8b9e88e9412282c2
@@ -0,0 +1,19 @@
---
kind: upgrade-guide
description: "The subagent service removes drainChildren, listChildren, and list."
---
# Subagent service method removal
English | [中文](guide.zh.md)
## Change
`ctx.subagents` removes `drainChildren(parent, childIds)`, `listChildren(parentSessionId, signal?)`, and `list()`. This affects plugins that release resident children by id, read direct-child catalogs through the service, or enumerate registered provider names.
## Migration
1. Retain the activation returned by `startActivation()` and await its `dispose()` to release that exact execution. An old activation cannot release a later execution of the same Session. There is no replacement for releasing the current resident child by id; use `drainDescendants([root])` only when closing the entire tree. `interrupt()` stops current work but preserves a local Activation.
2. For direct-child entries, use `ctx.sessionQuery.observeSession(parentSessionId, { signal })`, read `observation.projections?.values.subagentCatalog`, and dispose the observation with `using`. Load Session Query and the subagent service, and treat a missing catalog projection as unavailable. Keep `listDescendants()` for recursive discovery; its rows differ from direct catalog entries.
3. Use `getProvider(name)` to check known provider names. The service no longer enumerates all registered names.
4. Rebuild plugins against the updated service declarations. Verify registration and unload checks, exact-activation disposal, and catalog observation cleanup.
@@ -0,0 +1,19 @@
---
kind: upgrade-guide
description: "子代理服务移除 drainChildren、listChildren 和 list。"
---
# 子代理服务方法移除
[English](guide.md) | 中文
## 变更
`ctx.subagents` 移除 `drainChildren(parent, childIds)`、`listChildren(parentSessionId, signal?)` 和 `list()`。这会影响按 id 释放驻留子代理、通过服务读取直接子级目录或枚举已注册 provider 名称的插件。
## 迁移
1. 保留 `startActivation()` 返回的 activation,并等待其 `dispose()` 完成以释放那一次执行。旧 activation 不能释放同一 Session 后来恢复的执行。按 id 释放当前驻留子代理没有替代方法;仅在关闭整棵树时使用 `drainDescendants([root])`。`interrupt()` 停止当前工作,但保留本地 Activation。
2. 读取直接子级条目时,使用 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,读取 `observation.projections?.values.subagentCatalog`,并通过 `using` 释放观察对象。加载 Session Query 和子代理服务,并将缺失的目录投影视为不可用。递归发现继续使用 `listDescendants()`;它返回的行与直接目录条目不同。
3. 使用 `getProvider(name)` 检查已知 provider 名称。服务不再枚举全部已注册名称。
4. 根据更新后的服务声明重新构建插件,验证注册和卸载检查、指定 activation 的释放以及目录观察对象的清理。
+3 -6
View File
@@ -551,12 +551,9 @@
- id: tool-result-pruner
disabled: true
# The subagent registry and its backends STAY in the host plane. `subagents` is
# a process singleton with a cross-session query surface (`listChildren`,
# `followup`) that the host api-proxy serves to the browser, and a provider
# registers under a globally unique name, so a per-session copy would both
# starve that host row and collide on the second session. What a preset
# chooses is which delegation TOOLS its agent sees, below.
# The subagent registry and its backends belong in the host composition: browser
# prompt and interrupt requests share its runtime, and provider names are unique
# within the registry. Presets select the delegation tools visible to each Agent.
- id: tool-subagent-control
disabled: true
@@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import SubagentService, { type SubagentActivation } from '@deepseek-ai/dsh-subagent'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -73,7 +73,7 @@ function content(text: string) {
}
/** Start one teammate: a continuable child of the Lead. */
async function spawn(ctx: Context, lead: Agent, description: string | undefined, provider = 'spawn'): Promise<SessionId> {
async function spawn(ctx: Context, lead: Agent, description: string | undefined, provider = 'spawn'): Promise<SubagentActivation> {
const started = await ctx.subagents.startActivation({
provider,
label: `${description ?? 'shared'} responsibility`,
@@ -81,7 +81,7 @@ async function spawn(ctx: Context, lead: Agent, description: string | undefined,
signal: SIGNAL,
delivery: 'parent',
})
return started.childId
return started
}
async function waitNoAgent(ctx: Context, id: SessionId): Promise<void> {
@@ -147,8 +147,9 @@ describe('Tree-owned task boards', () => {
it('makes every root a Lead and every live subagent below it a teammate', async () => {
const { ctx, lead } = await setup(['hang', 'hang', 'hang'])
const named = await waitRunning(ctx, await spawn(ctx, lead, 'alpha'))
const unnamed = await waitRunning(ctx, await spawn(ctx, lead, undefined))
const namedActivation = await spawn(ctx, lead, 'alpha')
const named = await waitRunning(ctx, namedActivation.childId)
const unnamed = await waitRunning(ctx, (await spawn(ctx, lead, undefined)).childId)
const grandchildId = (await ctx.subagents.startActivation({
provider: 'spawn', label: 'nested', delivery: 'caller', signal: SIGNAL,
prompt: content('nested'), parent: named,
@@ -160,7 +161,7 @@ describe('Tree-owned task boards', () => {
expect(ctx.agentTeams.getTask(agent, shared.id).id).toBe(shared.id)
}
await ctx.subagents.drainChildren(lead, [named.id])
await namedActivation.dispose()
await waitNoAgent(ctx, named.id)
const task = await ctx.agentTeams.createTask(grandchild, { subject: 'still active', description: 'creator offline' })
expect(ctx.agentTeams.listTasks(lead).map(row => row.id)).toContain(task.id)
@@ -174,7 +175,7 @@ describe('Tree-owned task boards', () => {
it('treats an ordinary fork as a new Root Team and filters inherited Team state', async () => {
const { ctx, lead } = await setup([textResponse('alpha done')])
const alpha = await spawn(ctx, lead, 'alpha')
const alpha = (await spawn(ctx, lead, 'alpha')).childId
await waitNoAgent(ctx, alpha)
await ctx.agentTeams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' })
const handle = await ctx.agents.create({
@@ -195,8 +196,8 @@ describe('Tree-owned task boards', () => {
it('assigns deep descendants by id and rejects historical roster authority', async () => {
const { ctx, lead } = await setup(['hang', 'hang'])
const child = await waitRunning(ctx, await spawn(ctx, lead, 'parent'))
const grandchild = await waitRunning(ctx, await spawn(ctx, child, 'nested'))
const child = await waitRunning(ctx, (await spawn(ctx, lead, 'parent')).childId)
const grandchild = await waitRunning(ctx, (await spawn(ctx, child, 'nested')).childId)
const task = await ctx.agentTeams.createTask(lead, { subject: 'nested work', description: 'deep assignment' })
const assigned = await ctx.agentTeams.updateTask(lead, {
taskId: task.id, expectedRevision: task.revision, action: 'reassign', owner: grandchild.id,
@@ -280,7 +281,7 @@ describe('Tree-owned task boards', () => {
sessionId: SessionId('temporary-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const child = await spawn(ctx, parent.agent, 'orphan')
const child = (await spawn(ctx, parent.agent, 'orphan')).childId
await waitNoAgent(ctx, child)
await parent.dispose()
const orphan = await ctx.agents.resume({
@@ -348,9 +349,9 @@ describe('Team shared task DAG', () => {
it('enforces CAS, ownership, dependencies, transitions, and write-scope warnings', async () => {
const { ctx, lead } = await setup(['hang', 'hang', textResponse('beta integrated update')])
const firstMember = await spawn(ctx, lead, 'alpha')
const alpha = await waitRunning(ctx, firstMember)
const alpha = await waitRunning(ctx, firstMember.childId)
const secondMember = await spawn(ctx, lead, 'beta')
const beta = await waitRunning(ctx, secondMember)
const beta = await waitRunning(ctx, secondMember.childId)
const first = await ctx.agentTeams.createTask(alpha, {
subject: 'first',
@@ -416,7 +417,7 @@ describe('Team shared task DAG', () => {
expect(released).toMatchObject({ status: 'pending', ready: true })
expect('ownerId' in released).toBe(false)
await ctx.subagents.drainChildren(lead, [alpha.id, beta.id])
await Promise.all([firstMember.dispose(), secondMember.dispose()])
await Promise.all([waitNoAgent(ctx, alpha.id), waitNoAgent(ctx, beta.id)])
})
@@ -495,7 +496,7 @@ describe('Team shared task DAG', () => {
it('supports Lead reassignment, completion, reopen, and deletion permissions', async () => {
const { ctx, lead } = await setup(['hang'])
const started = await spawn(ctx, lead, 'owner')
const owner = await waitRunning(ctx, started)
const owner = await waitRunning(ctx, started.childId)
const task = await ctx.agentTeams.createTask(owner, { subject: 'lifecycle', description: 'lifecycle' })
const assigned = await ctx.agentTeams.updateTask(lead, {
taskId: task.id,
@@ -543,14 +544,14 @@ describe('Team shared task DAG', () => {
action: 'edit',
subject: 'late',
})).rejects.toMatchObject({ code: 'TEAM_TASK_DELETED' })
await ctx.subagents.drainChildren(lead, [owner.id])
await started.dispose()
await waitNoAgent(ctx, owner.id)
})
it('covers partial edits, Lead ownership, unassignment, and blocked reassignment', async () => {
const { ctx, lead } = await setup(['hang'])
const started = await spawn(ctx, lead, 'editor')
const editor = await waitRunning(ctx, started)
const editor = await waitRunning(ctx, started.childId)
const blocker = await ctx.agentTeams.createTask(lead, { subject: 'blocker', description: 'blocker' })
const task = await ctx.agentTeams.createTask(lead, {
subject: 'draft',
@@ -627,7 +628,7 @@ describe('Team shared task DAG', () => {
})
expect(rootAssigned).toMatchObject({ status: 'in_progress', ownerId: lead.id })
await ctx.subagents.drainChildren(lead, [editor.id])
await started.dispose()
await waitNoAgent(ctx, editor.id)
})
})
@@ -72,7 +72,7 @@ describe('OAR provider composition', () => {
expect(ctx.subagents.getProvider('codex-test')).toBeUndefined()
expect(ctx.subagents.getProvider('claude-test')).toBeDefined()
await second.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('claude-test')).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
@@ -12,7 +12,7 @@ import { scopeOf } from '@deepseek-ai/dsh-scope'
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SubagentService, { SubagentError } from '@deepseek-ai/dsh-subagent'
import SubagentService, { SubagentError, type SubagentActivation } from '@deepseek-ai/dsh-subagent'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt'
@@ -125,7 +125,7 @@ async function startWait(ctx: Context, agent: Agent) {
}
/** Start a continuable child of the Lead with a task description. */
async function spawn(ctx: Context, lead: Agent, description: string | undefined, prompt: string, provider = 'spawn'): Promise<SessionId> {
async function spawn(ctx: Context, lead: Agent, description: string | undefined, prompt: string, provider = 'spawn'): Promise<SubagentActivation> {
const started = await ctx.subagents.startActivation({
provider,
label: `${description ?? 'shared'} task`,
@@ -133,7 +133,7 @@ async function spawn(ctx: Context, lead: Agent, description: string | undefined,
signal: SIGNAL,
delivery: 'parent',
})
return started.childId
return started
}
async function assembly(ctx: Context, agent: Agent) {
@@ -167,14 +167,11 @@ async function waitNoAgent(ctx: Context, id: SessionId): Promise<void> {
await vi.waitFor(() => { expect(ctx.agents.get(id)).toBeUndefined() }, { timeout: 5_000 })
}
async function interrupt(ctx: Context, lead: Agent, target: SessionId): Promise<void> {
await ctx.subagents.drainChildren(lead, [target])
}
describe('dsh-tool-team', () => {
it.each([true, false])('cancels reassignment without appending when discovery observes cancellation: %s', async (observesCancellation) => {
const { ctx, lead } = await setup(['hang'])
const childId = await spawn(ctx, lead, 'worker', 'stay running')
const childActivation = await spawn(ctx, lead, 'worker', 'stay running')
const childId = childActivation.childId
await waitRunning(ctx, childId)
const task = await ctx.agentTeams.createTask(lead, { subject: 'work', description: 'assign work' })
const entered = Promise.withResolvers<undefined>()
@@ -218,8 +215,10 @@ describe('dsh-tool-team', () => {
expect(ctx.tools.get(name, scopeOf(lead.ctx))).toBe(ctx.tools.get(name))
}
const namedId = await spawn(ctx, lead, 'reviewer', 'stay available')
const unnamedId = await spawn(ctx, lead, undefined, 'stay available too')
const namedActivation = await spawn(ctx, lead, 'reviewer', 'stay available')
const namedId = namedActivation.childId
const unnamedActivation = await spawn(ctx, lead, undefined, 'stay available too')
const unnamedId = unnamedActivation.childId
for (const id of [namedId, unnamedId]) {
const child = await waitRunning(ctx, id)
const childAssembly = await assembly(ctx, child)
@@ -228,8 +227,8 @@ describe('dsh-tool-team', () => {
expect(renderContextSnapshot(childAssembly)).toContain(teammateIdentity(lead))
expect(ctx.tools.get('send_message', scopeOf(child.ctx))).toBe(ctx.tools.get('send_message'))
}
await interrupt(ctx, lead, namedId)
await interrupt(ctx, lead, unnamedId)
await namedActivation.dispose()
await unnamedActivation.dispose()
await Promise.all([waitNoAgent(ctx, namedId), waitNoAgent(ctx, unnamedId)])
})
@@ -241,7 +240,8 @@ describe('dsh-tool-team', () => {
await runTurn(lead, 'Parent task')
const parentRequest = serializeRequest(adapter.requests[0]!)
const parentHistory = structuredClone(lead.session.deriveMessages())
const childId = await spawn(ctx, lead, 'reviewer', 'Review the work', provider)
const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work', provider)
const childId = childActivation.childId
await waitNoAgent(ctx, childId)
const childRequest = serializeRequest(adapter.requests[1]!)
expect(childRequest.tools).toEqual(parentRequest.tools)
@@ -297,7 +297,8 @@ describe('dsh-tool-team', () => {
it.each([undefined, 'in-history'] as const)('appends current Lead identity to a teammate fork with update mode %s', async (systemPromptUpdate) => {
const { ctx, lead, adapter } = await setup([textResponse('review done'), textResponse('lead notified'), textResponse('fork answer')])
if (systemPromptUpdate !== undefined) adapter.systemPromptUpdate = systemPromptUpdate
const teammateId = await spawn(ctx, lead, 'reviewer', 'Review the work')
const teammateActivation = await spawn(ctx, lead, 'reviewer', 'Review the work')
const teammateId = teammateActivation.childId
await waitNoAgent(ctx, teammateId)
await using persisted = await ctx.sessionPersistence.open(teammateId, 'read')
const { events: seed } = await persisted.read()
@@ -346,7 +347,8 @@ describe('dsh-tool-team', () => {
}
return next()
})
const childId = await spawn(ctx, lead, 'reviewer', 'Review the work')
const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work')
const childId = childActivation.childId
await waitNoAgent(ctx, childId)
for (const request of adapter.requests.filter(request => request.sessionId === childId)) {
const snapshots = request.messages.filter(message => message.source?.kind === 'runtime-context')
@@ -362,7 +364,8 @@ describe('dsh-tool-team', () => {
it('keeps required Team identities when optional runtime context is suppressed', async () => {
const { ctx, lead, adapter } = await setup([textResponse('worker done')])
ctx.systemPrompt.suppressRuntimeContext()
const childId = await spawn(ctx, lead, 'reviewer', 'Review the work')
const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work')
const childId = childActivation.childId
await waitNoAgent(ctx, childId)
expect(renderContextSnapshot(await assembly(ctx, lead))).toContain(leadIdentity(lead))
expect(runtimeContextText(adapter.requests[0]!)).toContain(teammateIdentity(lead))
@@ -380,7 +383,8 @@ describe('dsh-tool-team', () => {
it('adapts task CAS operations and waits to canonical JSON', async () => {
const { ctx, lead } = await setup(['hang'])
const childId = await spawn(ctx, lead, 'json-worker', 'wait')
const childActivation = await spawn(ctx, lead, 'json-worker', 'wait')
const childId = childActivation.childId
const child = await waitRunning(ctx, childId)
const created = await execute(ctx, lead, 'team_task_create', {
@@ -419,13 +423,14 @@ describe('dsh-tool-team', () => {
expect(completed.isError).toBe(false)
expect(JSON.parse(text(await wait))).toEqual({ timedOut: false })
await interrupt(ctx, lead, childId)
await childActivation.dispose()
await waitNoAgent(ctx, childId)
})
it('adapts optional task filters, mutations, pagination, and default waiting', async () => {
const { ctx, lead } = await setup(['hang'])
const childId = await spawn(ctx, lead, 'fork-worker', 'stay active', 'fork')
const childActivation = await spawn(ctx, lead, 'fork-worker', 'stay active', 'fork')
const childId = childActivation.childId
await waitRunning(ctx, childId)
const firstResult = await execute(ctx, lead, 'team_task_create', {
@@ -487,13 +492,14 @@ describe('dsh-tool-team', () => {
expect(wake.isError).toBe(false)
expect(JSON.parse(text(await wait))).toEqual({ timedOut: false })
await interrupt(ctx, lead, childId)
await childActivation.dispose()
await waitNoAgent(ctx, childId)
})
it('removes and reinstalls every scoped registration across plugin HMR without stopping the child', async () => {
const { ctx, lead, fiber } = await setup(['hang'])
const childId = await spawn(ctx, lead, 'hmr-worker', 'wait')
const childActivation = await spawn(ctx, lead, 'hmr-worker', 'wait')
const childId = childActivation.childId
const child = await waitRunning(ctx, childId)
await fiber.dispose()
@@ -512,7 +518,7 @@ describe('dsh-tool-team', () => {
expect(renderContextSnapshot(assembled)).toContain(agent === lead ? leadIdentity(lead) : teammateIdentity(lead))
}
expect(child.status).toBe('running')
await interrupt(ctx, lead, childId)
await childActivation.dispose()
await waitNoAgent(ctx, childId)
await replacement.dispose()
})
@@ -636,7 +642,8 @@ describe('dsh-tool-team', () => {
it('installs Team scope before a cold-resumed teammate request', async () => {
const { ctx, lead, adapter } = await setup([textResponse('first'), textResponse('lead received settlement'), 'hang'])
const childId = await spawn(ctx, lead, 'cold-worker', 'finish once')
const childActivation = await spawn(ctx, lead, 'cold-worker', 'finish once')
const childId = childActivation.childId
await waitNoAgent(ctx, childId)
await vi.waitFor(() => {
expect(adapter.requests.filter(request => request.sessionId === lead.id)).toHaveLength(1)
@@ -651,7 +658,7 @@ describe('dsh-tool-team', () => {
await vi.waitFor(() => { expect(childRequests()).toHaveLength(2) })
const firstMessages = childRequests()[0]!.messages
expect(childRequests()[1]!.messages.slice(0, firstMessages.length)).toEqual(firstMessages)
await interrupt(ctx, lead, childId)
await ctx.subagents.drainDescendants([lead])
await waitNoAgent(ctx, childId)
})
@@ -663,7 +670,8 @@ describe('dsh-tool-team', () => {
sessionId: SessionId('temporary-parent'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const childId = await spawn(ctx, parent.agent, 'orphan', 'finish')
const childActivation = await spawn(ctx, parent.agent, 'orphan', 'finish')
const childId = childActivation.childId
await waitNoAgent(ctx, childId)
await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.agent.id)).toHaveLength(1) })
await parent.agent.whenIdle()
@@ -3168,20 +3168,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'once all admitted startup and member cleanup have settled.',
throws: ['an aggregate error after all branches settle when any failed.'],
},
{
signature: 'async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>',
description: 'Release selected resident direct children of one exact live parent. Every unselected member, including their descendants, stays resident. Absent targets and a manager-less composition are accepted no-ops.',
parameters: [{ name: 'parent', description: 'exact live direct parent authorizing the selected release.' }, { name: 'childIds', description: 'durable direct-child ids to release when resident.' }],
returns: 'once every selected activation released its execution handle.',
throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'],
},
{
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>',
description: 'Read the parent\'s durable direct-child catalog without loading or resuming an Agent. The service owns and releases the live-preferred Session observation.',
parameters: [{ name: 'parentSessionId', description: 'parent whose direct children are requested.' }, { name: 'signal', description: 'cancellation forwarded to the Session query.' }],
returns: 'catalog children in parent event order.',
throws: ['{@link SubagentError} when query or catalog projection is unavailable.', 'SessionQueryError when the parent cannot be read or the query is cancelled.'],
},
{
signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>',
description: 'Recursively list reachable parent catalogs in stable pre-order, preserving each catalog\'s event order. Each row carries its catalog parent and depth; external children are leaves; one-shot and unknown-mode children remain traversal nodes. Unknown modes produce unsupported diagnostics. Unreadable child catalogs produce corrupt or unavailable diagnostics and stop only that branch. Root read failures, missing services or projections, and cancellation reject the whole listing. Each catalog is observed once and released before the next read. No Agent is loaded or resumed; Sessions absent from reachable catalogs are omitted.',
@@ -3215,12 +3201,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'name', description: 'the provider name.' }],
returns: 'the provider, or undefined when absent.',
},
{
signature: 'list(): string[]',
description: 'List registered provider names in insertion order.',
parameters: [],
returns: 'the registered names.',
},
],
},
{
@@ -8098,10 +8078,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly agentPreset: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n}',
},
{
name: 'SubagentCatalogEntry',
declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & (KnownSubagentCatalogFields | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});',
},
{
name: 'SubagentCatalogRow',
declaration: 'export type SubagentCatalogRow = {\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & KnownSubagentCatalogFields;',
@@ -8164,7 +8140,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRuntime',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n static inject;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise<SubagentActivation & {\n readonly messageId: MessageId;\n }>;\n startActivation(spec: SubagentActivationSpec): Promise<SubagentActivation>;\n async startActivation(spec: SubagentActivationSpec): Promise<SubagentActivation>;\n rootOf(caller: Agent): Agent;\n async waitForTreeWork(root: Agent): Promise<boolean>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise<void>;\n async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote({ name: \'prompt\', resources: { session: request => request.childSessionId } })\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote({ name: \'interruptByUser\', re /* …truncated — full shape in source */',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n static inject;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise<SubagentActivation & {\n readonly messageId: MessageId;\n }>;\n startActivation(spec: SubagentActivationSpec): Promise<SubagentActivation>;\n async startActivation(spec: SubagentActivationSpec): Promise<SubagentActivation>;\n rootOf(caller: Agent): Agent;\n async waitForTreeWork(root: Agent): Promise<boolean>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise<void>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote({ name: \'prompt\', resources: { session: request => request.childSessionId } })\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote({ name: \'interruptByUser\', resources: { session: childSessionId => childSessionId } })\n interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => /* …truncated — full shape in source */',
},
{
name: 'SubagentSendMessageOptions',
@@ -1570,9 +1570,9 @@ describe('dsh-subagent-acp', () => {
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
expect(ctx.subagents.list()).toEqual(['acp'])
expect(ctx.subagents.getProvider('acp')).toBeDefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('acp')).toBeUndefined()
})
it('has the namespace-plugin export shape (no stray default)', () => {
@@ -72,8 +72,7 @@ try {
.sort()
process.stdout.write(`${JSON.stringify({
registeredProviders: ctx.subagents.list().filter(providerName =>
providerNames.includes(providerName as typeof providerNames[number])).sort(),
registeredProviders: providerNames.filter(providerName => ctx.subagents.getProvider(providerName) !== undefined).sort(),
providers,
tools,
jobTools,
@@ -428,7 +428,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263
])
await safeInstance.fixture.requestStarted
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['claude-bypass'])
expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined()
await expect(startExternalActivation(ctx, 'claude-safe', {
prompt: [{ type: 'text', text: 'This start must fail.' }],
parent: safeParent,
@@ -458,7 +458,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263
].sort())
await expectQuiescent(handles)
await bypassFiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('claude-bypass')).toBeUndefined()
})
it('maps a real CLI process failure to its exit outcome', async () => {
@@ -453,9 +453,9 @@ describe('task admission and package contracts', () => {
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['claude-code'])
expect(ctx.subagents.getProvider('claude-code')).toBeDefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('claude-code')).toBeUndefined()
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
@@ -519,7 +519,8 @@ describe('task admission and package contracts', () => {
permissionMode: 'bypassPermissions',
disposeGraceMs: 29,
})
expect(ctx.subagents.list()).toEqual(['claude-safe', 'claude-bypass'])
expect(ctx.subagents.getProvider('claude-safe')).toBeDefined()
expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined()
expect(added).toEqual(['claude-safe', 'claude-bypass'])
const safeController = new AbortController()
@@ -528,7 +529,8 @@ describe('task admission and package contracts', () => {
startExternalActivation(ctx, 'claude-bypass', request()),
])
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['claude-bypass'])
expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined()
expect(ctx.subagents.getProvider('claude-safe')).toBeUndefined()
expect(removed).toEqual(['claude-safe'])
await expect(startExternalActivation(ctx, 'claude-safe', request()))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
@@ -583,7 +585,6 @@ describe('task admission and package contracts', () => {
permissionMode: 'bypassPermissions',
})).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
expect(ctx.subagents.getProvider('claude-duplicate')).toBe(first)
expect(ctx.subagents.list()).toEqual(['claude-duplicate'])
await firstFiber.dispose()
await ctx.fiber.dispose()
})
@@ -336,7 +336,7 @@ describe('real @openai/codex 0.153.4 product', () => {
])
await safeInstance.fixture.requestStarted
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['codex-bypass'])
expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined()
await expect(startExternalActivation(ctx, 'codex-safe', {
prompt: [{ type: 'text', text: 'This start must fail.' }],
parent: safeParent,
@@ -365,7 +365,7 @@ describe('real @openai/codex 0.153.4 product', () => {
].sort())
await expectQuiescent(handles)
await bypassFiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('codex-bypass')).toBeUndefined()
}, 60_000)
it('overrides on-request with never and reports a denied command safely', async () => {
@@ -444,9 +444,9 @@ describe('task admission and package contracts', () => {
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['codex'])
expect(ctx.subagents.getProvider('codex')).toBeDefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('codex')).toBeUndefined()
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(codex, { disposeGraceMs }))
@@ -494,7 +494,8 @@ describe('task admission and package contracts', () => {
permissionMode: 'dangerously-bypass-approvals-and-sandbox',
disposeGraceMs: 29,
})
expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass'])
expect(ctx.subagents.getProvider('codex-safe')).toBeDefined()
expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined()
expect(added).toEqual(['codex-safe', 'codex-bypass'])
const safeController = new AbortController()
@@ -521,7 +522,8 @@ describe('task admission and package contracts', () => {
bypassStarting,
])
await safeFiber.dispose()
expect(ctx.subagents.list()).toEqual(['codex-bypass'])
expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined()
expect(ctx.subagents.getProvider('codex-safe')).toBeUndefined()
expect(removed).toEqual(['codex-safe'])
await expect(startExternalActivation(ctx, 'codex-safe', request()))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
@@ -576,7 +578,6 @@ describe('task admission and package contracts', () => {
permissionMode: 'dangerously-bypass-approvals-and-sandbox',
})).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first)
expect(ctx.subagents.list()).toEqual(['codex-duplicate'])
await firstFiber.dispose()
await ctx.fiber.dispose()
})
@@ -40,7 +40,8 @@ function text(blocks: readonly { type: string; text?: string }[]): string {
describe('multi-subagent coexistence (spawn + fork on one context)', () => {
it('both providers register and coexist', async () => {
const { ctx } = await setup([])
expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn'])
expect(ctx.subagents.getProvider('fork')).toBeDefined()
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
})
it('the same parent drives a spawn child AND a fork child, then keeps working', async () => {
@@ -199,9 +199,9 @@ describe('dsh-subagent-fork-in-process', () => {
await ctx.plugin(SubagentRuntime)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(fork, { providerName: 'fork' })
expect(ctx.subagents.list()).toEqual(['fork'])
expect(ctx.subagents.getProvider('fork')).toBeDefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('fork')).toBeUndefined()
})
it('contributes the completed-turn prefix as a continuable child\'s seed', async () => {
@@ -281,9 +281,9 @@ describe('dsh-subagent-spawn-in-process', () => {
await ctx.plugin(SubagentRuntime)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
expect(ctx.subagents.list()).toEqual(['spawn'])
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
})
it('captures structured output through the shipped plugin (driver runtime, plugin wiring)', async () => {
+2 -2
View File
@@ -33,8 +33,8 @@
en: a740e5e9bdd661d9
zh: 712a5458fc8b3615
/deepseek-ai-dsh-subagent/use-this-package/messaging-interrupting-and-discovering:
en: 3785a9c76ae22cad
zh: a742559205db0dc6
en: bf3175c2218a16dd
zh: 4be9271efe71ddb7
/deepseek-ai-dsh-subagent/use-this-package/failure-and-recovery:
en: 896bf2515524c01f
zh: 1f1413710e58e4f6
+1 -1
View File
@@ -66,7 +66,7 @@ In-process spawn accepts an independent `agentPreset`; fork and external backend
### Messaging, interrupting, and discovering
`sendMessage()` lets the live root and resident continuable subagents contact any other root or continuable member in the same tree by Session id. The target receives Steer input at its next step or starts an idle turn. Only the cold target resumes, under the subagent tree root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Agent interruption requires ancestor authority. Browser prompts and user interrupts address only the child Session; the Gateway checks the corresponding action on that Session before any side effect. Parent or global grants do not imply child access. Prompts require a live subagent tree root; the creator may be offline. User interrupts perform no restoration and accept absent targets as no-ops. They select Queue or Steer and admit image batches through the attachment store; incompatible target models reject images. Direct-child discovery reads `subagentCatalog` through `listChildren(parentSessionId, signal?)`, owning and releasing a live-preferred Session observation. Recursive discovery preserves catalog order, reads each reachable Session once, skips external leaves, and reports unreadable branches without activating Agents.
`sendMessage()` lets the live root and resident continuable subagents contact any other root or continuable member in the same tree by Session id. The target receives Steer input at its next step or starts an idle turn. Only the cold target resumes, under the subagent tree root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Agent interruption requires ancestor authority. Browser prompts and user interrupts address only the child Session; the Gateway checks the corresponding action on that Session before any side effect. Parent or global grants do not imply child access. Prompts require a live subagent tree root; the creator may be offline. User interrupts perform no restoration and accept absent targets as no-ops. They select Queue or Steer and admit image batches through the attachment store; incompatible target models reject images. `listDescendants()` discovers members recursively through parent-owned `subagentCatalog` projections. It preserves catalog order, reads each reachable Session once, skips external leaves, and reports unreadable branches without activating Agents.
### Failure and recovery
+1 -1
View File
@@ -66,7 +66,7 @@ kind: "package-reference"
### 消息、中断与发现
`sendMessage()` 允许在线根和驻留的可继续子代理,按 Session id 联系同一棵树中的任意其他根或可继续成员。目标在下一步接收 Steer 输入,或在空闲时开始轮次。仅在 subagent 树根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。Agent 中断要求祖先权限。浏览器提示词和用户中断仅按子 Session 寻址;Gateway 在任何副作用之前检查该 Session 上对应操作的权限。父级或 global 授权不意味着子级访问权。提示词要求 subagent 树根在线;创建者可以离线。用户中断不恢复目标,目标缺席时接受为无操作。提示词选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。直接子级发现通过 `listChildren(parentSessionId, signal?)` 读取 `subagentCatalog`,持有并释放优先在线的 Session observation。递归发现保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。
`sendMessage()` 允许在线根和驻留的可继续子代理,按 Session id 联系同一棵树中的任意其他根或可继续成员。目标在下一步接收 Steer 输入,或在空闲时开始轮次。仅在 subagent 树根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。Agent 中断要求祖先权限。浏览器提示词和用户中断仅按子 Session 寻址;Gateway 在任何副作用之前检查该 Session 上对应操作的权限。父级或 global 授权不意味着子级访问权。提示词要求 subagent 树根在线;创建者可以离线。用户中断不恢复目标,目标缺席时接受为无操作。提示词选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。`listDescendants()` 通过父级拥有的 `subagentCatalog` 投影递归发现成员。它保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。
### 失败与恢复
+1 -39
View File
@@ -69,10 +69,9 @@ import { assertSubagentMaxDepth } from './depth.ts'
import { createLifecycleEmitter } from './lifecycle.ts'
import type { LifecycleEmitter } from './lifecycle.ts'
import SubagentManager from './manager.ts'
import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts'
import { listDescendants as listSubagentDescendants } from './list-children.ts'
import type { SubagentDescendantListEntry } from './list-children.ts'
import { installSubagentArchiveAdmission } from './archive-admission.ts'
import type { SubagentCatalogEntry } from './projection-types.ts'
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
import type { IdentityState } from './projection.ts'
import { subagentCatalogProjectionDefinition } from './catalog.ts'
@@ -364,35 +363,6 @@ export class SubagentRuntime extends TypertRemoteService {
await manager.drainDescendants(parents)
}
/**
* Release selected resident direct children of one exact live
* parent. Every unselected member, including their descendants, stays resident.
* Absent targets and a manager-less composition are accepted no-ops.
* @param parent - exact live direct parent authorizing the selected release.
* @param childIds - durable direct-child ids to release when resident.
* @returns once every selected activation released its execution handle.
* @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a
* different parent or the supplied parent identity is stale.
*/
async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void> {
const manager = this.manager
if (manager === undefined) return
await manager.drainChildren(parent, childIds)
}
/**
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]> {
return listSubagentChildren(this.ctx, parentSessionId, signal)
}
/**
* Recursively list reachable parent catalogs in stable pre-order, preserving
* each catalog's event order. Each row carries its catalog parent and depth;
@@ -534,14 +504,6 @@ export class SubagentRuntime extends TypertRemoteService {
return this.providers.get(name)
}
/**
* List registered provider names in insertion order.
* @returns the registered names.
*/
list(): string[] {
return [...this.providers.keys()]
}
/**
* Resolve one provider's detached continuable-creation contribution. Method
* presence on the provider IS the capability, so a provider without it is
-29
View File
@@ -282,35 +282,6 @@ export class SubagentManager {
if (disposed.status === 'rejected') throw disposed.reason
}
/**
* Release selected resident direct children of one exact live parent.
* @param parent - exact live direct parent authorizing the selected release.
* @param childIds - durable direct-child ids to release when resident.
*/
async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void> {
if (this.ctx.agents.get(parent.id) !== parent) {
throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED')
}
const targets: Activation[] = []
for (const childId of new Set(childIds)) {
const activation = this.resident.get(childId)
if (activation === undefined) continue
if (activation.ancestors.at(-1) !== parent.id || activation.root !== this.rootOf(parent)) {
throw new SubagentError(
`subagent "${childId}" is not a direct child of agent "${parent.id}"`,
'UNAUTHORIZED',
)
}
targets.push(activation)
}
for (const activation of targets) {
const disposal = this.dispose(activation)
void disposal.catch(() => undefined)
}
await this.disposeRoots(targets, 'selected activation(s)')
}
/**
* Start one continuable background child and resolve at initial inbox acceptance.
* Every earlier failure disposes any created handle and rolls back Activation
@@ -1,3 +1,4 @@
import { listChildren } from '../src/list-children.ts'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -488,7 +489,7 @@ describe('continuable activation capacity', () => {
await dispose()
if (failDisposal) throw new Error('cleanup report failed')
}
const drained = ctx.subagents.drainChildren(parent, [started.childId])
const drained = started.dispose()
const outcome = drained.catch((error: unknown) => error)
releaseRun.resolve(undefined)
await entered.promise
@@ -1710,7 +1711,7 @@ describe('continuable durability and teardown', () => {
expect(ctx.agents.get(leaf.childId)).toBeUndefined()
})
it('releases only selected direct children', async () => {
it('disposes one activation without releasing siblings or descendants', async () => {
const releaseTarget = Promise.withResolvers<undefined>()
const releaseSibling = Promise.withResolvers<undefined>()
const releaseGrandchild = Promise.withResolvers<undefined>()
@@ -1729,13 +1730,9 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) })
const cancel = vi.spyOn(targetAgent, 'cancel')
// A repeated id folds to one teardown and an absent target is an accepted no-op.
const drained = ctx.subagents.drainChildren(
parent,
[target.childId, target.childId, SessionId('never-materialized')],
)
const drained = Promise.all([target.dispose(), target.dispose()])
expect(cancel).toHaveBeenCalledWith({ kind: 'parent' })
expect(cancel).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' })
expect(ctx.agents.get(sibling.childId)).toBe(siblingAgent)
releaseTarget.resolve(undefined)
await drained
@@ -1748,14 +1745,7 @@ describe('continuable durability and teardown', () => {
await waitNoActivation(ctx, sibling.childId)
})
it('ignores a selected direct child that is not resident', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.drainChildren(parent, [SessionId('settled-child')]))
.resolves.toBeUndefined()
})
it('reports selected-child disposal failures after releasing the child', async () => {
it('reports activation disposal failures after releasing the child', async () => {
const hold = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: hold.promise }])
const { ctx, parent } = await setupWith(adapter)
@@ -1769,37 +1759,13 @@ describe('continuable durability and teardown', () => {
throw new Error('selected cleanup failed')
}
const drained = ctx.subagents.drainChildren(parent, [target.childId])
const drained = target.dispose()
hold.resolve(undefined)
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
expect(ctx.agents.get(target.childId)).toBeUndefined()
})
it('rejects selected-child teardown through another live parent', async () => {
const release = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: release.promise }])
const { ctx, parent } = await setupWith(adapter)
const other = await ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' })
const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
await expect(ctx.subagents.drainChildren(other, [target.childId]))
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
expect(ctx.agents.get(target.childId)).toBeDefined()
release.resolve(undefined)
await waitNoActivation(ctx, target.childId)
})
it('rejects selected-child teardown through a stale parent identity', async () => {
const { ctx, parent } = await setup([])
const stale = { ...parent, id: parent.id } as Agent
await expect(ctx.subagents.drainChildren(stale, []))
.rejects.toMatchObject({ code: 'UNAUTHORIZED' })
})
it('drains the subagent tree after an intermediate creator leaves the registry', async () => {
const releaseIntermediate = Promise.withResolvers<undefined>()
const releaseDescendant = Promise.withResolvers<undefined>()
@@ -3534,7 +3500,7 @@ describe('continuable public API', () => {
await run.dispose()
await waitNoActivation(ctx, run.childId)
expect(settlementNotices(parent)).toEqual([])
expect(await ctx.subagents.listChildren(parent.id)).toMatchObject([
expect(await listChildren(ctx, parent.id)).toMatchObject([
{ id: run.childId, mode: 'continuable' },
])
expect(userTexts((await loadStoredSession(ctx.sessionPersistence, run.childId)).events))
@@ -3621,7 +3587,8 @@ describe('continuable errors', () => {
const detach = ctx.on('subagent/start', (info) => {
starts.push(info)
residentAtStart.push(ctx.agents.get(info.id)?.id === info.id)
drains.push(ctx.subagents.drainChildren(parent, [info.id]))
// The start event precedes the public activation receipt.
drains.push(subagentManager(ctx)['dispose'](managerState(ctx).resident.get(info.id)!))
})
ctx.on('subagent/end', (info) => { ends.push(info) })
try {
@@ -1,3 +1,4 @@
import { listChildren } from '../src/list-children.ts'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
/** External execution ownership, result delivery, and process cleanup. */
@@ -347,14 +348,14 @@ it('records external membership at creation without publishing settlement update
try {
const activation = await fixture.start('parent')
const expected = { id: activation.childId, mode: 'external', label: 'External work' }
expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected])
expect(await listChildren(fixture.ctx, fixture.parent.id)).toMatchObject([expected])
expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined()
const result: SubagentResult = { output: [{ type: 'text', text: 'x'.repeat(100_000) }], stopReason: 'completed' }
backend.result.resolve(result)
backend.cleanup.resolve(undefined)
await expect(activation.result).resolves.toEqual(result)
await activation.dispose()
expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected])
expect(await listChildren(fixture.ctx, fixture.parent.id)).toMatchObject([expected])
expect(snapshots).toHaveLength(1)
expect(JSON.stringify(snapshots).length).toBeLessThan(1_000)
} finally {
@@ -1,3 +1,4 @@
import { listChildren } from '../src/list-children.ts'
import { subagentCatalogProjectionDefinition } from '../src/catalog.ts'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -85,7 +86,7 @@ async function startChild(
return started.childId
}
describe('SubagentRuntime.listChildren', () => {
describe('direct-child catalog reader', () => {
it('lists historical children through a migrated parent while preserving V3 files', async () => {
const { ctx, root } = await setup([], { compression: 'none' })
const parent = SessionId('historical-parent')
@@ -106,7 +107,7 @@ describe('SubagentRuntime.listChildren', () => {
return { path, content }
})
expect(await ctx.subagents.listChildren(parent)).toEqual([{
expect(await listChildren(ctx, parent)).toEqual([{
id: child, createdAt: 2, mode: 'continuable', label: 'historical child',
}])
for (const { path, content } of originals) {
@@ -123,7 +124,7 @@ describe('SubagentRuntime.listChildren', () => {
parent,
signal: testSignal,
})
expect(await ctx.subagents.listChildren(parent.id)).toMatchObject([
expect(await listChildren(ctx, parent.id)).toMatchObject([
{ id: callerOwned.childId, label: 'spawn', mode: 'continuable' },
])
await callerOwned.result
@@ -132,7 +133,7 @@ describe('SubagentRuntime.listChildren', () => {
const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions')
const observeSession = vi.spyOn(ctx.sessionQuery, 'observeSession')
const children = await ctx.subagents.listChildren(parent.id)
const children = await listChildren(ctx, parent.id)
expect(children.map(({ createdAt: _createdAt, ...child }) => child)).toEqual([
{ id: callerOwned.childId, label: 'spawn', mode: 'continuable' },
{ id: continuableId, label: 'continuable child', mode: 'continuable' },
@@ -158,7 +159,7 @@ describe('SubagentRuntime.listChildren', () => {
...observed,
[Symbol.dispose]: dispose,
})
expect(await ctx.subagents.listChildren(parent.id)).toEqual([{
expect(await listChildren(ctx, parent.id)).toEqual([{
id: childId, createdAt: 3, label: 'state child', mode: 'one-shot',
}])
expect(dispose).toHaveBeenCalledOnce()
@@ -169,7 +170,7 @@ describe('SubagentRuntime.listChildren', () => {
using observed = await ctx.sessionQuery.observeSession(parent.id, { projectionMode: 'none' })
const dispose = vi.fn()
vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ ...observed, [Symbol.dispose]: dispose })
await expect(ctx.subagents.listChildren(parent.id)).rejects.toMatchObject({
await expect(listChildren(ctx, parent.id)).rejects.toMatchObject({
code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
})
expect(dispose).toHaveBeenCalledOnce()
@@ -179,12 +180,12 @@ describe('SubagentRuntime.listChildren', () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
controller.abort(new Error('cancel catalog'))
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toMatchObject({
await expect(listChildren(ctx, parent.id, controller.signal)).rejects.toMatchObject({
code: 'SESSION_QUERY_ABORTED',
})
const failure = new Error('parent read failed')
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(failure)
await expect(ctx.subagents.listChildren(parent.id)).rejects.toBe(failure)
await expect(listChildren(ctx, parent.id)).rejects.toBe(failure)
})
it('ignores inherited fork facts and accepts facts in the fork own suffix', async () => {
@@ -213,7 +214,7 @@ describe('SubagentRuntime.listChildren', () => {
label: 'own',
})
expect(await ctx.subagents.listChildren(fork.id)).toEqual([{
expect(await listChildren(ctx, fork.id)).toEqual([{
id: SessionId('own-child'), createdAt: 2, mode: 'continuable', label: 'own',
}])
})
@@ -246,7 +247,7 @@ describe('SubagentRuntime.listChildren', () => {
chunkLengths.push(chunk.values.length)
}
expect(chunkLengths).toEqual([1, ...Array.from({ length: 16 }, () => 64)])
const entries = await ctx.subagents.listChildren(parent.id)
const entries = await listChildren(ctx, parent.id)
expect(listSessions).not.toHaveBeenCalled()
expect(entries).toHaveLength(1_025)
expect(entries.find(entry => entry.id === 'child-064')).toEqual({
@@ -261,7 +262,7 @@ describe('SubagentRuntime.listChildren', () => {
await withoutProjection.plugin(SessionProjectionRegistry)
await withoutProjection.plugin(SubagentRuntime)
const parent = withoutProjection.sessions.create(SessionId('parent'))
await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({
await expect(listChildren(withoutProjection, parent.id)).rejects.toMatchObject({
code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE',
})
})
@@ -128,7 +128,7 @@ describe('SubagentRuntime', () => {
expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined()
})
it('registers, lists, looks up, starts, and removes providers', async () => {
it('registers, looks up, starts, and removes providers', async () => {
const { ctx, subagents } = await service()
const added: string[] = []
const removed: string[] = []
@@ -137,7 +137,6 @@ describe('SubagentRuntime', () => {
const provider = new StubProvider('alpha')
const dispose = subagents.registerProvider(provider)
expect(subagents.list()).toEqual(['alpha'])
expect(subagents.getProvider('alpha')).toBe(provider)
const run = await start(subagents, 'alpha', baseRequest())
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
@@ -190,7 +189,6 @@ describe('SubagentRuntime', () => {
// Without `ctx.agents` no manager exists, so nothing was ever materialized.
expect('drainContinuable' in subagents).toBe(false)
await expect(subagents.drainDescendants([])).resolves.toBeUndefined()
await expect(subagents.drainChildren(fakeParent(), [SessionId('child')])).resolves.toBeUndefined()
})
it('treats interrupt as an accepted no-op when no manager was bound', async () => {
@@ -1,3 +1,4 @@
import { listChildren } from '../src/list-children.ts'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
@@ -16,7 +17,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a
import SubagentRuntime from '../src/index.ts'
import { loadStoredSession } from './persistence-helpers.ts'
import { TestSessionQuery } from './test-session-query.ts'
import { managerState } from './manager-internals.ts'
import { managerState, subagentManager } from './manager-internals.ts'
import { requireLocalActivation } from '../src/activation.ts'
/** A model reply, or a turn that stays open until its Agent is cancelled. */
@@ -122,7 +123,7 @@ describe('subagent sibling messages', () => {
const b = await start(ctx, parent, 'b')
await settled(ctx, b)
expect(a).not.toBe(b)
expect(await ctx.subagents.listChildren(parent.id)).toEqual([
expect(await listChildren(ctx, parent.id)).toEqual([
expect.objectContaining({ id: a, mode: 'continuable', label: 'shared responsibility' }),
expect.objectContaining({ id: b, mode: 'continuable', label: 'shared responsibility' }),
])
@@ -345,7 +346,7 @@ describe('tree-wide messages', () => {
await dispose()
if (failCleanup) throw new Error('cleanup report failed')
}
const drained = ctx.subagents.drainChildren(parent, [branchId]).catch((error: unknown) => error)
const drained = subagentManager(ctx)['dispose'](activation).catch((error: unknown) => error)
let delivery: Promise<unknown> | undefined
try {
await entered.promise
@@ -34,7 +34,7 @@ async function mount(config: Partial<scripted.Config> = {}): Promise<Context> {
describe('scripted subagent provider fixture', () => {
it('registers through the real service and returns the scripted reply', async () => {
const ctx = await mount({ reply: 'hello from fixture' })
expect(ctx.subagents.list()).toEqual(['mock'])
expect(ctx.subagents.getProvider('mock')).toBeDefined()
const run = await ctx.subagents.getProvider('mock')!.start!(baseRequest())
await expect(run.result).resolves.toEqual({
@@ -47,7 +47,8 @@ describe('scripted subagent provider fixture', () => {
it('registers under a configurable name', async () => {
const ctx = await mount({ name: 'spawn' })
expect(ctx.subagents.list()).toEqual(['spawn'])
expect(ctx.subagents.getProvider('spawn')).toBeDefined()
expect(ctx.subagents.getProvider('mock')).toBeUndefined()
})
it('returns configured and default structured results', async () => {
@@ -98,8 +99,9 @@ describe('scripted subagent provider fixture', () => {
await mountWorkingDirectoryFixture(ctx)
await ctx.plugin(SubagentRuntime)
const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' })
expect(ctx.subagents.list()).toEqual(['mock'])
expect(ctx.subagents.getProvider('mock')).toBeDefined()
expect(ctx.subagents.getProvider('spawn')).toBeUndefined()
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
expect(ctx.subagents.getProvider('mock')).toBeUndefined()
})
})