refactor(subagent): unify service errors

This commit is contained in:
Dudu-0223
2026-08-02 04:34:17 +08:00
committed by imccyu
parent 0821ceb033
commit 112e25bb05
9 changed files with 41 additions and 37 deletions
@@ -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 .agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md
2026-07-26-merge-subagent-control-service.md: a5ce673997502ef6fbd7c66ff4e94e301d4192ba
2026-07-26-merge-subagent-control-service.zh.md: cf867ab444438b7ee62cde68a3d53e83c3d049d1
2026-07-26-merge-subagent-control-service.md: eb8a76dd4dfc5f06deb67608a67c12e061819286
2026-07-26-merge-subagent-control-service.zh.md: 6599606634a1933790949e8a66df906a0bb9def0
@@ -12,6 +12,8 @@ Continuable-child orchestration originally lived in a separate `ctx.subagentCont
`SubagentService` is the only public service. It retains raw `start(name, request)` and `resume(name, request)` for callers that own run collection, and exposes `startContinuable(spec)` and `sendMessage(...)` for durable Task-backed activations. The standalone `@deepseek-ai/dsh-subagent-control` package and `ctx.subagentControl` key are deleted; the optional `@deepseek-ai/dsh-tool-subagent-control` package injects `ctx.subagents` directly.
The merged service and its providers expose one `SubagentError` taxonomy. Stable codes distinguish provider lookup and capability failures from continuation routing, authorization, cancellation, persistence, and delivery failures; the removed service does not retain a separate error class.
The continuation implementation remains an internal manager rather than expanding the provider registry's core state. `SubagentService` creates it through `ctx.inject(['tasks', 'agents'], ...)`, so the injected Cordis child fiber owns its Task completion listener and teardown effects. Loading the provider registry does not require Tasks or persistence. The manager exists only while Tasks and Agents are available, and each continuation operation resolves session persistence at the point it needs durability. Disposing that fiber cancels and settles active continuations before releasing their associations.
`startContinuable` remains distinct from raw `start` because it has a different ownership and timing contract: it allocates the durable child id, creates the Task, and returns both ids synchronously while startup continues inside the Task. Raw `start` instead awaits provider publication and transfers a holder-owned run. Folding the method onto `start` through flags or return unions would broaden the low-level contract and create more change than keeping the existing explicit entry.
@@ -12,6 +12,8 @@ Status: implemented
`SubagentService` 是唯一的公开服务。它为自行收集 run 的调用方保留底层 `start(name, request)``resume(name, request)`,并公开 `startContinuable(spec)``sendMessage(...)`,用于具备持久性、由 Task 支撑的激活。系统删除独立的 `@deepseek-ai/dsh-subagent-control` 包(package)和 `ctx.subagentControl` 键;可选的 `@deepseek-ai/dsh-tool-subagent-control` 包则直接注入 `ctx.subagents`
合并后的服务及其提供方公开一套 `SubagentError` 分类体系。稳定错误码把提供方查找失败和功能检查失败,与继续执行路由、鉴权、取消、持久化和送达失败区分开来;已移除的服务不保留单独的错误类。
继续执行的实现仍是内部管理器,不会扩展提供方注册表的核心状态。`SubagentService` 通过 `ctx.inject(['tasks', 'agents'], ...)` 创建该管理器,因此注入的 Cordis child fiber 拥有自身的 Task 完成监听器和拆卸 effect。加载提供方注册表不要求 Task 或持久化。只有 Task 和 Agent 可用时,该管理器才会存在;每项继续执行操作都在需要持久性时解析会话持久化服务。dispose(资源释放)该 fiber 会先取消并结算活跃的继续执行,再释放其关联。
`startContinuable` 与底层 `start` 保持分离,因为二者的所有权与时序契约不同:前者分配持久化 child id、创建 Task,并同步返回两个 id,而启动过程继续在 Task 内运行;底层 `start` 则等待提供方发布,并移交一个由持有方负责的 run。若通过标志或返回值联合类型将该方法并入 `start`,会扩大底层契约,改动反而多于保留现有的显式入口。
+1 -1
View File
@@ -2030,7 +2030,7 @@ async resume(name: string, request: SubagentResumeRequest): Promise<SubagentRun>
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageSource](../core-data-structures/core.md) · [SendMessageResult](../core-data-structures/subagent.md) · [SessionId](../core-data-structures/core.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentResumeRequest](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
Source: [`packages/subagent/subagent/src/index.ts:206`](../../packages/subagent/subagent/src/index.ts)
Source: [`packages/subagent/subagent/src/index.ts:198`](../../packages/subagent/subagent/src/index.ts)
## `ctx.subprocess` — `SubprocessService` (abstract seam)
@@ -35,7 +35,7 @@
{"type":"tool/call","seq":33,"time":1785517567431,"data":{"turn":1,"step":3,"callId":"call_follow_up","name":"send_message","arguments":"{\"subagent_id\": \"22222222-2222-4222-8222-222222222222\", \"message\": \"Please continue.\"}"}}
{"type":"tool/result","seq":34,"time":1785517567438,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_follow_up"},"content":[{"type":"tool-result","toolCallId":"call_follow_up","content":[{"type":"text","text":"message started task subagent-2 continuing subagent 22222222-2222-4222-8222-222222222222"}],"isError":false}],"role":"user","id":"6a7a5d22-1172-4a10-9230-ec12aed58e5e"}},"sourceEventSeqs":[33],"surfaceOp":"append"}
{"type":"step/end","seq":35,"time":1785517567438,"data":{"turn":1,"step":3}}
{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"}
{"type":"user/message","seq":36,"time":1785517567444,"data":{"content":[{"type":"text","text":"background task subagent-2 (subagent: Please continue.) finished [status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]. Read its output with task_output."}],"source":{"kind":"plugin","plugin":"tool-tasks"},"role":"user","id":"32644e35-5ea1-4d29-8ef6-e09eb813781c"},"surfaceOp":"append"}
{"type":"step/start","seq":37,"time":1785517567444,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":38,"time":1789000000037,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":39,"time":1789000000038,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_collect_2","name":"task_output","argumentsDelta":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}}
@@ -44,7 +44,7 @@
{"type":"assistant/chunk","seq":42,"time":1785517567453,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":43,"time":1785517567453,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"31137fd0-a07c-4d5f-b847-6dbb33e86305"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
{"type":"tool/call","seq":44,"time":1785517567454,"data":{"turn":1,"step":4,"callId":"call_collect_2","name":"task_output","arguments":"{\"task_id\": \"subagent-2\", \"wait\": true}"}}
{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentControlError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
{"type":"tool/result","seq":45,"time":1785517567460,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_collect_2"},"content":[{"type":"tool-result","toolCallId":"call_collect_2","content":[{"type":"text","text":"(no new output)\n[status: failed, SubagentError: subagent \"22222222-2222-4222-8222-222222222222\" is unavailable]"}],"isError":false}],"role":"user","id":"21807217-0a28-4369-868c-c2480398e883"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
{"type":"step/end","seq":46,"time":1785517567460,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":47,"time":1785517567467,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":48,"time":1789000000047,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+13 -20
View File
@@ -24,6 +24,7 @@ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor
import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts'
import type { SubagentService } from './index.ts'
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
import { SubagentError } from './error.ts'
/** Attribution for a model coordinator's follow-up to one of its children. */
export interface CoordinatorMessageSource {
@@ -38,14 +39,6 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
/** Typed error for continuation routing, authorization, and delivery failures. */
export class SubagentControlError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentControlError'
}
}
/** What a caller asks for when starting a continuable background child. */
export interface ContinuableStartSpec {
/** The `ctx.subagents` provider to establish the child on. */
@@ -285,13 +278,13 @@ export class SubagentContinuationManager {
if (live === undefined) return
const activation = this.activations.get(childId)
if (activation === undefined) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" has a live agent outside continuation ownership; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
}
if (activation.run !== undefined && activation.run.localAgent !== live) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" registry agent is not the associated activation's agent; the message was not delivered`,
'OWNERSHIP_CONFLICT',
)
@@ -309,12 +302,12 @@ export class SubagentContinuationManager {
const taskId = activation.taskId
/* v8 ignore next 3 -- the install and Task registration share one synchronous frame, so an observed activation carries its Task id. */
if (taskId === undefined) {
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
}
// Owner-session authorization plus the live status for admission.
const snapshot = this.ctx.tasks.get(taskId, parent)
if (snapshot.status !== 'running') {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" task ${taskId} is ${snapshot.status}; the message was not delivered `
+ '— retry after it settles to start the next activation',
'NOT_DELIVERED',
@@ -322,10 +315,10 @@ export class SubagentContinuationManager {
}
const run = activation.run
if (run === undefined) {
throw new SubagentControlError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
throw new SubagentError(`subagent "${childId}" activation is starting; the message was not delivered`, 'NOT_DELIVERED')
}
if (run.steer === undefined) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" provider does not accept live delivery; the message was not delivered`,
'NOT_DELIVERED',
)
@@ -336,7 +329,7 @@ export class SubagentContinuationManager {
// Confirmed steering lost the race with request admission. Deliberately no
// cold-resume fallback here: that would attach the message to a turn the
// caller did not observe.
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" stopped before delivery; the message was not delivered`,
'NOT_DELIVERED',
{ cause: error },
@@ -364,18 +357,18 @@ export class SubagentContinuationManager {
try {
loaded = await persistence.load(childId)
} catch (error: unknown) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" is unavailable`,
'NOT_RESUMABLE',
{ cause: error },
)
}
// The persistence seam takes no signal; recheck before any child work.
if (signal.aborted) throw new SubagentControlError('subagent resume was cancelled during lookup', 'CANCELLED')
if (signal.aborted) throw new SubagentError('subagent resume was cancelled during lookup', 'CANCELLED')
// Authorize the persisted header before folding: only the direct parent
// recorded at creation may continue this child.
if (loaded.meta.parentSession !== parent.id) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" belongs to another parent session`,
'UNAUTHORIZED',
)
@@ -385,7 +378,7 @@ export class SubagentContinuationManager {
// itself a continuable child.
const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
if (descriptor === undefined) {
throw new SubagentControlError(
throw new SubagentError(
`subagent "${childId}" has no supported continuation state and cannot be resumed; `
+ 'do not retry send_message with this id',
'NOT_RESUMABLE',
@@ -477,7 +470,7 @@ export class SubagentContinuationManager {
private requirePersistence(): SessionPersistence {
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new SubagentControlError(
throw new SubagentError(
'continuable subagents require session persistence (load a dsh-session-persistence backend)',
'PERSISTENCE_UNAVAILABLE',
)
+15
View File
@@ -0,0 +1,15 @@
/**
* Typed failures shared by subagent service and provider operations.
*
* @module @deepseek-ai/dsh-subagent
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Typed failure for the subagent seam. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentError'
}
}
+2 -10
View File
@@ -32,7 +32,6 @@ import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -45,6 +44,7 @@ import type {
SubagentStartRequest,
} from './types.ts'
import { SubagentRunId } from './types.ts'
import { SubagentError } from './error.ts'
import SubagentContinuationManager from './continuation.ts'
import type {
ContinuableStart,
@@ -71,10 +71,10 @@ export {
SUBAGENT_DESCRIPTOR_VERSION,
} from './descriptor.ts'
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
export { SubagentError } from './error.ts'
export {
runOutcome,
settleRun,
SubagentControlError,
} from './continuation.ts'
export type {
ContinuableStart,
@@ -194,14 +194,6 @@ export interface SubagentRunEndInfo {
readonly lastAssistantMessage?: ContentBlock[]
}
/** Typed error for provider lookup, registration, and capability failures. */
export class SubagentError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'SubagentError'
}
}
/** Named provider registry with raw and Task-backed continuation operations. */
export class SubagentService extends Service {
private providers = new Map<string, SubagentProvider>()
@@ -20,7 +20,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
import SubagentService, {
runOutcome,
settleRun,
SubagentControlError,
SubagentError,
SUBAGENT_DESCRIPTOR_VERSION,
} from '../src/index.ts'
@@ -625,7 +625,7 @@ describe('SubagentService.sendMessage', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(SubagentControlError)
.rejects.toThrow(SubagentError)
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
.rejects.toThrow(/outside continuation ownership.*not delivered/)
await handle.dispose()