fix(subagent): decouple child listing runtime
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: cea62b90a4c5cb3c4ec74c98f4272daefe78b38e
|
||||
README.zh.md: 73256ef503c23d391a2c6186c36f4ac933929c8a
|
||||
README.md: 0c93856bf973781254195b2f3869a54833bf83ac
|
||||
README.zh.md: aa4c58d01582863e702404d1c8f3d32d3db7f534
|
||||
|
||||
@@ -32,6 +32,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
| `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. |
|
||||
| `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 continuable children and per-child diagnostics in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
|
||||
`SubagentStartRequest.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 live child. 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.
|
||||
|
||||
@@ -88,7 +89,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
## 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. 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. 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 [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. 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()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan without consulting the continuation manager, Agent registrations, Activations, or providers. It forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ subagent seam 允许一个 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`;冷恢复还要求会话持久化。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出直接可继续 child 及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
|
||||
`SubagentStartRequest.signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
|
||||
|
||||
@@ -88,7 +89,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。完整契约见[后台 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/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,其通用状态、收集和取消工具负责后续交互。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释只读、实时优先的扫描结果,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。它会把调用方的取消信号转发给可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 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`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -19,7 +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.
|
||||
* creation spec and see no handle, turn, or teardown. Direct-child discovery
|
||||
* independently interprets the optional session-query corpus and does not
|
||||
* require that continuation runtime.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -36,11 +38,6 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertSessionHeadersCompatible,
|
||||
SessionQueryError,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
ContinuableCreateRequest,
|
||||
ContinuableCreateSpec,
|
||||
@@ -52,7 +49,6 @@ import type {
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import { SubagentError } from './error.ts'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { createActivationObserver, createLifecycleEmitter, observeRun } from './lifecycle.ts'
|
||||
import type { ActivationObserver, LifecycleEmitter } from './lifecycle.ts'
|
||||
@@ -62,6 +58,8 @@ import type {
|
||||
ContinuableStartSpec,
|
||||
SubagentFollowupOptions,
|
||||
} from './continuation.ts'
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -100,30 +98,9 @@ export type {
|
||||
CoordinatorMessageSource,
|
||||
SubagentFollowupOptions,
|
||||
} from './continuation.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
|
||||
/**
|
||||
* One direct-child enumeration result. Descriptor-less ordinary children are
|
||||
* omitted; a per-child inspection failure remains visible as a diagnostic.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
/** Durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
/** Whether the child is currently live or exists only in persistence. */
|
||||
readonly status: 'running' | 'complete'
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** Traced candidate session id. */
|
||||
readonly id: SessionId
|
||||
/** Fixed reason the candidate could not be returned as a child. */
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
subagents: SubagentService
|
||||
@@ -165,7 +142,7 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Named provider registry with one-shot runs and continuable-child operations. */
|
||||
/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
|
||||
export class SubagentService extends Service {
|
||||
private providers = new Map<string, SubagentProvider>()
|
||||
private continuations: SubagentContinuationManager | undefined
|
||||
@@ -247,93 +224,25 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate one session's direct continuable children from the durable,
|
||||
* live-preferred corpus without loading or resuming an Agent. The lineage
|
||||
* trace supplies stable candidate order and live status; each candidate is
|
||||
* then inspected independently for exactly one supported descriptor in its
|
||||
* own suffix. Listing is storage-read-only: session query resolves persisted
|
||||
* candidates through the non-mutating `inspect()` read, so no catalog,
|
||||
* descriptor, or repair event is written. Session-query reads take no signal,
|
||||
* so cancellation is cooperative: the scan rechecks `signal` before and
|
||||
* after the initial trace and after every other un-signalled await instead of
|
||||
* draining a slow or large catalog after the caller has gone.
|
||||
* @param parentSessionId - parent whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation observed between query awaits.
|
||||
* @returns child and diagnostic entries in lineage-trace order.
|
||||
* Enumerate the parent's direct continuable children from the live-preferred
|
||||
* session corpus without loading or resuming an Agent. Session query supplies
|
||||
* lineage, candidate order, event reads, and live state; this service
|
||||
* interprets descriptors, status, and per-child diagnostics without consulting
|
||||
* Agent registrations, Activations, or providers.
|
||||
*
|
||||
* The trace and exact descriptor read receive `signal`; the full event-list
|
||||
* read has no signal parameter, so the scan rechecks cancellation around
|
||||
* every await and between candidates. Query rejections that settle after an
|
||||
* abort become a stable `SubagentError` with code `CANCELLED`.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation forwarded where supported and
|
||||
* observed around every query await.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or the
|
||||
* caller cancels the scan.
|
||||
*/
|
||||
async listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
|
||||
const query = this.ctx.get('sessionQuery')
|
||||
if (query === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires session query (load a dsh-session-query backend)',
|
||||
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
const trace = await query.traceSession(parentSessionId)
|
||||
assertListingNotCancelled(signal)
|
||||
const entries: SubagentListEntry[] = []
|
||||
for (const node of trace.descendants) {
|
||||
const entry = await this.inspectChild(query, parentSessionId, node.session, signal)
|
||||
// Recheck after the inspection settles, not only inside it: a mapped
|
||||
// per-child failure during an abort becomes a diagnostic and skips the
|
||||
// inspection's own checkpoints, and a cancelled scan must not return a
|
||||
// successful result or start another candidate read.
|
||||
assertListingNotCancelled(signal)
|
||||
if (entry !== undefined) entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Inspect one traced candidate without materializing its Agent. */
|
||||
private async inspectChild(
|
||||
query: SessionQueryService,
|
||||
parentSessionId: SessionId,
|
||||
candidate: SessionRecord,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SubagentListEntry | undefined> {
|
||||
const childId = candidate.header.id
|
||||
try {
|
||||
const records = await query.listEvents(childId)
|
||||
assertListingNotCancelled(signal)
|
||||
// Fork seeds replay ancestor events, so only this child's suffix owns its descriptor.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
.map(record => record.seq)
|
||||
if (descriptorSeqs.length === 0) return undefined
|
||||
if (descriptorSeqs.length > 1) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this index exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await query.readEvent({ sessionId: childId, seq })
|
||||
assertListingNotCancelled(signal)
|
||||
assertSessionHeadersCompatible(window.session, candidate.header)
|
||||
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let descriptor: ReturnType<typeof foldSubagentDescriptor>
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([window.target])
|
||||
} catch {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
|
||||
}
|
||||
return {
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
label: descriptor.label,
|
||||
status: candidate.live ? 'running' : 'complete',
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error)
|
||||
if (reason === undefined) throw error
|
||||
return { kind: 'diagnostic', id: childId, reason }
|
||||
}
|
||||
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
|
||||
return listSubagentChildren(this.ctx, parentSessionId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,26 +376,3 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
export default SubagentService
|
||||
|
||||
/** Stop a cooperative listing scan at its next cancellation checkpoint. */
|
||||
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
|
||||
}
|
||||
}
|
||||
|
||||
/** Map isolated session-query failures to the fixed child diagnostic taxonomy. */
|
||||
function perChildDiagnosticReason(error: unknown): 'corrupt' | 'unavailable' | undefined {
|
||||
if (!(error instanceof SessionQueryError)) return undefined
|
||||
switch (error.code) {
|
||||
case 'SESSION_QUERY_SESSION_NOT_FOUND':
|
||||
case 'SESSION_QUERY_EVENT_NOT_FOUND':
|
||||
case 'SESSION_QUERY_PERSISTENCE_FAILED':
|
||||
return 'unavailable'
|
||||
case 'SESSION_QUERY_INVALID_SURFACE':
|
||||
case 'SESSION_QUERY_SOURCE_CONFLICT':
|
||||
return 'corrupt'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
200
packages/subagent/subagent/src/list-children.ts
Normal file
200
packages/subagent/subagent/src/list-children.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Read-only interpretation of session-query lineage as durable subagent
|
||||
* children. The module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from './error.ts'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
|
||||
type SessionQueryRuntime = Pick<
|
||||
typeof import('@deepseek-ai/dsh-session-query'),
|
||||
'assertSessionHeadersCompatible' | 'SessionQueryError'
|
||||
>
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result in trace candidate order. A valid
|
||||
* descriptor produces a `child`, a per-child inspection failure produces a
|
||||
* `diagnostic`, and a descriptor-less ordinary child is omitted. Diagnostics
|
||||
* are transient query results, never session events or catalog state, and
|
||||
* never expose model-hidden descriptor content.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
readonly kind: 'child'
|
||||
/** The durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/** The durable creation label from the child's descriptor. */
|
||||
readonly label: string
|
||||
/**
|
||||
* Corpus snapshot status: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `complete` means it exists only in persistence and
|
||||
* `send_message` may materialize another Activation. Neither encodes a durable
|
||||
* outcome, and a listed `running` child may still reject delivery as an
|
||||
* ownership conflict.
|
||||
*/
|
||||
readonly status: 'running' | 'complete'
|
||||
}
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** The traced candidate's session id. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Why the candidate was omitted: `corrupt` for invalid surfaces, header
|
||||
* conflicts, or malformed/duplicated descriptors; `unsupported` for an
|
||||
* unknown descriptor version; `unavailable` when the child disappeared or
|
||||
* its per-child read hit a persistence failure.
|
||||
*/
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one parent's direct session descendants as continuable subagents
|
||||
* without loading or resuming an Agent.
|
||||
* @param ctx - context carrying the optional session-query service.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation forwarded where supported and
|
||||
* observed around every query await.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or
|
||||
* the caller cancels the scan.
|
||||
*/
|
||||
export async function listChildren(
|
||||
ctx: Context,
|
||||
parentSessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SubagentListEntry[]> {
|
||||
const query = ctx.get('sessionQuery')
|
||||
if (query === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires session query (load a dsh-session-query backend)',
|
||||
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// Keep runtime values behind the listing-only boundary so ordinary
|
||||
// subagent imports and control operations do not evaluate the optional peer.
|
||||
const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
|
||||
assertListingNotCancelled(signal)
|
||||
const trace = await runListingQuery(
|
||||
() => query.traceSession(parentSessionId, signal),
|
||||
signal,
|
||||
)
|
||||
const entries: SubagentListEntry[] = []
|
||||
for (const node of trace.descendants) {
|
||||
const entry = await inspectChild(query, queryRuntime, parentSessionId, node.session, signal)
|
||||
// Cancellation can race the inspection's last checkpoint or diagnostic
|
||||
// mapping; do not return success or begin another candidate afterward.
|
||||
assertListingNotCancelled(signal)
|
||||
if (entry !== undefined) entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
|
||||
async function inspectChild(
|
||||
query: SessionQueryService,
|
||||
queryRuntime: SessionQueryRuntime,
|
||||
parentSessionId: SessionId,
|
||||
candidate: SessionRecord,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SubagentListEntry | undefined> {
|
||||
const childId = candidate.header.id
|
||||
try {
|
||||
const records = await runListingQuery(() => query.listEvents(childId), signal)
|
||||
// Only the child's own suffix: a fork seed may replay an ancestor's
|
||||
// descriptor without making the fork itself a continuable subagent.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
.map(record => record.seq)
|
||||
if (descriptorSeqs.length === 0) return undefined
|
||||
if (descriptorSeqs.length > 1) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this exact-read sequence exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await runListingQuery(
|
||||
() => query.readEvent({ sessionId: childId, seq }, signal),
|
||||
signal,
|
||||
)
|
||||
queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
|
||||
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let descriptor: ReturnType<typeof foldSubagentDescriptor>
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([window.target])
|
||||
} catch {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
|
||||
}
|
||||
return {
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
label: descriptor.label,
|
||||
status: candidate.live ? 'running' : 'complete',
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
|
||||
if (reason === undefined) throw error
|
||||
return { kind: 'diagnostic', id: childId, reason }
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a listing scan at its next cancellation checkpoint. */
|
||||
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one session-query operation between cancellation checkpoints. Query
|
||||
* implementations may reject with their own abort error after observing the
|
||||
* forwarded signal; cancellation remains a stable subagent failure.
|
||||
*/
|
||||
async function runListingQuery<T>(
|
||||
operation: () => Promise<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<T> {
|
||||
assertListingNotCancelled(signal)
|
||||
try {
|
||||
const result = await operation()
|
||||
assertListingNotCancelled(signal)
|
||||
return result
|
||||
} catch (error: unknown) {
|
||||
assertListingNotCancelled(signal)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-child query failure to a fixed diagnostic. Configuration errors
|
||||
* and unrecognized failures remain operation failures.
|
||||
*/
|
||||
function perChildDiagnosticReason(
|
||||
error: unknown,
|
||||
SessionQueryError: SessionQueryRuntime['SessionQueryError'],
|
||||
): 'corrupt' | 'unavailable' | undefined {
|
||||
if (!(error instanceof SessionQueryError)) return undefined
|
||||
switch (error.code) {
|
||||
case 'SESSION_QUERY_SESSION_NOT_FOUND':
|
||||
case 'SESSION_QUERY_EVENT_NOT_FOUND':
|
||||
case 'SESSION_QUERY_PERSISTENCE_FAILED':
|
||||
return 'unavailable'
|
||||
case 'SESSION_QUERY_INVALID_SURFACE':
|
||||
case 'SESSION_QUERY_SOURCE_CONFLICT':
|
||||
return 'corrupt'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -102,6 +102,29 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
|
||||
}
|
||||
|
||||
describe('SubagentService.listChildren', () => {
|
||||
it('lists through session query without the Activation continuation runtime', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
|
||||
const parentId = SessionId('query-only-parent')
|
||||
ctx.sessions.create(parentId)
|
||||
const childId = SessionId('query-only-child')
|
||||
const child = ctx.sessions.create(childId, { meta: { parentSession: parentId } })
|
||||
child.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
child.append('subagent/descriptor', descriptorPayload('query-only child'))
|
||||
|
||||
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
|
||||
{ kind: 'child', id: childId, label: 'query-only child', status: 'running' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud before any work when session query is not loaded', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
@@ -408,6 +431,49 @@ describe('SubagentService.listChildren', () => {
|
||||
expect(inspected).toBe(1)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.traceSession = (_sessionId, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query trace aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled exact read')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.readEvent = (_request, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query read aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('stops after a per-child read when the signal aborts mid-inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled mid-read')
|
||||
@@ -436,8 +502,8 @@ describe('SubagentService.listChildren', () => {
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => {
|
||||
// The read fails with a diagnostic-mapped code while the caller aborts:
|
||||
// the loop's post-inspection checkpoint must fail the scan rather than
|
||||
// return a one-diagnostic success.
|
||||
// cancellation normalization must fail the scan rather than return a
|
||||
// one-diagnostic success.
|
||||
controller.abort()
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
|
||||
it('loads ordinary subagent operations without evaluating the optional query package', async () => {
|
||||
vi.doMock('@deepseek-ai/dsh-session-query', () => {
|
||||
throw new Error('optional session-query runtime was loaded eagerly')
|
||||
})
|
||||
|
||||
const subagent = await import('../src/index.ts')
|
||||
|
||||
expect(subagent.SubagentService).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user