Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/core/agent/tests/agent.spec.ts # packages/subagent/subagent/README.md # packages/subagent/subagent/src/index.ts # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json
This commit is contained in:
@@ -1,26 +1,37 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees.
|
||||
The `subagent` tool lets the model delegate one self-contained task and collect the child's final output. It is a thin consumer of `ctx.subagents`; changing the configured provider changes the transport without changing the model-facing execution contract.
|
||||
|
||||
## Provider selection is config, not model-facing
|
||||
## Provider selection
|
||||
|
||||
This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt, run_in_background? }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one.
|
||||
|
||||
## The description states the provider's context contract
|
||||
The description is derived from `provider.inheritsParentContext`: spawn and ACP tell the model to provide a standalone prompt, while fork says the child already sees completed conversation turns. The plugin follows `subagent/provider-added` and `subagent/provider-removed`, so concurrent Cordis plugin loading does not create a registration-order dependency.
|
||||
|
||||
The tool description and the `prompt` parameter description are DERIVED from the bound provider's `inheritsParentContext` (`providerWording`): a fresh-context provider (spawn, ACP) gets the standalone-prompt wording ("it does not see this conversation"), an inheriting provider (fork) tells the model the child already sees the conversation's completed turns and its prompt should state only what is new. Because the description is fixed at tool registration, the tool **mirrors the provider's lifecycle** (`subagent/provider-added`/`-removed`): it registers when the bound provider is (or becomes) available and unregisters when the provider goes away — no load-order requirement (the cordis Loader starts sibling entries concurrently, so "listed first" never guaranteed "registered first"), and an HMR reload of the backend re-derives the wording from the fresh provider. While the provider is absent the tool simply does not exist (a `ctx.logger` note records the wait; a typo'd provider name shows up as a tool that never materializes).
|
||||
## Lifecycle
|
||||
|
||||
| Config key | Meaning |
|
||||
Foreground `execute` passes the tool execution's abort signal when present, otherwise supplies an inert signal to satisfy the required `SubagentStartRequest.signal`. It awaits `ctx.subagents.start(...)`, then awaits `run.result` inside a `try/finally` that always calls `run.dispose()`. The selected signal therefore covers startup and live execution, while disposal guarantees quiescence on success, failure, and abort.
|
||||
|
||||
A non-`completed` stop reason becomes an `isError` tool result; partial child output is never reported as success. With `run_in_background`, an independent task-owned signal covers both asynchronous startup and the ready child, while collection moves to the generic task tools.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). |
|
||||
| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. |
|
||||
| `enableRunInBackground` | Expose `run_in_background` in this instance's schema (default `true`). Disabled, the parameter is absent entirely AND a caller that forces the key anyway is refused at execution time (the arg validator allows undeclared keys) — delegation through this instance stays strictly synchronous. |
|
||||
| `agentOptions` | Default per-child `{ model? }` applied to every spawned child. (No per-child persona: the deployment persona is a context-wide section every agent shares.) |
|
||||
| `agentOptions` | Default child agent options, currently including `model`. |
|
||||
| `persona` | Per-child persona; requires provider `persona` capability. |
|
||||
| `toolFilter` | Per-child global-tool restriction; requires provider `toolFilter` capability. |
|
||||
| `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. |
|
||||
|
||||
## Foreground lifecycle (synchronous collect)
|
||||
|
||||
`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
`execute` awaits a ready run from the configured provider and then **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The required request signal is the canonical cancellation path across startup and live execution. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success.
|
||||
|
||||
## Background delegation (a generic task)
|
||||
|
||||
`run_in_background: true` refuses an already-aborted `exec.signal`, starts the run, registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks` (`@deepseek-ai/dsh-tasks`), and returns `started background subagent task <id>` — the parent keeps working and collects/stops the child through the generic `task_output`/`task_list`/`task_kill` tools (`@deepseek-ai/dsh-tool-tasks`). The tool-call signal is deliberately NOT wired to the run after the id is returned; cancellation belongs to `task_kill` (its logged `reason` is forwarded to `run.cancel`) and the runtime's owner-disposal cleanup. The task is final-output-only (no incremental transcript — the child session remains the detailed trace), and its `done` settles only after `run.dispose()` (child quiescence), so owner disposal cannot resolve before the child is actually gone. Mapping (exported for tests): `runOutcome` — `completed` carries the final text as the task output; `aborted` → `killed`; `error`/`max-tokens`/`refusal`/unknown → `failed` with the reason as status-line detail — and `settleRun`, which disposes on both result paths and contains an infrastructure rejection as `failed`. A missing `ctx.tasks` fails the call loud (`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`). See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
`run_in_background: true` refuses an already-aborted `exec.signal`, synchronously registers `{ kind: 'subagent', label: description, owner: parent, cancel, done }` with `ctx.tasks`, and returns `started background subagent task <id>`. The starter immediately calls async `ctx.subagents.start()` with an independent `AbortController`; `task_kill` and owner-scope teardown abort that signal whether startup is still pending or the child is ready. The task is final-output-only, and `done` settles only after startup rollback or `run.dispose()` reaches quiescence. Mapping: `runOutcome` turns `completed` into final output, `aborted` into `killed`, and other terminal reasons into `failed`; `settleRun` contains infrastructure and disposal failures. A missing task runtime fails loud. See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
|
||||
`toolFilter` changes the child's visible global tool layer; it is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
* — there is no provider/type parameter in the model-facing schema. The model
|
||||
* sees only `{ description, prompt }` (plus `run_in_background` when enabled).
|
||||
*
|
||||
* The tool DESCRIPTION is derived from the bound provider's context contract
|
||||
* ({@link providerWording}): a fresh-context provider (spawn, ACP) gets the
|
||||
* standalone-prompt wording, an inheriting provider (fork) tells the model the
|
||||
* child already sees the conversation's completed turns. The tool MIRRORS the
|
||||
* The tool DESCRIPTION is derived from the bound provider's conversation-history
|
||||
* descriptor ({@link providerWording}): a fresh-conversation provider (spawn,
|
||||
* ACP) gets the standalone-prompt wording, while a seeded-conversation provider
|
||||
* (fork) tells the model the child already sees the conversation's completed
|
||||
* turns. This descriptor says nothing about Cordis scope, services, tools, or
|
||||
* authority. The tool MIRRORS the
|
||||
* provider's lifecycle via `subagent/provider-added`/`-removed` — it registers
|
||||
* when the provider is (or becomes) available and unregisters when the
|
||||
* provider goes away — so no load-order requirement exists and an HMR reload
|
||||
@@ -44,8 +46,9 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
|
||||
@@ -74,20 +77,70 @@ export interface Config {
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults. There is no
|
||||
* per-child persona: the deployment persona (the system-prompt plugin's
|
||||
* `persona` config) is a context-wide section every agent shares.
|
||||
* Omitted fields fall back to the child loop's own defaults.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Per-child persona applied to every child this tool spawns: a scoped
|
||||
* `deployment:persona` section shadowing the deployment's persona for the
|
||||
* child alone. Requires the bound provider's `persona` capability
|
||||
* (in-process backends support it; a request against one that doesn't is
|
||||
* rejected at start). Omitted ⇒ the child renders the deployment persona.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Tool scoping applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.toolFilter`): the named global tools vanish from
|
||||
* the child's prompt AND refuse to execute. Requires the provider's
|
||||
* `toolFilter` capability. Unknown names fail the spawn loudly. Note the
|
||||
* child otherwise sees every global tool — including this delegation tool
|
||||
* itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
|
||||
* bounds recursion.
|
||||
*/
|
||||
toolFilter?: {
|
||||
/** Global tool names the child keeps; everything else is removed. */
|
||||
allow?: string[]
|
||||
/** Global tool names removed from the child. */
|
||||
deny?: string[]
|
||||
}
|
||||
/**
|
||||
* Recursion cap applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
|
||||
* than this in the delegation tree is rejected. Requires the provider's
|
||||
* `depthLimit` capability. Must be a non-negative safe integer and is
|
||||
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
|
||||
* deployments that expose this tool to children).
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
// Omitted-object discipline (see the toolFilter note below): without the
|
||||
// forced default an omitted `agentOptions` materializes `{}`, which reads as
|
||||
// present — the request would carry `agentOptions: {}` and the presence
|
||||
// check in execute() could never be false through config.
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
}),
|
||||
}).default(undefined as unknown as { model: string }),
|
||||
persona: z.string(),
|
||||
// A schemastery object materializes {} (with [] for nested arrays) when the
|
||||
// key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e.
|
||||
// deny-everything, silently. Force the omitted key to stay absent (the same
|
||||
// shape discipline as SystemPrompt's toolOrder); the cast is needed because
|
||||
// .default() expects the object type.
|
||||
// The NESTED arrays get the same treatment as the object itself: a partial
|
||||
// filter ({deny: […]}) must not materialize allow: [] beside it — an empty
|
||||
// allow-list means deny-EVERYTHING, so the materialized default would turn
|
||||
// a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only
|
||||
// children) survives, since only the omitted key defaults to undefined.
|
||||
toolFilter: z.object({
|
||||
allow: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
deny: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
}).default(undefined as unknown as { allow: string[]; deny: string[] }),
|
||||
maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -134,20 +187,21 @@ function stopReasonError(result: SubagentResult): string | undefined {
|
||||
* @param result - the child's terminal result.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome { switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: outputText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
export function runOutcome(result: SubagentResult): TaskOutcome {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return { status: 'completed', output: outputText(result.output) }
|
||||
case 'aborted':
|
||||
return { status: 'killed' }
|
||||
case 'error':
|
||||
case 'max-tokens':
|
||||
case 'refusal':
|
||||
return { status: 'failed', detail: result.stopReason }
|
||||
// Merge-extensible union: an unknown terminal reason is a failure with
|
||||
// the raw reason as detail, never partial output as success.
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,33 +209,42 @@ export function runOutcome(result: SubagentResult): TaskOutcome { switch (resul
|
||||
* dispose the run (the owned child agent/session is released on every path),
|
||||
* and only then report the mapped outcome — so the task registry's `done`,
|
||||
* and therefore owner-disposal cleanup, cannot resolve before the child is
|
||||
* actually gone. A rejected `run.result` (infrastructure fault — no
|
||||
* SubagentResult exists) reports `failed` with the error as detail rather
|
||||
* than rejecting the producer contract. Exported for tests.
|
||||
* actually gone. A rejected `run.result` or `run.dispose()` reports `failed`
|
||||
* with the error as detail rather than rejecting the producer contract; when
|
||||
* both fail, both independent failures are preserved. Exported for tests.
|
||||
* @param run - the live background run to settle and release.
|
||||
* @returns the task outcome, after the run's resources are released.
|
||||
*/
|
||||
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
|
||||
let outcome: TaskOutcome
|
||||
try {
|
||||
return runOutcome(await run.result)
|
||||
outcome = runOutcome(await run.result)
|
||||
} catch (error: unknown) {
|
||||
return { status: 'failed', detail: String(error) }
|
||||
} finally {
|
||||
await run.dispose()
|
||||
outcome = { status: 'failed', detail: String(error) }
|
||||
}
|
||||
try {
|
||||
await run.dispose()
|
||||
} catch (error: unknown) {
|
||||
const prefix = outcome.detail === undefined ? '' : `${outcome.detail}; `
|
||||
return { status: 'failed', detail: `${prefix}dispose failed: ${String(error)}` }
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing wording per context contract ({@link SubagentProvider.inheritsParentContext}).
|
||||
* Model-facing wording from the provider's conversation-history descriptor
|
||||
* ({@link SubagentProvider.inheritsParentContext}).
|
||||
* A fresh child needs a standalone prompt; a forked child already sees the
|
||||
* conversation's completed turns — telling the model to restate everything
|
||||
* (or, worse, that the child "does not see this conversation") would be false
|
||||
* for a fork. Exported for tests.
|
||||
* @param inherits - the bound provider's context contract.
|
||||
* @param inheritsConversation - whether the child's conversation is seeded
|
||||
* with the parent's completed turns; this says nothing about tool, service,
|
||||
* scope, or authority inheritance.
|
||||
* @returns the tool `description` and the `prompt` parameter description.
|
||||
*/
|
||||
export function providerWording(inherits: boolean): { description: string; promptDescription: string } {
|
||||
if (inherits) {
|
||||
export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } {
|
||||
if (inheritsConversation) {
|
||||
return {
|
||||
description:
|
||||
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
|
||||
@@ -207,7 +270,40 @@ export function providerWording(inherits: boolean): { description: string; promp
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the provider request shared by foreground and background execution. */
|
||||
function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest {
|
||||
return {
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
parent,
|
||||
signal,
|
||||
...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {},
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {},
|
||||
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle a possibly-pending provider start through the task outcome contract. */
|
||||
async function settleStart(start: Promise<SubagentRun>, signal: AbortSignal): Promise<TaskOutcome> {
|
||||
try {
|
||||
return await settleRun(await start)
|
||||
} catch (error: unknown) {
|
||||
return signal.aborted
|
||||
? { status: 'killed' }
|
||||
: { status: 'failed', detail: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Keep misconfiguration at plugin load even when a caller invokes apply()
|
||||
// directly and bypasses Schemastery's natural/max metadata.
|
||||
assertSubagentMaxDepth(config.maxDepth)
|
||||
// Misconfiguration fails loud AT LOAD (the check is self-contained): an
|
||||
// explicit `toolFilter: {}` would otherwise pass the capability gate and
|
||||
// kill every delegation later, in the child-setup `restrict({})` throw.
|
||||
if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) {
|
||||
throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter')
|
||||
}
|
||||
// The tool MIRRORS its provider's lifecycle instead of assuming load order:
|
||||
// the cordis Loader starts sibling entries concurrently, so "backend listed
|
||||
// first in cordis.yml" does not guarantee "provider registered first", and
|
||||
@@ -267,9 +363,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// A step already cancelled must not spawn a child. After the id is
|
||||
// returned the tool-call signal is deliberately NOT wired to the run
|
||||
// (the child outlives this step; cancellation belongs to task_kill
|
||||
// and owner-disposal cleanup), so the request carries NO signal.
|
||||
// returned the tool-call signal is deliberately NOT wired to the run;
|
||||
// an independent controller lets task_kill/owner disposal cancel both
|
||||
// a pending async start and a ready child through the seam's one
|
||||
// canonical cancellation channel.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
// tasks.start preflights (surface fence, owner cleanup) BEFORE run()
|
||||
// spawns the child, and cannot fail after — a child can never start
|
||||
@@ -279,14 +376,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
run: () => {
|
||||
const run = ctx.subagents.start(config.provider, {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const start = ctx.subagents.start(
|
||||
config.provider,
|
||||
startRequest(config, args.prompt, parent, controller.signal),
|
||||
)
|
||||
return {
|
||||
cancel: (reason?: string) => { run.cancel(reason ?? 'background subagent task killed') },
|
||||
done: settleRun(run),
|
||||
cancel: (reason?: string) => {
|
||||
controller.abort(reason ?? 'background subagent task killed')
|
||||
},
|
||||
done: settleStart(start, controller.signal),
|
||||
// No readOutput: a subagent task is final-output-only — the
|
||||
// child session remains the detailed trace.
|
||||
}
|
||||
@@ -295,24 +394,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
const request = startRequest(
|
||||
config,
|
||||
args.prompt,
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
...config.agentOptions ? { agentOptions: config.agentOptions } : {},
|
||||
}
|
||||
exec.signal ?? new AbortController().signal,
|
||||
)
|
||||
|
||||
const run: SubagentRun = ctx.subagents.start(config.provider, request)
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the child is in flight, cancel the child too.
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before this
|
||||
// line, so a step cancelled before the tool ran would never reach the
|
||||
// child. Cancel explicitly in that case — the bridge must honor an
|
||||
// already-aborted signal, not lean on each provider re-checking it.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
@@ -324,7 +413,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach child quiescence — never leak a live idle child/session.
|
||||
await run.dispose()
|
||||
}
|
||||
|
||||
@@ -139,12 +139,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'weird',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('weird-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
})
|
||||
@@ -165,14 +164,13 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -195,14 +193,13 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'bare',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request) => {
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('bare-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -245,7 +242,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
const backend = await ctx.plugin(mock, { name: 'mock' }) // spawn-shaped (inherits: false)
|
||||
const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false)
|
||||
await ctx.plugin(tool, { provider: 'mock' })
|
||||
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation')
|
||||
|
||||
@@ -253,7 +250,7 @@ describe('dsh-tool-subagent', () => {
|
||||
await backend.dispose()
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
|
||||
|
||||
// Backend reloads with a DIFFERENT contract: the wording is re-derived
|
||||
// Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived
|
||||
// from the fresh provider, not served stale from the first mount.
|
||||
await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true })
|
||||
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('INHERITS this conversation')
|
||||
@@ -298,7 +295,7 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
|
||||
})
|
||||
|
||||
it('derives spawn-shaped wording from a fresh-context provider (default mock)', async () => {
|
||||
it('derives spawn-shaped wording from a fresh-conversation provider (default mock)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
|
||||
expect(schema.description).toContain('does not see this conversation')
|
||||
@@ -306,7 +303,7 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(props['prompt']!.description).toContain('include everything it needs')
|
||||
})
|
||||
|
||||
it('derives fork-shaped wording from an inheriting provider (the description stops lying)', async () => {
|
||||
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
|
||||
const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
|
||||
expect(schema.description).toContain('INHERITS this conversation')
|
||||
@@ -325,12 +322,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -348,12 +344,11 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => ({
|
||||
start: async () => ({
|
||||
id: AgentId('spy-child'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
cancel() {},
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
})
|
||||
@@ -364,7 +359,7 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(disposed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bridges the tool abort signal to run.cancel()', async () => {
|
||||
it('passes the tool abort signal as the provider cancellation channel', async () => {
|
||||
const cancelled = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -372,18 +367,19 @@ describe('dsh-tool-subagent', () => {
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
start: async (request) => {
|
||||
if (request.signal.aborted) throw new Error('start aborted')
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
@@ -392,12 +388,7 @@ describe('dsh-tool-subagent', () => {
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
// Abort AFTER the tool body has had a chance to register its abort listener
|
||||
// (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
|
||||
// body runs, so the listener is not registered synchronously). A few
|
||||
// microtask turns let execute() reach `addEventListener('abort')`, so this
|
||||
// exercises the LIVE onAbort bridge — distinct from the already-aborted
|
||||
// sync path the next test covers.
|
||||
// Let provider.start install its listener before aborting.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
@@ -406,33 +397,19 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
|
||||
// `addEventListener('abort')` does not fire for a signal already aborted
|
||||
// before the listener is added, so a step cancelled before the tool ran
|
||||
// would never reach the child unless the bridge re-checks `signal.aborted`.
|
||||
// A provider that leans only on the abort EVENT (this spy never inspects
|
||||
// request.signal) proves the bridge itself must cancel.
|
||||
const cancelled = vi.fn()
|
||||
it('passes an already-aborted signal so provider startup rejects', async () => {
|
||||
const sawAborted = vi.fn()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'spy',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
|
||||
const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
|
||||
return {
|
||||
id: AgentId('spy-child'),
|
||||
result,
|
||||
cancel: () => {
|
||||
cancelled()
|
||||
resolveResult({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: async () => {},
|
||||
}
|
||||
start: async (request) => {
|
||||
if (request.signal.aborted) sawAborted()
|
||||
throw new Error('start aborted')
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'spy' })
|
||||
@@ -440,7 +417,7 @@ describe('dsh-tool-subagent', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort() // already aborted BEFORE the tool runs
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
|
||||
expect(cancelled).toHaveBeenCalledTimes(1)
|
||||
expect(sawAborted).toHaveBeenCalledTimes(1)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
@@ -479,12 +456,144 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('passes persona/toolFilter/maxDepth config through to the start request', async () => {
|
||||
let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture2',
|
||||
capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture2-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, {
|
||||
provider: 'capture2',
|
||||
persona: 'You are the child.',
|
||||
toolFilter: { deny: ['subagent'] },
|
||||
maxDepth: 2,
|
||||
})
|
||||
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.persona).toBe('You are the child.')
|
||||
expect(seen?.toolFilter).toMatchObject({ deny: ['subagent'] })
|
||||
expect(seen?.maxDepth).toBe(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'null', value: null as unknown as number },
|
||||
{ label: 'a string', value: '1' as unknown as number },
|
||||
{ label: 'NaN', value: Number.NaN },
|
||||
{ label: 'positive infinity', value: Number.POSITIVE_INFINITY },
|
||||
{ label: 'negative infinity', value: Number.NEGATIVE_INFINITY },
|
||||
{ label: 'a negative integer', value: -1 },
|
||||
{ label: 'a fractional number', value: 1.5 },
|
||||
{ label: 'negative zero', value: -0 },
|
||||
{ label: 'an unsafe integer', value: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])('rejects maxDepth=$label when the plugin loads', async ({ value }) => {
|
||||
await expect(setup({ provider: 'mock', maxDepth: value }))
|
||||
.rejects.toThrow()
|
||||
})
|
||||
|
||||
it('validates maxDepth when apply() is invoked directly without Schemastery', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => {
|
||||
tool.apply(ctx, {
|
||||
provider: 'unused',
|
||||
maxDepth: Number.NaN,
|
||||
})
|
||||
}).toThrow('subagent maxDepth must be a non-negative safe integer')
|
||||
})
|
||||
|
||||
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
|
||||
let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture3',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture3-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } })
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen?.toolFilter).toEqual({ deny: ['subagent'] })
|
||||
expect(seen?.toolFilter).not.toHaveProperty('allow')
|
||||
})
|
||||
|
||||
it('an omitted agentOptions does not materialize an empty object onto the request', async () => {
|
||||
// Same schemastery trap as toolFilter, adjacent field: an omitted
|
||||
// `agentOptions` config key materializes `{}` without the forced default,
|
||||
// which reads as present and puts a dishonest `agentOptions: {}` on every
|
||||
// start request.
|
||||
let seen: { agentOptions?: unknown } | undefined
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'capture4',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
seen = request
|
||||
return {
|
||||
id: AgentId('capture4-child'),
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(tool, { provider: 'capture4' })
|
||||
await callSubagent(ctx, { description: 'd', prompt: 'p' })
|
||||
expect(seen).toBeDefined()
|
||||
expect(seen).not.toHaveProperty('agentOptions')
|
||||
})
|
||||
|
||||
it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'p',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => { throw new Error('unreachable') },
|
||||
})
|
||||
const fiber = ctx.plugin(tool, { provider: 'p', toolFilter: {} })
|
||||
await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-tool-subagent background mode', () => {
|
||||
/** A parent agent carrying a real session token, registered in ctx.agents (owner-cleanup wiring requires a live registry entry). */
|
||||
/** A live parent with a dedicated scope fiber for structural task cleanup. */
|
||||
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const agent = { id: AgentId(`agent-${sessionId}`), inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const agent = {
|
||||
id: AgentId(`agent-${sessionId}`),
|
||||
ctx: scopeFiber.ctx,
|
||||
inject,
|
||||
session: { header: { version: 0, id: sessionId, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
@@ -540,22 +649,89 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
expect(text(result)).toContain('subagent delegation aborted')
|
||||
})
|
||||
|
||||
it('forwards task_kill reasons to run.cancel (and defaults one when absent)', async () => {
|
||||
// A provider whose runs settle only on cancel — the mock settles on a
|
||||
it('settles an asynchronous provider-start failure as a failed task', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'broken-start',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async () => { throw new Error('setup failed') },
|
||||
})
|
||||
tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' })
|
||||
|
||||
const started = await ctx.tools.execute({
|
||||
callId: CallId('broken-start'),
|
||||
name: 'subagent_broken',
|
||||
arguments: { description: 'broken', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(started)).toBe('started background subagent task subagent-1')
|
||||
const output = await ctx.tools.execute({
|
||||
callId: CallId('broken-output'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(output)).toContain('[status: failed, Error: setup failed]')
|
||||
})
|
||||
|
||||
it('kills a subagent task while provider readiness is still pending', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'pending-start',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: request => new Promise((_resolve, reject) => {
|
||||
request.signal.addEventListener('abort', () => { reject(new Error('startup aborted')) }, { once: true })
|
||||
}),
|
||||
})
|
||||
tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' })
|
||||
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('pending-start'),
|
||||
name: 'subagent_pending',
|
||||
arguments: { description: 'pending', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('pending-kill'),
|
||||
name: 'task_kill',
|
||||
arguments: { task_id: 'subagent-1', reason: 'no longer needed' },
|
||||
agent: parent,
|
||||
})
|
||||
const output = await ctx.tools.execute({
|
||||
callId: CallId('pending-output'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(output)).toBe('(no new output)\n[status: killed]')
|
||||
})
|
||||
|
||||
it('forwards task_kill reasons through the run signal (and defaults one when absent)', async () => {
|
||||
// A provider whose runs settle only on signal abort — the mock settles on a
|
||||
// microtask, too fast to observe a LIVE kill through the real tools.
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const cancels: (string | undefined)[] = []
|
||||
let starts = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'hanging',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
start: async (request) => {
|
||||
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
|
||||
const id = AgentId(`hang-${++starts}`)
|
||||
const result = new Promise<{ output: { type: 'text'; text: string }[]; stopReason: 'aborted' }>((res) => { settle = res })
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cancels.push(typeof request.signal.reason === 'string' ? request.signal.reason : undefined)
|
||||
settle({ output: [], stopReason: 'aborted' })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: AgentId(`hang-${cancels.length}`),
|
||||
result: new Promise((res) => { settle = res }),
|
||||
cancel(reason?: string) { cancels.push(reason); settle({ output: [], stopReason: 'aborted' }) },
|
||||
id,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
@@ -597,7 +773,6 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const completed = await settleRun({
|
||||
id: AgentId('child-1'),
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
@@ -609,11 +784,27 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const failed = await settleRun({
|
||||
id: AgentId('child-2'),
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
cancel() {},
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: AgentId('child-3'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' })
|
||||
|
||||
const bothFailed = await settleRun({
|
||||
id: AgentId('child-4'),
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
expect(bothFailed).toEqual({
|
||||
status: 'failed',
|
||||
detail: 'Error: result failed; dispose failed: Error: reap failed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -625,20 +816,25 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const parent = { id: AgentId('agent-sess-p'), inject: () => {}, session: { header: { version: 0, id: 'sess-p', createdAt: 0 } } } as unknown as Agent
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const parent = {
|
||||
id: AgentId('agent-sess-p'),
|
||||
ctx: scopeFiber.ctx,
|
||||
inject: () => {},
|
||||
session: { header: { version: 0, id: 'sess-p', createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(parent)
|
||||
|
||||
let starts = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'probe',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => {
|
||||
start: async () => {
|
||||
starts += 1
|
||||
return {
|
||||
id: AgentId('probe-child'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
cancel() {},
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user