Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/bash.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/bash/bash/src/types.ts # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/subagent/tool-subagent/tests/tool-subagent.spec.ts # packages/util/brand/README.md # packages/util/brand/src/index.ts
This commit is contained in:
@@ -1,66 +1,51 @@
|
||||
# @deepseek-ai/dsh-tool-subagent
|
||||
|
||||
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.
|
||||
The model-facing delegation tool over one configured `ctx.subagents` provider. Changing the provider changes transport without changing the execution contract.
|
||||
|
||||
## Provider selection
|
||||
## Provider selection and lifecycle
|
||||
|
||||
Each plugin instance binds to exactly one provider. The model sees `{ description, prompt }`, not a provider selector. To expose multiple transports, load the plugin multiple times with distinct `toolName` values.
|
||||
Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns.
|
||||
|
||||
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.
|
||||
A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns final text; abort, refusal, token limit, and other failures become errored tool results without partial output.
|
||||
|
||||
## Lifecycle
|
||||
With `run_in_background: true`, the tool registers the parent-owned task before starting the provider. A task-owned signal covers pending startup and the child after the starting call returns. `task_kill` and owner disposal abort it. Settlement awaits startup rollback or child disposal, then maps completed final text, abort to `killed`, and other failures to `failed`. The task has no incremental read; generic task tools own later status, collection, cancellation, and notices. See the [background subagent RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md).
|
||||
|
||||
`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. The current tool blocks the parent turn until collection finishes; background and polling modes are deferred.
|
||||
`toolFilter` changes the child's global tool layer but 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).
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `provider` | Required `ctx.subagents` provider name. |
|
||||
| `toolName` | Model-facing tool name (default `subagent`). Must be unique per plugin instance. |
|
||||
| `agentOptions` | Default child agent options, currently including `model`. |
|
||||
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
|
||||
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
|
||||
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
|
||||
| `agentOptions` | Default child 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. |
|
||||
|
||||
`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-explicit-non-goals).
|
||||
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
|
||||
| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Standalone-provider schema
|
||||
### Tool schema
|
||||
|
||||
**What the model sees**: While a fresh-context provider exists, the configured tool uses the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent); the catalog also records how `toolName` changes the visible name.
|
||||
**What the model sees**: The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`.
|
||||
|
||||
**Token effect**: Fixed schema cost per parent request while mounted. Removing the provider removes the whole schema.
|
||||
**Token effect**: Fixed schema cost per parent request; each provider instance adds one schema.
|
||||
|
||||
### Inherited-context-provider schema
|
||||
### Foreground result
|
||||
|
||||
**What the model sees**: Relative to the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent), a provider that seeds completed turns replaces only the tool and `prompt` parameter descriptions with the text below; the shape and `description` parameter stay unchanged.
|
||||
**What the model sees**: The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: <message>`. Intermediate child steps stay out of the parent.
|
||||
|
||||
**Token effect**: Fixed schema cost per parent request while mounted. Exposing multiple providers adds one independently named schema per load.
|
||||
**Token effect**: The prompt and result remain in parent history until compaction; child working context remains in the child.
|
||||
|
||||
#### Inherited-context-provider tool description
|
||||
### Background task result
|
||||
|
||||
```markdown
|
||||
Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.
|
||||
```
|
||||
**What the model sees**: Start returns exactly `started background subagent task <id>`. The generic task surface provides later status, final output, cancellation responses, and notices.
|
||||
|
||||
#### Inherited-context-provider prompt description
|
||||
|
||||
```markdown
|
||||
The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new.
|
||||
```
|
||||
|
||||
### Tool-call history and result
|
||||
|
||||
**What the model sees**: The task description and full prompt remain in the parent assistant tool call. Success contains only the child's data-dependent final text. Other stop reasons become exactly `Error: subagent run was cancelled`, `Error: subagent run failed`, `Error: subagent run hit its token limit before finishing`, `Error: subagent declined the task`, or `Error: subagent run ended abnormally (<reason>)`; a call without an owning agent becomes `Error: subagent tool requires a calling agent (exec.agent was undefined)`. Intermediate child steps never enter the parent.
|
||||
|
||||
**Token effect**: Prompt and final output are data-dependent retained tokens. All child working context is paid in the child and omitted from the parent.
|
||||
**Token effect**: The acknowledgement is retained; final output enters parent history only when collected or injected.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Delegation blocks the parent turn** — synchronous collect only; background start and poll collection are deferred to the long-running-runtime redesign.
|
||||
- **Duplicate `toolName` across waiting loads is detected late** (`TODO(subagent-dup-toolname)`) — two loads waiting on providers collide only when a provider arrives, and the throw rolls back the provider's fiber rather than the misconfigured tool's; config-time detection needs a cross-fiber registry of intended names.
|
||||
- **Child policy is fixed per tool registration** — `model`, persona, tool filter, and depth cap come from this plugin load's config, not model-call arguments; exposing another policy requires another distinctly named tool.
|
||||
- **Background runs expose final output only** — intermediate child steps stay in the child session.
|
||||
- **Duplicate names across waiting instances are detected late** (`TODO(subagent-dup-toolname)`) — preventing provider-registration rollback requires a registry of intended names.
|
||||
- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -32,13 +33,15 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* Model-facing delegation tool bound by configuration to one provider; transport selection is not
|
||||
* exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and
|
||||
* re-derives conversation-history wording after reload, so load order is irrelevant.
|
||||
*
|
||||
* Execution synchronously awaits the child result and always disposes the run. Non-completed stop
|
||||
* reasons become error results, while transport details remain behind `ctx.subagents`. Load this
|
||||
* plugin more than once to expose multiple configured providers.
|
||||
* Model-facing delegation through one configured `ctx.subagents` provider.
|
||||
* Provider lifecycle controls tool registration and context-sensitive schema
|
||||
* wording. Foreground calls always dispose the run after collection; background
|
||||
* calls use an independent cancellation signal and settle a final-output task
|
||||
* only after child disposal.
|
||||
* @module @deepseek-ai/dsh-tool-subagent
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
export const name = 'tool-subagent'
|
||||
export const inject = ['tools', 'subagents']
|
||||
@@ -25,34 +24,29 @@ export interface Config {
|
||||
/** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
|
||||
provider: string
|
||||
/**
|
||||
* The model-facing tool name to register (default `subagent`). To expose more
|
||||
* than one transport, load this plugin once per provider — each load MUST set
|
||||
* a distinct `toolName` (the tool registry rejects a duplicate name), e.g.
|
||||
* `{ provider: 'spawn', toolName: 'subagent' }` and
|
||||
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
|
||||
* Model-facing tool name (default `subagent`). Each loaded instance must use
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults.
|
||||
* Expose `run_in_background` (default true). Disabled instances omit the
|
||||
* parameter and reject forced background calls.
|
||||
*/
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* Agent options applied to every child; omitted fields use child-loop 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.
|
||||
* Per-child persona that shadows `deployment:persona`. Requires the
|
||||
* provider's `persona` capability; omission preserves 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.
|
||||
* Tool filter applied to every child. Filtered tools disappear from its
|
||||
* prompt and reject execution. Requires the provider's `toolFilter`
|
||||
* capability; unknown names fail startup. Children otherwise see this tool,
|
||||
* so deny it or set `maxDepth` to bound recursion.
|
||||
*/
|
||||
toolFilter?: {
|
||||
/** Global tool names the child keeps; everything else is removed. */
|
||||
@@ -61,12 +55,8 @@ export interface Config {
|
||||
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).
|
||||
* Maximum child depth. Requires the provider's `depthLimit` capability and a
|
||||
* non-negative safe integer. Omission is unbounded.
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
@@ -74,16 +64,13 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
toolName: z.string().default('subagent'),
|
||||
// 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.
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
// Prevent Schemastery from materializing omitted agentOptions as `{}`.
|
||||
agentOptions: z.object({
|
||||
model: z.string(),
|
||||
}).default(undefined as unknown as { model: string }),
|
||||
persona: z.string(),
|
||||
// Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which
|
||||
// silently means deny all. Preserve omission while retaining an explicit empty allow-list.
|
||||
// Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool.
|
||||
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[]),
|
||||
@@ -93,9 +80,8 @@ export const Config: z<Config> = z.object({
|
||||
|
||||
/**
|
||||
* Flatten a child's final output blocks to text for the tool result. The child
|
||||
* may return non-text blocks; this cut surfaces the text content (the common
|
||||
* case) and drops the rest, which is acceptable for a synchronous summary —
|
||||
* the structured path (`outputSchema`) is the channel for non-text results.
|
||||
* may return non-text blocks; this path returns only text. Structured results
|
||||
* use `outputSchema`.
|
||||
*/
|
||||
function outputText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
@@ -124,6 +110,50 @@ function stopReasonError(result: SubagentResult): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a child result to the task outcome: completed carries final text,
|
||||
* aborted is killed, and every other reason is failed without partial output.
|
||||
* @param result - child terminal result.
|
||||
* @returns 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 }
|
||||
// Merge-extensible reasons remain failures with their raw detail.
|
||||
default:
|
||||
return { status: 'failed', detail: String(result.stopReason) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the child result, dispose the run, then return its task outcome. Result
|
||||
* and disposal failures become `failed`; when both fail, both details survive.
|
||||
* @param run - live run to settle and release.
|
||||
* @returns outcome after child resources are released.
|
||||
*/
|
||||
export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
|
||||
let outcome: TaskOutcome
|
||||
try {
|
||||
outcome = runOutcome(await run.result)
|
||||
} catch (error: unknown) {
|
||||
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 from the provider's conversation-history descriptor
|
||||
* ({@link SubagentProvider.inheritsParentContext}).
|
||||
@@ -140,7 +170,7 @@ export function providerWording(inheritsConversation: boolean): { description: s
|
||||
if (inheritsConversation) {
|
||||
return {
|
||||
description:
|
||||
'Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all '
|
||||
'Delegate a task to a subagent that inherits this conversation: a child agent seeded with all '
|
||||
+ 'completed turns so far (it does not see the current in-flight turn), returning only its final '
|
||||
+ 'result. Use this when the subtask builds on this conversation\'s context — a follow-up analysis, '
|
||||
+ 'a review, a continuation — without consuming this conversation\'s context for the work itself. '
|
||||
@@ -163,30 +193,47 @@ export function providerWording(inheritsConversation: boolean): { description: s
|
||||
}
|
||||
}
|
||||
|
||||
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 pending startup without rejecting the task producer 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.
|
||||
// Direct apply() bypasses Schemastery's numeric constraints.
|
||||
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.
|
||||
// Reject an empty explicit filter at load instead of failing every delegation.
|
||||
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
|
||||
// an HMR reload of the backend replaces the provider while this fiber stays
|
||||
// loaded. Register the tool when the bound provider is (or becomes)
|
||||
// available — deriving the wording from THAT provider — and unregister it
|
||||
// when the provider goes away, so the description can never outlive or
|
||||
// predate the provider it describes.
|
||||
// Mirror provider lifecycle because sibling load order and HMR replacement
|
||||
// can change provider availability while this fiber remains active.
|
||||
let disposeTool: (() => void) | undefined
|
||||
const mount = (provider: SubagentProvider): void => {
|
||||
const wording = providerWording(provider.inheritsParentContext)
|
||||
const backgroundEnabled = config.enableRunInBackground !== false
|
||||
disposeTool = ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'subagent',
|
||||
description: wording.description,
|
||||
description: wording.description + (backgroundEnabled
|
||||
? ' Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.'
|
||||
: ''),
|
||||
parameters: {
|
||||
description: {
|
||||
type: 'string',
|
||||
@@ -198,55 +245,85 @@ export function apply(ctx: Context, config: Config): void {
|
||||
required: true,
|
||||
description: wording.promptDescription,
|
||||
},
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: {
|
||||
type: 'boolean' as const,
|
||||
description: 'Run as a background task and return its id; collect with task_output or stop with task_kill.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// The loop sets `exec.agent` for every model-driven call; its absence
|
||||
// means a non-agent caller invoked the tool directly, which has no
|
||||
// parent to attribute the child to. Fail loud rather than guess.
|
||||
// Non-agent callers provide no parent for delegation ownership.
|
||||
throw new Error('subagent tool requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
|
||||
const request: SubagentStartRequest = {
|
||||
prompt: [{ type: 'text', text: args.prompt }],
|
||||
parent,
|
||||
signal: exec.signal ?? new AbortController().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 } : {},
|
||||
if (args.run_in_background === true) {
|
||||
// The validator permits undeclared keys, so schema omission also needs
|
||||
// execution-time enforcement.
|
||||
if (!backgroundEnabled) {
|
||||
throw new Error('run_in_background is disabled for this tool instance (enableRunInBackground: false)')
|
||||
}
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// Reject cancellation before spawning; after return, the task-owned
|
||||
// signal covers both pending startup and the ready child.
|
||||
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
|
||||
// Task preflight finishes before the starter can spawn a child.
|
||||
const id = tasks.start({
|
||||
kind: 'subagent',
|
||||
label: args.description,
|
||||
owner: parent,
|
||||
run: () => {
|
||||
const controller = new AbortController()
|
||||
const start = ctx.subagents.start(
|
||||
config.provider,
|
||||
startRequest(config, args.prompt, parent, controller.signal),
|
||||
)
|
||||
return {
|
||||
cancel: (reason?: string) => {
|
||||
controller.abort(reason ?? 'background subagent task killed')
|
||||
},
|
||||
done: settleStart(start, controller.signal),
|
||||
// No readOutput: the child session owns intermediate detail.
|
||||
}
|
||||
},
|
||||
})
|
||||
return [{ type: 'text', text: `started background subagent task ${id}` }]
|
||||
}
|
||||
|
||||
const request = startRequest(
|
||||
config,
|
||||
args.prompt,
|
||||
parent,
|
||||
exec.signal ?? new AbortController().signal,
|
||||
)
|
||||
|
||||
const run: SubagentRun = await ctx.subagents.start(config.provider, request)
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
const error = stopReasonError(result)
|
||||
if (error !== undefined) {
|
||||
// Map a non-clean finish to an isError result (the registry turns a
|
||||
// throw into an isError). Report the reason, not partial output.
|
||||
// The registry converts this throw to isError; partial output is not success.
|
||||
throw new Error(error)
|
||||
}
|
||||
return [{ type: 'text', text: outputText(result.output) }]
|
||||
} finally {
|
||||
// Always reach child quiescence — never leak a live idle child/session.
|
||||
// Dispose before returning so no child session outlives the call.
|
||||
await run.dispose()
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Listeners first, then the presence check: both run synchronously, so no
|
||||
// registration can slip between them; the `disposeTool === undefined` guard
|
||||
// makes a same-tick added-event after a successful mount a no-op.
|
||||
// Register listeners before checking presence so no synchronous change is missed.
|
||||
// TODO(subagent-dup-toolname): two WAITING fibers configured with the same
|
||||
// toolName collide only when their provider finally arrives — the duplicate
|
||||
// tool-name throw then propagates through `subagent/provider-added` and
|
||||
// rolls back the PROVIDER registration, so an invalid config blasts the
|
||||
// backend's fiber instead of the misconfigured tool's. Config-time detection
|
||||
// would need a cross-fiber registry of intended tool names; revisit if a
|
||||
// real deployment ever hits it.
|
||||
// toolName collide when their provider appears, and the duplicate-name throw
|
||||
// rolls back the provider registration. Add an intent registry if this occurs.
|
||||
ctx.on('subagent/provider-added', (provider) => {
|
||||
if (provider.name === config.provider && disposeTool === undefined) mount(provider)
|
||||
})
|
||||
@@ -259,9 +336,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (present !== undefined) {
|
||||
mount(present)
|
||||
} else {
|
||||
// Not an error: the backend's fiber may activate after this one.
|
||||
// The tool appears the moment the provider registers; a typo'd provider
|
||||
// name shows up as this note plus a tool that never materializes.
|
||||
// A backend fiber may activate later; a misspelled provider remains visible in this log.
|
||||
ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as mock from '@deepseek-ai/dsh-subagent-mock'
|
||||
import * as tool from '../src/index.ts'
|
||||
import { runOutcome, settleRun } from '../src/index.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
@@ -62,12 +65,36 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(text(result)).toBe('child says hi')
|
||||
})
|
||||
|
||||
it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
|
||||
it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
|
||||
expect(schema!.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => {
|
||||
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
|
||||
expect(schema!.description).not.toContain('task_output')
|
||||
})
|
||||
|
||||
it('refuses a forced run_in_background at execution time when the instance disables it', async () => {
|
||||
// Schema omission is advertising, not enforcement: the arg validator
|
||||
// allows undeclared keys, so the opt-out must also hold in execute().
|
||||
const ctx = await setup({ provider: 'mock', enableRunInBackground: false })
|
||||
const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent
|
||||
|
||||
const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this tool instance')
|
||||
// The provider was never asked to start a child.
|
||||
expect(ctx.subagents.getProvider('mock')).toBeDefined()
|
||||
const foreground = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: parent })
|
||||
expect(foreground.isError).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -227,7 +254,7 @@ describe('dsh-tool-subagent', () => {
|
||||
// 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')
|
||||
expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('inherits this conversation')
|
||||
})
|
||||
|
||||
it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => {
|
||||
@@ -277,10 +304,10 @@ describe('dsh-tool-subagent', () => {
|
||||
expect(props['prompt']!.description).toContain('include everything it needs')
|
||||
})
|
||||
|
||||
it('derives fork-shaped wording from a seeded-conversation provider (the description stops lying)', async () => {
|
||||
it('derives inherited-context wording from a seeded-conversation provider', 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')
|
||||
expect(schema.description).toContain('inherits this conversation')
|
||||
expect(schema.description).not.toContain('does not see this conversation')
|
||||
const props = (schema.parameters as { properties: Record<string, { description: string }> }).properties
|
||||
expect(props['prompt']!.description).toContain('completed turns')
|
||||
@@ -557,3 +584,270 @@ describe('dsh-tool-subagent', () => {
|
||||
await expect(fiber).rejects.toThrow(/names neither `allow` nor `deny`/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-tool-subagent background mode', () => {
|
||||
/** A live parent with a dedicated scope fiber for structural task cleanup. */
|
||||
function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject,
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
async function backgroundSetup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
|
||||
const ctx = await setup(toolConfig, mockConfig)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('returns a task id immediately and the answer is collected through task_output', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
|
||||
const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
|
||||
expect(start.isError).toBe(false)
|
||||
expect(text(start)).toBe('started background subagent task subagent-1')
|
||||
|
||||
const collected = await ctx.tools.execute({
|
||||
callId: CallId('collect-1'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1', wait: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(collected)).toBe('background answer\n[status: completed]')
|
||||
|
||||
// Final-output reads are idempotent (not consumed).
|
||||
const again = await ctx.tools.execute({
|
||||
callId: CallId('collect-2'),
|
||||
name: 'task_output',
|
||||
arguments: { task_id: 'subagent-1' },
|
||||
agent: parent,
|
||||
})
|
||||
expect(text(again)).toBe('background answer\n[status: completed]')
|
||||
})
|
||||
|
||||
it('fails loud when the tasks runtime is not loaded', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('refuses to start when the tool signal is already aborted', async () => {
|
||||
const ctx = await backgroundSetup({ provider: 'mock' })
|
||||
const parent = ownerAgent(ctx, 'sess-parent')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('subagent delegation aborted')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// Use a provider that remains live until its signal is aborted.
|
||||
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, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async (request) => {
|
||||
let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void
|
||||
const id = SessionId(`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,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
// Direct apply preserves omitted agentOptions instead of applying schema defaults.
|
||||
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
|
||||
|
||||
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
|
||||
expect(text(startOne)).toBe('started background subagent task subagent-1')
|
||||
expect(text(startTwo)).toBe('started background subagent task subagent-2')
|
||||
|
||||
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
|
||||
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
|
||||
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
|
||||
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
|
||||
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
|
||||
|
||||
// The aborted children settle as killed tasks.
|
||||
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
|
||||
expect(text(killed)).toBe('(no new output)\n[status: killed]')
|
||||
})
|
||||
|
||||
it('runOutcome maps the stop-reason vocabulary onto task outcomes', () => {
|
||||
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' })
|
||||
})
|
||||
|
||||
it('settleRun disposes the run before reporting, on both result paths', async () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: SessionId('child-1'),
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
order.push('reported')
|
||||
expect(completed).toEqual({ status: 'completed', output: 'ok' })
|
||||
expect(order).toEqual(['dispose', 'reported'])
|
||||
|
||||
// An infrastructure rejection still disposes and reports failed.
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: SessionId('child-2'),
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
expect(failed).toEqual({ status: 'failed', detail: 'Error: transport gone' })
|
||||
expect(disposed).toBe(true)
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: SessionId('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: SessionId('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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('background preflight failure (no orphaned child, by construction)', () => {
|
||||
it('never starts the child when tasks.start preflight throws', async () => {
|
||||
// With no control surface, task preflight fails before the provider can spawn.
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId('sess-p')
|
||||
const parent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
inject: () => {},
|
||||
session: { id, header: { version: 0, id, 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, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async () => {
|
||||
starts += 1
|
||||
return {
|
||||
id: SessionId('probe-child'),
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('probe-1'),
|
||||
name: 'subagent_probe',
|
||||
arguments: { description: 'd', prompt: 'p', run_in_background: true },
|
||||
agent: parent,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// Declare-then-execute: the failed preflight means no child ever existed.
|
||||
expect(starts).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user