refactor(subagent): narrow continuation interface
This commit is contained in:
@@ -11,7 +11,12 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-fork'
|
||||
@@ -59,7 +64,7 @@ class ForkProvider implements SubagentProvider {
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
start(request: SubagentProviderStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(request, {
|
||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
||||
@@ -68,7 +73,7 @@ class ForkProvider implements SubagentProvider {
|
||||
})
|
||||
}
|
||||
|
||||
resume(request: SubagentResumeRequest) {
|
||||
resume(request: SubagentProviderResumeRequest) {
|
||||
// Cold resume loads the child's OWN persisted transcript, which already
|
||||
// contains the completed-turn prefix captured at initial creation; it
|
||||
// never forks the parent's newer history again.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
|
||||
README.md: 525760ccc413bb46ca5ea3a37e610a3ff58b8068
|
||||
README.zh.md: 4d02e2bb89f38e449dfd8bf31a39b79891f6a69e
|
||||
README.md: 8d266e93021285e27e7819386a4de9c33492a796
|
||||
README.zh.md: 79450a32a7ecc3cf2a442524a2680614b3f28ed0
|
||||
|
||||
@@ -14,7 +14,7 @@ The driver follows this sequence:
|
||||
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
|
||||
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the prepended one-shot `agent/prompt-submit` contribution. It appends the `subagent/descriptor` event before downstream prompt admission can block or throw; allowed admission opens the initial turn afterward, while the final required checkpoint persists the descriptor even when no turn opens.
|
||||
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
|
||||
5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior.
|
||||
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result and require its participation result to be `true`. This final confirmation retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior.
|
||||
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.
|
||||
|
||||
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
|
||||
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时;对于可继续请求,还会前置安装一次性的 `agent/prompt-submit` 贡献。它会在下游 prompt admission 能够阻止请求或抛出异常之前追加 `subagent/descriptor` 事件;admission 获准后才会开启初始轮次,即使没有轮次开启,最终的必需检查点仍会持久化该描述符。
|
||||
4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
|
||||
5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。
|
||||
5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`,并要求其参与结果为 `true`。这次最终确认会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。
|
||||
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
@@ -15,10 +15,10 @@ import { createUserMessage, errorChain, type ContentBlock, type MessageSource }
|
||||
import { assertSubagentMaxDepth, delegationDepthOf, SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubagentDescriptorData,
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
|
||||
@@ -108,7 +108,7 @@ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescripto
|
||||
* @returns a ready holder-owned run.
|
||||
*/
|
||||
export async function startInProcessRun(
|
||||
request: SubagentStartRequest,
|
||||
request: SubagentProviderStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): Promise<SubagentRun> {
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
@@ -197,10 +197,10 @@ export async function startInProcessRun(
|
||||
* (loaded through the parent's persistence-backed registry `resume`), so a
|
||||
* fork child never re-forks current parent history; the persisted header
|
||||
* remains authoritative for lineage and the delegation-depth floor.
|
||||
* @param request - the fully resolved resume request from the low-level service.
|
||||
* @param request - the fully resolved resume request from the continuation manager.
|
||||
* @returns a fresh ready holder-owned run for this activation.
|
||||
*/
|
||||
export async function resumeInProcessRun(request: SubagentResumeRequest): Promise<SubagentRun> {
|
||||
export async function resumeInProcessRun(request: SubagentProviderResumeRequest): Promise<SubagentRun> {
|
||||
if (request.signal.aborted) throw prePublicationAbort()
|
||||
const descriptor = request.descriptor
|
||||
const agentOptions: AgentOptions = {
|
||||
@@ -269,7 +269,10 @@ function driveTurn(
|
||||
await child.whenIdle()
|
||||
if (durability === 'required') {
|
||||
try {
|
||||
await child.ctx.sessions.flushRequired(child.session)
|
||||
const participated = await child.ctx.sessions.flush(child.session)
|
||||
if (!participated) {
|
||||
throw new Error(`session "${child.id}" required durability checkpoint has no registered listener`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted) {
|
||||
throw new SubagentError(
|
||||
|
||||
@@ -73,6 +73,21 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => {
|
||||
const { ctx } = await setup([textResponse('driver answer')])
|
||||
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' })
|
||||
const run = await startInProcessRun({
|
||||
...request(parent),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
}, {})
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
expect(child.session.header.cwd).toBe('/workspace')
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects a continuable child when no durability listener is registered', async () => {
|
||||
const { parent } = await setup([textResponse('driver answer')])
|
||||
|
||||
@@ -346,9 +361,9 @@ describe('startInProcessRun', () => {
|
||||
acceptsNextStep: false,
|
||||
ctx: {
|
||||
sessions: {
|
||||
flushRequired: () => {
|
||||
flush: () => {
|
||||
flushes++
|
||||
return Promise.resolve()
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
},
|
||||
} as unknown as Context,
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentResumeRequest, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import { resumeInProcessRun, startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
|
||||
export const name = 'subagent-spawn'
|
||||
@@ -40,14 +45,14 @@ class SpawnProvider implements SubagentProvider {
|
||||
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
start(request: SubagentStartRequest) {
|
||||
start(request: SubagentProviderStartRequest) {
|
||||
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
||||
// depth, drives the one-shot (including the structured capture when the
|
||||
// request carries an outputSchema), and maps the result.
|
||||
return startInProcessRun(request, {})
|
||||
}
|
||||
|
||||
resume(request: SubagentResumeRequest) {
|
||||
resume(request: SubagentProviderResumeRequest) {
|
||||
// Cold resume reconstructs the persisted child from its own transcript
|
||||
// under the live parent scope; the shared driver drives the follow-up turn.
|
||||
return resumeInProcessRun(request)
|
||||
|
||||
@@ -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: c0811eb3bd76543b4a07e7242772e38bd51db67e
|
||||
README.zh.md: 6d346ee423af8e242c58486164cdef85d241b53d
|
||||
README.md: a484352c486c067058bef806bad3bcd7623cf6cc
|
||||
README.zh.md: 9a750d5dfa22c5df199cdb22e7de6207841d2803
|
||||
|
||||
@@ -21,19 +21,18 @@ Multiple providers may coexist under different names. This lets a deployment exp
|
||||
|
||||
## Service API
|
||||
|
||||
`SubagentService` has seven main operations:
|
||||
`SubagentService` has six main operations:
|
||||
|
||||
| Member | Meaning |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | Register one trusted same-process implementation by name. Registration is effect-scoped; removing it prevents new starts but does not revoke runs already returned to callers. Duplicate names fail loud. |
|
||||
| `getProvider(name)` | Return the provider, or `undefined` when absent. |
|
||||
| `list()` | Return provider names in insertion order. |
|
||||
| `start(name, request)` | Validate requested capabilities and semantic values, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. |
|
||||
| `resume(name, request)` | Capability-checked raw dispatch to `provider.resume?()` with the same run lifecycle observation as `start`; the caller owns descriptor lookup, authorization, and collection. |
|
||||
| `start(name, request)` | Validate an ordinary caller request, then await the provider until a real child is ready. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every partial startup resource. Continuation state cannot enter through this operation. |
|
||||
| `startContinuable(spec)` | Allocate a durable child id and register its initial Task-backed activation. Requires `ctx.tasks`, `ctx.agents`, session persistence, and a resumable provider. |
|
||||
| `sendMessage(parent, childId, message, source, signal)` | Steer the current activation or start a new Task that cold-resumes the durable child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after it reaches quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
|
||||
| `followup(parent, childId, content, { source, signal })` | Follow up with a durable child, matching `Agent.followup()` terminology. It steers the current activation or starts a new Task that cold-resumes the child. Aborting `signal` while live delivery awaits admission cancels the shared activation and rejects after quiescence. Requires `ctx.tasks` and `ctx.agents`; cold resume also requires session persistence. |
|
||||
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. 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, set a child persona, or carry a resolved `continuation` (the control-allocated stable child id plus its durable descriptor), which requires the provider's `resume` capability.
|
||||
`SubagentStartRequest.signal` is required and is the canonical cancellation channel. 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. Only the internal continuation manager can add a stable child id and durable descriptor to the provider-facing `SubagentProviderStartRequest`; cold provider resume is likewise private dispatch after descriptor lookup and parent authorization.
|
||||
|
||||
Same-process requests, descriptors, results, and event payloads are trusted typed values borrowed as immutable. The service does not clone or freeze them; serialization and hostile-input validation belong at actual process, worker, persistence, and model boundaries.
|
||||
|
||||
@@ -60,13 +59,13 @@ The seam owns the depth vocabulary shared by implementations and consumers: the
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation.
|
||||
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce partial resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path. `provider.resume?(request)` shares the same contract for a resumed activation; only the continuation manager dispatches it.
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. For a continuable activation, a completed result also confirms that the provider made its final state durable; a failed required checkpoint rejects as infrastructure rather than publishing unconfirmed output. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. A continuable start publishes exactly the service-allocated `continuation.sessionId`. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
|
||||
The service emits `subagent/start` only after `start()` or `resume()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
|
||||
The service emits `subagent/start` only after an ordinary start or privately dispatched provider resume has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
|
||||
@@ -21,19 +21,18 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 服务 API
|
||||
|
||||
`SubagentService` 有七个主要操作:
|
||||
`SubagentService` 有六个主要操作:
|
||||
|
||||
| 成员 | 含义 |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | 按名称注册一个可信的同进程实现。注册受 effect 作用域约束;移除注册会阻止新的启动,但不会撤销已返回给调用方的运行。重复名称会立即失败。 |
|
||||
| `getProvider(name)` | 返回提供方;不存在时返回 `undefined`。 |
|
||||
| `list()` | 按插入顺序返回提供方名称。 |
|
||||
| `start(name, request)` | 校验请求的能力和语义值,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。 |
|
||||
| `resume(name, request)` | 在功能检查后将底层请求分发给 `provider.resume?()`,并沿用与 `start` 相同的运行生命周期观察;描述符查找、授权与收集由调用方负责。 |
|
||||
| `start(name, request)` | 校验普通调用方请求,然后等待提供方,直到真实子 agent 就绪。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有局部启动资源。此操作不允许传入继续执行状态。 |
|
||||
| `startContinuable(spec)` | 分配持久化子 agent id,并注册其初始的由 Task 支撑的激活。要求 `ctx.tasks`、`ctx.agents`、会话持久化及可恢复的提供方。 |
|
||||
| `sendMessage(parent, childId, message, source, signal)` | 引导当前激活,或启动新 Task 从持久化存储恢复子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 |
|
||||
| `followup(parent, childId, content, { source, signal })` | 对持久化子 agent 执行后续操作,术语与 `Agent.followup()` 一致。它会引导当前激活,或启动新 Task 从持久化存储恢复该子 agent。若在在线投递等待准入期间中止 `signal`,则会取消共享激活,并在其完全停稳后拒绝该调用。要求 `ctx.tasks` 和 `ctx.agents`;从持久化存储恢复还要求会话持久化。 |
|
||||
|
||||
`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具、设置子 agent persona,或携带已解析的 `continuation`(由控制层分配的稳定子 agent id 及其持久化描述符);后者要求提供方具备 `resume` 功能。
|
||||
`SubagentStartRequest.signal` 是必填项,也是规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消实时子 agent。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。只有内部继续执行管理器才能把稳定子 agent id 和持久化描述符添加到面向提供方的 `SubagentProviderStartRequest`;从持久化存储恢复时,向提供方的请求同样只会在查找描述符并授权父级后由内部管理器分发。
|
||||
|
||||
同进程请求、描述符、结果和事件 payload 都是以不可变方式借用的可信类型值。服务不会克隆或冻结它们;序列化和不可信输入校验属于真实的进程、worker、持久化和模型边界。
|
||||
|
||||
@@ -60,13 +59,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 所有权与生命周期
|
||||
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约。
|
||||
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使局部资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`。`provider.resume?(request)` 对恢复后的激活采用相同契约;只有继续执行管理器会分发该请求。
|
||||
|
||||
`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。对于可继续激活,完成的结果还会确认提供方已使其最终状态具备持久性;必需检查点失败会作为基础设施故障拒绝,而不会发布未经确认的输出。`dispose()` 是幂等的,会取消剩余工作,并等待子 agent 资源完全停稳。
|
||||
|
||||
本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,并把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header。可继续启动会准确发布由服务分配的 `continuation.sessionId`。远程提供方则生成父级作用域的生命周期 id,并返回 `localAgent: undefined`。
|
||||
|
||||
服务只会在 `start()` 或 `resume()` 兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
|
||||
服务只会在普通启动或内部向提供方分发的恢复操作兑现后发出 `subagent/start`。它在同步通知前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`。这对事件共享服务生成的 `runId`;其 `local` 标志取自提供方准确 `localAgent` 的快照,因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
|
||||
|
||||
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
|
||||
|
||||
|
||||
@@ -21,8 +21,13 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest } from './types.ts'
|
||||
import type { SubagentService } from './index.ts'
|
||||
import type {
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
SubagentResult,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import type { TaskHooks, TaskId, TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
import { SubagentError } from './error.ts'
|
||||
|
||||
@@ -50,10 +55,10 @@ export interface ContinuableStartSpec {
|
||||
* durable descriptor, then supplies the Task-owned cancellation signal and
|
||||
* `continuation` itself.
|
||||
*/
|
||||
readonly request: Omit<SubagentStartRequest, 'signal' | 'continuation'>
|
||||
readonly request: Omit<SubagentStartRequest, 'signal'>
|
||||
}
|
||||
|
||||
/** Identities returned by {@link SubagentContinuationManager.startContinuable}. */
|
||||
/** Identities returned by a continuable start. */
|
||||
export interface ContinuableStart {
|
||||
/** The durable child session id, stable across activations. */
|
||||
readonly childId: SessionId
|
||||
@@ -62,16 +67,29 @@ export interface ContinuableStart {
|
||||
}
|
||||
|
||||
/**
|
||||
* How {@link SubagentContinuationManager.sendMessage} delivered a message:
|
||||
* Options for following up with one continuable child.
|
||||
*/
|
||||
export interface SubagentFollowupOptions {
|
||||
/** Durable attribution retained on either live or resumed delivery. */
|
||||
readonly source: MessageSource
|
||||
/** Caller cancellation for a live-delivery admission wait. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* How a continuable follow-up was routed:
|
||||
* `steered` joined the running activation's existing Task without creating a
|
||||
* Task of its own; `started` created a fresh Task that cold-resumes the
|
||||
* durable child with the message. Failure is an exception, never a result —
|
||||
* an undelivered message throws.
|
||||
* durable child with the content. Failure is an exception, never a result —
|
||||
* undelivered content throws.
|
||||
*/
|
||||
export type SendMessageResult =
|
||||
export type SubagentFollowupResult =
|
||||
| { readonly route: 'steered'; readonly taskId: TaskId }
|
||||
| { readonly route: 'started'; readonly taskId: TaskId }
|
||||
|
||||
type StartProvider = (name: string, request: SubagentProviderStartRequest) => Promise<SubagentRun>
|
||||
type ResumeProvider = (request: SubagentProviderResumeRequest) => Promise<SubagentRun>
|
||||
|
||||
/**
|
||||
* One child's current process-local activation: its Task and, after provider
|
||||
* publication, its run. Installed before any provider or persistence await
|
||||
@@ -98,7 +116,7 @@ interface ActiveActivation {
|
||||
* @param result - child terminal result.
|
||||
* @returns outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome {
|
||||
function runOutcome(result: SubagentResult): TaskOutcome {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: finalText(result.output) }
|
||||
@@ -154,7 +172,7 @@ function finalText(blocks: ContentBlock[]): string {
|
||||
/**
|
||||
* The continuable-subagent orchestration service. Tool schema and UI adapters
|
||||
* are consumers of this one contract: parent and human messages route through
|
||||
* {@link sendMessage} and share one activation result and cancellation
|
||||
* {@link followup} and share one activation result and cancellation
|
||||
* boundary, while foreground one-shot delegation keeps calling
|
||||
* `ctx.subagents.start()` directly.
|
||||
*/
|
||||
@@ -164,7 +182,8 @@ export class SubagentContinuationManager {
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly subagents: SubagentService,
|
||||
private readonly startProvider: StartProvider,
|
||||
private readonly resumeProvider: ResumeProvider,
|
||||
) {
|
||||
// Terminal publication is one of the two removal conditions. The exact
|
||||
// Task id pins the resolution to this activation, never a later same-child one.
|
||||
@@ -224,7 +243,7 @@ export class SubagentContinuationManager {
|
||||
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
|
||||
})
|
||||
const taskId = this.startActivation(childId, spec.label, request.parent, signal =>
|
||||
this.subagents.start(spec.provider, {
|
||||
this.startProvider(spec.provider, {
|
||||
...request,
|
||||
signal,
|
||||
continuation: { sessionId: childId, descriptor },
|
||||
@@ -233,7 +252,7 @@ export class SubagentContinuationManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one message to a known continuable child: steer its running
|
||||
* Follow up with a known continuable child: steer its running
|
||||
* activation, or cold-resume the durable session into a fresh Task-backed
|
||||
* activation. The two routes are reported distinctly so timing-dependent
|
||||
* routing is observable. Rejection means the message was NOT delivered — in
|
||||
@@ -246,28 +265,36 @@ export class SubagentContinuationManager {
|
||||
* @param parent - the live parent agent sending the message (model tool or
|
||||
* human adapter); Task access is authorized by its session id.
|
||||
* @param childId - the stable child session id.
|
||||
* @param message - the user-role content to deliver.
|
||||
* @param source - caller-supplied attribution retained across either route.
|
||||
* @param signal - caller cancellation. During live delivery, abort cancels
|
||||
* the shared activation and rejects only after it reaches quiescence.
|
||||
* @returns whether the message `steered` the existing Task or `started` a new one.
|
||||
* @param content - the user-role content to deliver.
|
||||
* @param options - caller attribution and cancellation. During live delivery,
|
||||
* abort cancels the shared activation and rejects only after quiescence.
|
||||
* @returns whether the content `steered` the existing Task or `started` a new one.
|
||||
*/
|
||||
async sendMessage(
|
||||
async followup(
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
signal: AbortSignal,
|
||||
): Promise<SendMessageResult> {
|
||||
content: ContentBlock[],
|
||||
options: SubagentFollowupOptions,
|
||||
): Promise<SubagentFollowupResult> {
|
||||
this.assertOwnership(childId)
|
||||
const activation = this.activations.get(childId)
|
||||
if (activation !== undefined) {
|
||||
return {
|
||||
route: 'steered',
|
||||
taskId: await this.steerActivation(activation, parent, childId, message, source, signal),
|
||||
taskId: await this.steerActivation(
|
||||
activation,
|
||||
parent,
|
||||
childId,
|
||||
content,
|
||||
options.source,
|
||||
options.signal,
|
||||
),
|
||||
}
|
||||
}
|
||||
return { route: 'started', taskId: this.resumeActivation(parent, childId, message, source) }
|
||||
return {
|
||||
route: 'started',
|
||||
taskId: this.resumeActivation(parent, childId, content, options.source),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,7 +449,7 @@ export class SubagentContinuationManager {
|
||||
'NOT_RESUMABLE',
|
||||
)
|
||||
}
|
||||
return this.subagents.resume(descriptor.provider, {
|
||||
return this.resumeProvider({
|
||||
sessionId: childId,
|
||||
prompt: message,
|
||||
source,
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Raw `start` and `resume` remain collection-agnostic provider dispatch.
|
||||
* When `ctx.tasks` and `ctx.agents` are available, the same service also binds
|
||||
* an internal continuation manager for durable child ids, descriptor lookup,
|
||||
* Task-backed activations, and steer-or-resume delivery. Persistence remains
|
||||
* optional and is required only when a continuation operation is called.
|
||||
* Public operations express caller intent: `start` returns one ready owned run,
|
||||
* `startContinuable` starts a Task-backed durable child, and `followup` routes
|
||||
* later content without exposing whether the child is live. Provider resume
|
||||
* dispatch stays private because only the continuation manager holds the
|
||||
* resolved descriptor and authorization facts.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -32,14 +32,15 @@ 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 type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
@@ -49,7 +50,8 @@ import SubagentContinuationManager from './continuation.ts'
|
||||
import type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
SendMessageResult,
|
||||
SubagentFollowupOptions,
|
||||
SubagentFollowupResult,
|
||||
} from './continuation.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
@@ -58,8 +60,9 @@ export type {
|
||||
SubagentCapabilities,
|
||||
SubagentContinuation,
|
||||
SubagentProvider,
|
||||
SubagentProviderResumeRequest,
|
||||
SubagentProviderStartRequest,
|
||||
SubagentResult,
|
||||
SubagentResumeRequest,
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
SubagentStopReason,
|
||||
@@ -72,15 +75,13 @@ export {
|
||||
} from './descriptor.ts'
|
||||
export type { SubagentDescriptorData, SubagentDescriptorInput } from './descriptor.ts'
|
||||
export { SubagentError } from './error.ts'
|
||||
export {
|
||||
runOutcome,
|
||||
settleRun,
|
||||
} from './continuation.ts'
|
||||
export { settleRun } from './continuation.ts'
|
||||
export type {
|
||||
ContinuableStart,
|
||||
ContinuableStartSpec,
|
||||
CoordinatorMessageSource,
|
||||
SendMessageResult,
|
||||
SubagentFollowupOptions,
|
||||
SubagentFollowupResult,
|
||||
} from './continuation.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-agent' {
|
||||
@@ -202,7 +203,11 @@ export class SubagentService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subagents')
|
||||
ctx.inject(['tasks', 'agents'], (childCtx: Context) => {
|
||||
const manager = new SubagentContinuationManager(childCtx, this)
|
||||
const manager = new SubagentContinuationManager(
|
||||
childCtx,
|
||||
(name, request) => this.startProvider(name, request),
|
||||
request => this.resumeProvider(request),
|
||||
)
|
||||
this.continuations = manager
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore else -- one injected binding owns the slot until its fiber disposes. */
|
||||
@@ -222,24 +227,24 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message to a continuable child by steering its live activation
|
||||
* or cold-resuming a fresh Task-backed activation.
|
||||
* Follow up with a continuable child. A live child is steered and fulfillment
|
||||
* confirms request admission; an idle child immediately returns a fresh Task
|
||||
* whose descriptor lookup, authorization, and cold resume may later fail.
|
||||
* @param parent - live direct parent authorizing the operation.
|
||||
* @param childId - durable child session id.
|
||||
* @param message - user-role content to deliver.
|
||||
* @param source - durable caller attribution.
|
||||
* @param signal - caller cancellation; while live delivery awaits admission,
|
||||
* abort cancels the shared activation so the wait reaches quiescence.
|
||||
* @param content - user-role content to deliver.
|
||||
* @param options - durable attribution and caller cancellation; aborting a
|
||||
* live-delivery wait cancels the shared activation and awaits quiescence.
|
||||
* @returns the existing steered Task or newly started Task.
|
||||
* @throws when continuation services are unavailable or live delivery is not admitted.
|
||||
*/
|
||||
sendMessage(
|
||||
followup(
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
message: ContentBlock[],
|
||||
source: MessageSource,
|
||||
signal: AbortSignal,
|
||||
): Promise<SendMessageResult> {
|
||||
return this.requireContinuations().sendMessage(parent, childId, message, source, signal)
|
||||
content: ContentBlock[],
|
||||
options: SubagentFollowupOptions,
|
||||
): Promise<SubagentFollowupResult> {
|
||||
return this.requireContinuations().followup(parent, childId, content, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +299,16 @@ export class SubagentService extends Service {
|
||||
* @returns the ready holder-owned run.
|
||||
*/
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
// A provider request is structurally assignable to the caller shape. Clear
|
||||
// its wider field so only startContinuable can supply service-owned state.
|
||||
return this.startProvider(name, { ...request, continuation: undefined })
|
||||
}
|
||||
|
||||
/** Validate and dispatch one ordinary or service-resolved provider start. */
|
||||
private async startProvider(
|
||||
name: string,
|
||||
request: SubagentProviderStartRequest,
|
||||
): Promise<SubagentRun> {
|
||||
const provider = this.expectProvider(name)
|
||||
this.assertCapabilities(provider, request)
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
@@ -308,17 +323,9 @@ export class SubagentService extends Service {
|
||||
return this.observeRun(name, request.parent, await provider.start(request))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a persisted continuable child through the named provider's
|
||||
* `resume` capability, with the same run lifecycle observation as
|
||||
* {@link start}. The internal continuation manager has already loaded the
|
||||
* child, folded its descriptor, and authorized the parent; this method owns
|
||||
* only capability-checked dispatch.
|
||||
* @param name - the provider recorded in the child's descriptor.
|
||||
* @param request - the fully resolved resume request.
|
||||
* @returns the fresh holder-owned run for the resumed activation.
|
||||
*/
|
||||
async resume(name: string, request: SubagentResumeRequest): Promise<SubagentRun> {
|
||||
/** Dispatch one authorized provider resume and observe its run lifecycle. */
|
||||
private async resumeProvider(request: SubagentProviderResumeRequest): Promise<SubagentRun> {
|
||||
const name = request.descriptor.provider
|
||||
const provider = this.expectProvider(name)
|
||||
if (provider.resume === undefined) {
|
||||
throw new SubagentError(
|
||||
|
||||
@@ -43,8 +43,8 @@ export interface SubagentCapabilities {
|
||||
/**
|
||||
* What a caller asks for when starting a subagent. The tool layer builds this
|
||||
* from the model's `{ description, prompt }` plus its own config; the service
|
||||
* validates {@link SubagentCapabilities} against the named provider, then
|
||||
* passes it to {@link SubagentProvider.start}.
|
||||
* validates {@link SubagentCapabilities} against the named provider and
|
||||
* resolves a {@link SubagentProviderStartRequest} for dispatch.
|
||||
*/
|
||||
export interface SubagentStartRequest {
|
||||
/** Content delivered as the child's user message. */
|
||||
@@ -93,20 +93,29 @@ export interface SubagentStartRequest {
|
||||
* persona (strict `{{…}}` interpolation against the registered variables).
|
||||
*/
|
||||
readonly persona?: string
|
||||
/**
|
||||
* Continuable-child intent, resolved by `ctx.subagents` before start.
|
||||
* The provider MUST publish exactly `sessionId` as the child identity
|
||||
* instead of allocating one internally, and MUST append the snapshotted
|
||||
* `descriptor` as the child's turn-enclosed `subagent/descriptor` event
|
||||
* before its first request. Requires {@link SubagentProvider.resume} (the
|
||||
* continuation capability); the service rejects the request otherwise.
|
||||
*/
|
||||
readonly continuation?: SubagentContinuation
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved continuable-child identity and durable composition record a
|
||||
* continuation caller attaches to a start request.
|
||||
* Provider-facing start request after the service resolves optional
|
||||
* continuation state. Ordinary callers use {@link SubagentStartRequest}; only
|
||||
* the Task-backed continuation path can attach a stable child identity and
|
||||
* durable descriptor.
|
||||
*/
|
||||
export interface SubagentProviderStartRequest extends SubagentStartRequest {
|
||||
/**
|
||||
* Continuable-child state resolved by `ctx.subagents` before provider dispatch.
|
||||
* The provider MUST publish exactly `sessionId` as the child identity
|
||||
* instead of allocating one internally, and MUST append the snapshotted,
|
||||
* model-hidden `subagent/descriptor` before the initial prompt is admitted.
|
||||
* Requires {@link SubagentProvider.resume} (the
|
||||
* continuation capability); the service rejects the request otherwise.
|
||||
*/
|
||||
readonly continuation?: SubagentContinuation | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved continuable-child identity and durable composition record the
|
||||
* service attaches before provider dispatch.
|
||||
*/
|
||||
export interface SubagentContinuation {
|
||||
/** Service-allocated stable child session id, published verbatim. */
|
||||
@@ -116,14 +125,13 @@ export interface SubagentContinuation {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller asks for when resuming a persisted continuable child. The
|
||||
* continuation manager loads the child log, folds and authorizes its descriptor,
|
||||
* and passes this fully resolved request to
|
||||
* {@link SubagentService.resume}, which dispatches to
|
||||
* Provider-facing request for reconstructing a persisted continuable child.
|
||||
* The continuation manager loads the child log, folds and authorizes its
|
||||
* descriptor, then privately dispatches this resolved request to
|
||||
* {@link SubagentProvider.resume}. The provider reconstructs the declared
|
||||
* composition under the live parent's scope and drives one turn with `prompt`.
|
||||
*/
|
||||
export interface SubagentResumeRequest {
|
||||
export interface SubagentProviderResumeRequest {
|
||||
/** The persisted child session id to resume. */
|
||||
readonly sessionId: SessionId
|
||||
/** The follow-up message that starts the resumed activation's turn. */
|
||||
@@ -257,16 +265,16 @@ export interface SubagentProvider {
|
||||
* fulfillment, the provider owns and cleans all partial resources before this
|
||||
* promise rejects. Ownership transfers to the caller only on fulfillment.
|
||||
*/
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
start(request: SubagentProviderStartRequest): Promise<SubagentRun>
|
||||
/**
|
||||
* OPTIONAL (continuation capability): reconstruct a persisted continuable
|
||||
* child from its own transcript and declared descriptor, drive one
|
||||
* follow-up turn, and return a fresh run. Method presence is the capability
|
||||
* — the service rejects `resume` dispatch and continuable starts on
|
||||
* — the service rejects continuable starts and cold-resume dispatch on
|
||||
* providers without it. Same publication contract as {@link start}: if
|
||||
* reconstruction fails or `request.signal` aborts before fulfillment, the
|
||||
* provider rolls its creation transaction back to quiescence before
|
||||
* rejecting; after fulfillment the same signal cancels the published run.
|
||||
*/
|
||||
resume?(request: SubagentResumeRequest): Promise<SubagentRun>
|
||||
resume?(request: SubagentProviderResumeRequest): Promise<SubagentRun>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, HarnessError, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentService, {
|
||||
runOutcome,
|
||||
settleRun,
|
||||
SubagentError,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
@@ -121,14 +120,17 @@ const coordinatorSource = {
|
||||
} as const
|
||||
const testSendSignal = new AbortController().signal
|
||||
|
||||
function sendMessage(
|
||||
function followup(
|
||||
ctx: Context,
|
||||
parent: Agent,
|
||||
childId: SessionId,
|
||||
content: ReturnType<typeof message>,
|
||||
signal: AbortSignal = testSendSignal,
|
||||
) {
|
||||
return ctx.subagents.sendMessage(parent, childId, content, { kind: 'user' }, signal)
|
||||
return ctx.subagents.followup(parent, childId, content, {
|
||||
source: { kind: 'user' },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
describe('SubagentService.startContinuable', () => {
|
||||
@@ -145,6 +147,24 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a continuable Task before dispatch when its provider has no resume capability', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const start = vi.fn(async () => { throw new Error('must not dispatch') })
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'one-shot',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start,
|
||||
})
|
||||
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent, 'one-shot'))
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('does not support continuable children')
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails the Task when persistence detaches before the activation completes', async () => {
|
||||
const releaseResponse = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
@@ -270,7 +290,7 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('maxDepth')
|
||||
// The unmaterialized child id is reported unavailable on later use.
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('hello?'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('hello?'))
|
||||
expect(followUp.route).toBe('started')
|
||||
const failed = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(failed.status).toBe('failed')
|
||||
@@ -313,7 +333,22 @@ describe('SubagentService.startContinuable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubagentService.sendMessage', () => {
|
||||
describe('SubagentService.followup', () => {
|
||||
it('fails a cold-resume Task when the provider loses its resume capability', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first answer')])
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
|
||||
const provider = ctx.subagents.getProvider('spawn')!
|
||||
Object.defineProperty(provider, 'resume', { value: undefined, configurable: true })
|
||||
|
||||
const next = await followup(ctx, parent, started.childId, message('continue'))
|
||||
const snapshot = await waitTerminal(ctx, next.taskId, parent)
|
||||
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('does not support resuming persisted children')
|
||||
})
|
||||
|
||||
it('omits undeclared model selectors and rejects a provider without live delivery', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const result = Promise.withResolvers<{
|
||||
@@ -341,14 +376,14 @@ describe('SubagentService.sendMessage', () => {
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
expect(descriptor).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, provider: 'no-steer' })
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('join')))
|
||||
await expect(followup(ctx, parent, started.childId, message('join')))
|
||||
.rejects.toThrow(/provider does not accept live delivery/)
|
||||
|
||||
let terminalDeliveryError: unknown
|
||||
let terminalDelivery: Promise<void> | undefined
|
||||
ctx.tasks.onTaskDone((snapshot) => {
|
||||
if (snapshot.id !== started.taskId) return
|
||||
terminalDelivery = sendMessage(ctx, parent, started.childId, message('after terminal')).then(
|
||||
terminalDelivery = followup(ctx, parent, started.childId, message('after terminal')).then(
|
||||
() => undefined,
|
||||
(error: unknown) => {
|
||||
terminalDeliveryError = error
|
||||
@@ -390,7 +425,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent, 'mismatched-local'))
|
||||
await waitPublishedRun(ctx, started.childId)
|
||||
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('join')))
|
||||
await expect(followup(ctx, parent, started.childId, message('join')))
|
||||
.rejects.toThrow(/registry agent is not the associated activation's agent/)
|
||||
result.resolve({ output: [{ type: 'text', text: 'done' }], stopReason: 'completed' })
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
@@ -419,12 +454,11 @@ describe('SubagentService.sendMessage', () => {
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const delivery = ctx.subagents.sendMessage(
|
||||
const delivery = ctx.subagents.followup(
|
||||
parent,
|
||||
started.childId,
|
||||
message('also consider Y'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
{ source: coordinatorSource, signal: testSendSignal },
|
||||
)
|
||||
releaseFirst()
|
||||
const delivered = await delivery
|
||||
@@ -450,7 +484,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller already cancelled')
|
||||
|
||||
await expect(sendMessage(
|
||||
await expect(followup(
|
||||
ctx,
|
||||
parent,
|
||||
started.childId,
|
||||
@@ -492,12 +526,11 @@ describe('SubagentService.sendMessage', () => {
|
||||
})
|
||||
await startedTool.promise
|
||||
|
||||
const delivery = ctx.subagents.sendMessage(
|
||||
const delivery = ctx.subagents.followup(
|
||||
parent,
|
||||
started.childId,
|
||||
message('follow-up that terminal policy rejects'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
{ source: coordinatorSource, signal: testSendSignal },
|
||||
)
|
||||
releaseTool.resolve(undefined)
|
||||
await expect(delivery).rejects.toThrow(/message was not delivered/)
|
||||
@@ -515,12 +548,11 @@ describe('SubagentService.sendMessage', () => {
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
|
||||
const followUp = await ctx.subagents.sendMessage(
|
||||
const followUp = await ctx.subagents.followup(
|
||||
parent,
|
||||
started.childId,
|
||||
message('and then?'),
|
||||
coordinatorSource,
|
||||
testSendSignal,
|
||||
{ source: coordinatorSource, signal: testSendSignal },
|
||||
)
|
||||
expect(followUp.route).toBe('started')
|
||||
expect(followUp.taskId).not.toBe(started.taskId)
|
||||
@@ -559,7 +591,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
expect(descriptor?.data.persona).toBe('You are the resumable child.')
|
||||
expect(descriptor?.data.toolFilter).toEqual({ deny: [] })
|
||||
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('continue'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('continue'))
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
expect(snapshot.status).toBe('completed')
|
||||
// The resumed child's system prompt carried the persona back.
|
||||
@@ -588,7 +620,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
parent.followup(createUserMessage({ content: message('parent question two'), source: { kind: 'user' } }))
|
||||
await parent.whenIdle()
|
||||
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('follow up'))
|
||||
await waitTerminal(ctx, followUp.taskId, parent)
|
||||
const resumed = await ctx.sessionPersistence.load(started.childId)
|
||||
// The persisted seed boundary is unchanged and parent turn two is absent.
|
||||
@@ -604,7 +636,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('go on'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('go on'))
|
||||
|
||||
const childAgents: Agent[] = []
|
||||
const stop = ctx.on('agent/created', (agent: Agent) => {
|
||||
@@ -624,7 +656,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
const started = ctx.subagents.startContinuable(startSpec(otherParent))
|
||||
await waitTerminal(ctx, started.taskId, otherParent)
|
||||
|
||||
const attempt = await sendMessage(ctx, parent, started.childId, message('mine now'))
|
||||
const attempt = await followup(ctx, parent, started.childId, message('mine now'))
|
||||
expect(attempt.route).toBe('started')
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
@@ -643,7 +675,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
await handle.agent.whenIdle()
|
||||
await handle.dispose()
|
||||
|
||||
const attempt = await sendMessage(ctx, parent, SessionId('plain-child'), message('continue?'))
|
||||
const attempt = await followup(ctx, parent, SessionId('plain-child'), message('continue?'))
|
||||
const snapshot = await waitTerminal(ctx, attempt.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain(
|
||||
@@ -653,9 +685,9 @@ describe('SubagentService.sendMessage', () => {
|
||||
|
||||
it('derives fallback and bounded labels for resumed activations', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const blank = await sendMessage(ctx, parent, SessionId('blank-child'), message(' '))
|
||||
const blank = await followup(ctx, parent, SessionId('blank-child'), message(' '))
|
||||
const longText = 'x'.repeat(100)
|
||||
const long = await sendMessage(ctx, parent, SessionId('long-child'), message(longText))
|
||||
const long = await followup(ctx, parent, SessionId('long-child'), message(longText))
|
||||
|
||||
expect(ctx.tasks.get(blank.taskId, parent).label).toBe('subagent follow-up')
|
||||
expect(ctx.tasks.get(long.taskId, parent).label).toBe(`${'x'.repeat(79)}…`)
|
||||
@@ -673,9 +705,9 @@ describe('SubagentService.sendMessage', () => {
|
||||
meta: { parentSession: parent.id },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(SubagentError)
|
||||
await expect(sendMessage(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
await expect(followup(ctx, parent, SessionId('rogue-child'), message('hello')))
|
||||
.rejects.toThrow(/outside continuation ownership.*not delivered/)
|
||||
await handle.dispose()
|
||||
})
|
||||
@@ -686,9 +718,10 @@ describe('SubagentService.sendMessage', () => {
|
||||
const { ctx, parent } = await setup([textResponse('quick answer'), textResponse('unused')])
|
||||
let releaseDispose!: () => void
|
||||
const disposeGate = new Promise<void>((resolve) => { releaseDispose = resolve })
|
||||
const realStart = ctx.subagents.start.bind(ctx.subagents)
|
||||
ctx.subagents.start = async (name, request) => {
|
||||
const run = await realStart(name, request)
|
||||
const provider = ctx.subagents.getProvider('spawn')!
|
||||
const realStart = provider.start.bind(provider)
|
||||
provider.start = async (request) => {
|
||||
const run = await realStart(request)
|
||||
const realDispose = run.dispose.bind(run)
|
||||
return {
|
||||
...run,
|
||||
@@ -716,13 +749,13 @@ describe('SubagentService.sendMessage', () => {
|
||||
|
||||
// Confirmed steering finds the settled child, fails loud, and does NOT start
|
||||
// a cold resume within this call.
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('too late?')))
|
||||
await expect(followup(ctx, parent, started.childId, message('too late?')))
|
||||
.rejects.toThrow(/not delivered/)
|
||||
expect(ctx.tasks.list(parent).map(task => task.id)).toEqual([started.taskId])
|
||||
releaseDispose()
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
// AFTER the Task settles, retry legitimately starts the next activation.
|
||||
const retry = await sendMessage(ctx, parent, started.childId, message('retry'))
|
||||
const retry = await followup(ctx, parent, started.childId, message('retry'))
|
||||
expect(retry.route).toBe('started')
|
||||
await waitTerminal(ctx, retry.taskId, parent)
|
||||
})
|
||||
@@ -731,7 +764,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second')])
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await waitTerminal(ctx, started.taskId, parent)
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('more'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('more'))
|
||||
const other = ctx.agentLoop.create(SessionId('intruder'), { provider: 'mock', model: 'mock' })
|
||||
expect(() => ctx.tasks.get(followUp.taskId, other)).toThrow(/belongs to another session/)
|
||||
})
|
||||
@@ -750,7 +783,7 @@ describe('SubagentService.sendMessage', () => {
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const followUp = await sendMessage(ctx, parent, started.childId, message('follow up'))
|
||||
const followUp = await followup(ctx, parent, started.childId, message('follow up'))
|
||||
expect(ctx.tasks.kill(followUp.taskId, parent)).toBe('requested')
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, followUp.taskId, parent)
|
||||
@@ -772,11 +805,11 @@ describe('SubagentService.sendMessage', () => {
|
||||
return realLoad(id)
|
||||
}
|
||||
|
||||
const first = await sendMessage(ctx, parent, started.childId, message('first follow-up'))
|
||||
const first = await followup(ctx, parent, started.childId, message('first follow-up'))
|
||||
expect(first.route).toBe('started')
|
||||
// The association is installed synchronously, so the competing caller
|
||||
// observes the pending activation instead of starting a duplicate resume.
|
||||
await expect(sendMessage(ctx, parent, started.childId, message('second follow-up')))
|
||||
await expect(followup(ctx, parent, started.childId, message('second follow-up')))
|
||||
.rejects.toThrow(/not delivered/)
|
||||
releaseLoad()
|
||||
const snapshot = await waitTerminal(ctx, first.taskId, parent)
|
||||
@@ -830,15 +863,21 @@ describe('service disposal with live activations', () => {
|
||||
})
|
||||
|
||||
describe('outcome mapping helpers', () => {
|
||||
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
|
||||
it.each([
|
||||
['completed', { status: 'completed', output: 'partial' }],
|
||||
['aborted', { status: 'killed' }],
|
||||
['error', { status: 'failed', detail: 'error' }],
|
||||
['max-tokens', { status: 'failed', detail: 'max-tokens' }],
|
||||
['refusal', { status: 'failed', detail: 'refusal' }],
|
||||
['paused', { status: 'failed', detail: 'paused' }],
|
||||
] as const)('settleRun maps the %s stop reason onto its Task outcome', async (stopReason, expected) => {
|
||||
const output = [{ type: 'text' as const, text: 'partial' }]
|
||||
expect(runOutcome({ output, stopReason: 'completed' })).toEqual({ status: 'completed', output: 'partial' })
|
||||
expect(runOutcome({ output, stopReason: 'aborted' })).toEqual({ status: 'killed' })
|
||||
expect(runOutcome({ output, stopReason: 'error' })).toEqual({ status: 'failed', detail: 'error' })
|
||||
expect(runOutcome({ output, stopReason: 'max-tokens' })).toEqual({ status: 'failed', detail: 'max-tokens' })
|
||||
expect(runOutcome({ output, stopReason: 'refusal' })).toEqual({ status: 'failed', detail: 'refusal' })
|
||||
// Merge-extensible: an unknown reason is failed-with-detail, never success.
|
||||
expect(runOutcome({ output, stopReason: 'paused' as never })).toEqual({ status: 'failed', detail: 'paused' })
|
||||
await expect(settleRun({
|
||||
id: SessionId('child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output, stopReason: stopReason as never }),
|
||||
dispose: () => Promise.resolve(),
|
||||
})).resolves.toEqual(expected)
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import SubagentService, {
|
||||
assertSubagentMaxDepth,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
type SubagentProviderStartRequest,
|
||||
type SubagentResult,
|
||||
type SubagentRun,
|
||||
type SubagentStartRequest,
|
||||
@@ -37,6 +38,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
|
||||
class StubProvider implements SubagentProvider {
|
||||
readonly inheritsParentContext = false
|
||||
startCount = 0
|
||||
lastRequest: SubagentProviderStartRequest | undefined
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
@@ -47,8 +49,9 @@ class StubProvider implements SubagentProvider {
|
||||
},
|
||||
) {}
|
||||
|
||||
async start(request: SubagentStartRequest): Promise<SubagentRun> {
|
||||
async start(request: SubagentProviderStartRequest): Promise<SubagentRun> {
|
||||
this.startCount += 1
|
||||
this.lastRequest = request
|
||||
return {
|
||||
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
||||
localAgent: undefined,
|
||||
@@ -102,27 +105,23 @@ describe('SubagentService', () => {
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('rejects continuable start and resume when the provider has no resume capability', async () => {
|
||||
it('keeps provider continuation state out of raw start and exposes no raw resume operation', async () => {
|
||||
const { subagents } = await service()
|
||||
subagents.registerProvider(new StubProvider('one-shot'))
|
||||
const provider = new StubProvider('one-shot')
|
||||
subagents.registerProvider(provider)
|
||||
const descriptor = snapshotSubagentDescriptor({ provider: 'one-shot' })
|
||||
const sessionId = SessionId('continuable-child')
|
||||
const parent = fakeParent()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(subagents.start('one-shot', baseRequest({
|
||||
parent,
|
||||
signal,
|
||||
const providerRequest: SubagentProviderStartRequest = {
|
||||
...baseRequest({ parent, signal }),
|
||||
continuation: { sessionId, descriptor },
|
||||
}))).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
|
||||
await expect(subagents.resume('one-shot', {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'continue' }],
|
||||
source: { kind: 'user' },
|
||||
parent,
|
||||
signal,
|
||||
descriptor,
|
||||
})).rejects.toMatchObject({ code: 'UNSUPPORTED_CAPABILITY' })
|
||||
}
|
||||
await subagents.start('one-shot', providerRequest)
|
||||
|
||||
expect(provider.lastRequest?.continuation).toBeUndefined()
|
||||
expect('resume' in subagents).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects Task-backed continuation operations when their runtime services are absent', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md
|
||||
README.md: 0f1eb7a966689d1540c47f41e2f3fa89d2011d2b
|
||||
README.zh.md: bd140f93f7338a6b1f0e89a285b273080cc5d3cb
|
||||
README.md: 44fbd44b035ce283e404c491d9fa143a08b71127
|
||||
README.zh.md: 3fa1d1e543d1d390975c3aab16504954f283c2f4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.sendMessage()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
|
||||
The optional, globally named `send_message` tool: a thin adapter over `ctx.subagents.followup()`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers one shared follow-up tool, so multiple delegation tools never register duplicate global controls. Its presence does not determine whether a delegation tool starts continuable work.
|
||||
|
||||
The tool performs no lifecycle routing. It attributes every follow-up as `{ kind: 'coordinator', senderSessionId: parent.id }`; the subagent service preserves that source while deciding between live delivery to the running activation's existing Task and a fresh Task that cold-resumes the durable child. The tool forwards its execution signal, so cancellation while live delivery awaits admission cancels the shared activation and settles only after the child reaches quiescence. The tool renders which route was taken and the relevant Task id. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 工具:`ctx.subagents.sendMessage()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
|
||||
可选的全局具名 `send_message` 工具:`ctx.subagents.followup()` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包(package)只注册一个共享后续操作工具,因此多个委派工具绝不会重复注册全局控制工具。是否加载本工具不会决定委派工具是否启动可继续工作。
|
||||
|
||||
本工具不执行生命周期路由。它将每条后续消息的来源标记为 `{ kind: 'coordinator', senderSessionId: parent.id }`;subagent 服务会保留该来源,并在向运行中激活的现有 Task 在线投递消息与创建新 Task、从持久化存储恢复子 agent 之间做出选择。本工具会转发其执行信号,因此,若在在线投递等待准入期间取消,则会取消共享激活,并仅在子 agent 完全停稳后结算。本工具会渲染实际采用的路由及相关 Task id。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The globally named `send_message` tool: a thin model-facing adapter over
|
||||
* `ctx.subagents.sendMessage()`. It performs no lifecycle routing of its
|
||||
* `ctx.subagents.followup()`. It performs no lifecycle routing of its
|
||||
* own — steer-or-resume orchestration belongs to the subagent service — and it
|
||||
* lives apart from the provider-bound `@deepseek-ai/dsh-tool-subagent`
|
||||
* instances so multiple delegation tools share one control tool.
|
||||
@@ -60,21 +60,23 @@ export function apply(ctx: Context): void {
|
||||
: `message started task ${value.taskId} continuing subagent ${args.subagent_id}`,
|
||||
}],
|
||||
},
|
||||
execute(args, exec) {
|
||||
async execute(args, exec) {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// Non-agent callers have no session to authorize Task access with.
|
||||
throw new Error('send_message requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
|
||||
const result = ctx.subagents.sendMessage(
|
||||
const result = await ctx.subagents.followup(
|
||||
parent,
|
||||
SessionId(args.subagent_id),
|
||||
message,
|
||||
{ kind: 'coordinator', senderSessionId: parent.id },
|
||||
exec.signal,
|
||||
{
|
||||
source: { kind: 'coordinator', senderSessionId: parent.id },
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
return Promise.resolve(result)
|
||||
return result
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -106,9 +106,9 @@ describe('dsh-tool-subagent-control', () => {
|
||||
// Reach past the tool into the subagent service to fake a running route
|
||||
// deterministically: the tool is a thin adapter, so its steered wording is
|
||||
// what this test pins.
|
||||
ctx.subagents.sendMessage = async (agent, _childId, message, messageSource) => {
|
||||
ctx.subagents.followup = async (agent, _childId, message, options) => {
|
||||
steered = (message[0] as { text: string }).text
|
||||
source = messageSource
|
||||
source = options.source
|
||||
return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
|
||||
}
|
||||
const result = await callTool(ctx, 'send_message', {
|
||||
@@ -130,9 +130,9 @@ describe('dsh-tool-subagent-control', () => {
|
||||
})
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const deliveryStarted: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const sendMessage = ctx.subagents.sendMessage.bind(ctx.subagents)
|
||||
ctx.subagents.sendMessage = (agent, childId, message, source, signal) => {
|
||||
const delivery = sendMessage(agent, childId, message, source, signal)
|
||||
const followup = ctx.subagents.followup.bind(ctx.subagents)
|
||||
ctx.subagents.followup = (agent, childId, message, options) => {
|
||||
const delivery = followup(agent, childId, message, options)
|
||||
deliveryStarted.resolve()
|
||||
return delivery
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user