Merge origin/master into parallel-tool-call
This commit is contained in:
@@ -14,6 +14,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
|
||||
The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock.
|
||||
|
||||
`SubagentProvider.start()` must be safe to call concurrently for independent runs: the `subagent` tool is parallel-safe, so one parent step may issue several subagent calls at once. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every call.
|
||||
`SubagentProvider.start()` must be safe to call concurrently for independent runs: foreground `subagent` calls are parallel-safe, so one parent step may issue several at once. Background starts remain exclusive while registering parent-owned task state. Each backend reads the parent synchronously at start (a snapshot, never mutated or re-read during the run) — `fork` seeds each child from the parent's completed-turn prefix, which the open in-flight turn cannot change, so concurrent forks inside one open step all see the same stable prefix. A resource-limited provider may queue or cap internally, but must not require the loop to serialize every foreground call.
|
||||
|
||||
The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md).
|
||||
|
||||
@@ -33,7 +33,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
|
||||
config:
|
||||
providerName: acp
|
||||
command: node
|
||||
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
||||
args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
|
||||
permission: reject
|
||||
env:
|
||||
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as acp from '../src/index.ts'
|
||||
*/
|
||||
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url))
|
||||
const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
@@ -42,7 +42,7 @@
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
@@ -39,6 +39,6 @@
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
@@ -43,7 +43,7 @@
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +58,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
## Collection model
|
||||
|
||||
The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. Background delegation does not change this seam; the consumer registers startup and the eventual run with the generic `ctx.tasks` runtime, then collection and cancellation use the shared task tools. See the [background subagent tasks RFC](../../../docs/rfc/implemented/feature/2026-07-08-background-subagent-tasks.md), the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-subagent`, which retains only a provider's data-dependent final output or exact `Error: no subagent provider registered for "<name>"`, `Error: subagent provider "<name>" does not support the "<capability>" capability`, and `Error: <message>` start failures in the parent while child working tokens remain child-only.
|
||||
Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The current consumer collects synchronously** — the model-facing tool starts a run and awaits `result`; steering (`sendMessage`) is part of the seam but intentionally unused, and background/poll/spill semantics are deferred to a future long-running-runtime design.
|
||||
- **The lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface is deferred until a consumer needs one.
|
||||
- **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool.
|
||||
- **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface waits for a concrete consumer.
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
* child before returning its run, so fulfillment is the single publication and
|
||||
* ownership-transfer boundary.
|
||||
*
|
||||
* Unlike the bash seam (one executor per context, second load throws), MULTIPLE
|
||||
* providers coexist here: each registers under a unique name and a caller picks
|
||||
* one by name. The shape mirrors the LLM adapter registry
|
||||
* (`LlmService.registerAdapter`), not the single-service bash executor.
|
||||
*
|
||||
* This package is the INTERFACE third of the capability seam. Implementations
|
||||
* (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing
|
||||
* consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages.
|
||||
*
|
||||
* Scope: the seam stays collection-agnostic — a run is started and its
|
||||
* `result` awaited, whether the consumer blocks on it (foreground) or
|
||||
* registers it as a `ctx.tasks` background task (the generic runtime owns
|
||||
* ids/polling/stop; this seam gains nothing task-shaped). Steering
|
||||
* ({@link SubagentRun.sendMessage}) is part of the contract but intentionally
|
||||
* unused.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
* serialization and hostile-input validation belong at real process, worker,
|
||||
@@ -77,6 +93,7 @@ declare module 'cordis' {
|
||||
* parent-scoped listener observes only its own delegations. Paired with
|
||||
* `subagent/end`.
|
||||
* @param info - the provider and ready child identity.
|
||||
* @dshScopeScan unsupported
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
@@ -85,6 +102,7 @@ declare module 'cordis' {
|
||||
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
|
||||
* same scoped audience.
|
||||
* @param info - the run identity and terminal outcome.
|
||||
* @dshScopeScan unsupported
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
|
||||
@@ -187,11 +187,11 @@ export interface SubagentProvider {
|
||||
* fulfillment, the provider owns and cleans all partial resources before this
|
||||
* promise rejects. Ownership transfers to the caller only on fulfillment.
|
||||
*
|
||||
* MUST be safe to call concurrently for independent runs: the `subagent` tool
|
||||
* is parallel-safe, so a parent step may issue several subagent calls at once,
|
||||
* MUST be safe to call concurrently for independent runs: foreground
|
||||
* `subagent` calls are parallel-safe, so a parent step may issue several at once,
|
||||
* each invoking `start()` before an earlier run settles. An implementation
|
||||
* snapshots the parent at start and must not require the parent loop to
|
||||
* serialize every `subagent` call; a resource-limited provider queues or
|
||||
* serialize every foreground `subagent` call; a resource-limited provider queues or
|
||||
* rejects internally.
|
||||
*/
|
||||
start(request: SubagentStartRequest): Promise<SubagentRun>
|
||||
|
||||
@@ -1,70 +1,55 @@
|
||||
# @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` | Per-child global-tool restriction; requires `toolFilter` capability. |
|
||||
| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. |
|
||||
|
||||
## Concurrency
|
||||
|
||||
The tool declares `isConcurrencySafe: () => true`: each call starts an independent child run and returns only its final answer, touching no parent-agent state, and `SubagentProvider.start()` is contractually concurrent-safe for independent runs (see [subagent/](../README.md)). So the agent loop may run several `subagent` calls from one assistant step in parallel, and the tool description tells the model it may issue independent tasks together when their work scopes do not overlap. The subagent tool stays synchronous (one result = the child's final answer); background spawning + later collection is separate future work.
|
||||
|
||||
`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).
|
||||
Foreground calls opt into concurrent scheduling because each owns an independent child run and returns only its final answer. Background starts remain exclusive because they register parent-owned task state. Providers must accept concurrent `start()` calls for independent runs; they may queue internally, enforce capacity, or return a typed failure. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md).
|
||||
|
||||
## 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. You may issue several subagent calls in one message to run independent tasks concurrently when their work scopes do not overlap.
|
||||
```
|
||||
**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,20 +25,23 @@
|
||||
"@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.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"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.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"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. '
|
||||
@@ -165,30 +195,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',
|
||||
@@ -200,61 +247,88 @@ 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.',
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
// Each call starts an independent child run and returns only its final
|
||||
// answer; the tool touches no parent-agent state. SubagentProvider.start()
|
||||
// is contractually safe to call concurrently for independent runs (a
|
||||
// resource-limited provider queues internally), so sibling subagent calls
|
||||
// may run in parallel.
|
||||
isConcurrencySafe: () => true,
|
||||
// A foreground call owns only its child run; background mode first
|
||||
// registers parent-owned task state and therefore remains exclusive.
|
||||
isConcurrencySafe: args => args.run_in_background !== true,
|
||||
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)
|
||||
})
|
||||
@@ -267,9 +341,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,9 +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 { AgentId, 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'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
|
||||
@@ -60,21 +64,50 @@ 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'])
|
||||
expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background'])
|
||||
expect(schema!.description).toContain('task_output')
|
||||
})
|
||||
|
||||
it('declares each subagent call parallel-safe through the shared tool scheduler contract', async () => {
|
||||
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: AgentId('agent-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('classifies foreground calls as parallel and background starts as exclusive', async () => {
|
||||
const ctx = await setup({ provider: 'mock' })
|
||||
expect(ctx.tools.executionMode({
|
||||
callId: CallId('subagent-safe'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK' },
|
||||
})).toEqual({ kind: 'parallel' })
|
||||
expect(ctx.tools.executionMode({
|
||||
callId: CallId('subagent-background'),
|
||||
name: 'subagent',
|
||||
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
|
||||
})).toEqual({ kind: 'exclusive' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -234,7 +267,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 () => {
|
||||
@@ -284,10 +317,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')
|
||||
@@ -564,3 +597,268 @@ 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 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
|
||||
}
|
||||
|
||||
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 = 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,
|
||||
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: AgentId('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: AgentId('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: 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 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, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async () => {
|
||||
starts += 1
|
||||
return {
|
||||
id: AgentId('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