workflow: dynamic workflows — script-driven multi-agent orchestration
A new capability family at packages/workflow/ in the bash seam shape,
modeled on Claude Code's dynamic workflows: the model writes a JavaScript
orchestration script (export const meta = {...} + plain-JS body), a runtime
executes it, and the script — not the conversation — holds the loop, the
branching, and the intermediate results.
- dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary
(WorkflowRun whose result NEVER rejects) + observe-only workflow/* events
carrying data snapshots (id + meta, never the live run), per-listener
contained like subagent/*.
- dsh-workflow-vm: in-process node:vm engine. Meta extraction via a
string/comment-aware scanner (template interpolation rejected; literal
evaluated alone in an empty timed context; statement blanked line-
preservingly so stacks keep script line numbers). Hooks: agent(prompt,
{label, phase, schema, model}) over ctx.subagents, parallel(), pipeline()
(no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline:
hook misuse (unknown/deferred options, bad arguments, unsupported
schemas, tripped caps, seam start failures, cancellation) throws fatal
WorkflowErrors the combinators RE-THROW — never dissolved into the
per-item null reserved for child failures. Realm boundary: inbound values
materialized by descriptor walks that never invoke accessors (defineProperty
copies, __proto__-safe); outbound values rebuilt in-realm via the
context's own JSON.parse. Determinism bans (Date.now/Math.random/argless
new Date) kept so future resume support cannot break scripts. Caps and
timeouts are validated Config. Every hook promise carries a no-op
rejection consumer (app-boot exits on unhandled rejections).
- dsh-tool-workflow: the model-facing workflow tool, synchronous like
dsh-tool-subagent (start → await → try/finally dispose; abort bridged;
non-completed → isError). Generic render card titled by a textual
meta.name sniff. The tool description carries the authoring contract.
Wired into examples/{coding-agent,acp-agent} with explicit-ask-only
guidance. Coverage at every tier: unit (meta scanner, materializer incl.
counting-getter and __proto__ regressions, combinator semantics,
concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon),
integration over the real spawn stack, with-key e2e (real two-phase run +
the tool through the registry pipeline), and a recorded ACP snapshot
scenario (workflow-run, 1 child session). RFC:
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred
work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's
layout line.
This commit is contained in:
@@ -18,6 +18,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
web/ web seam + search/fetch providers + model-facing web tools
|
||||
compact/ compaction seam + basic backend
|
||||
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool
|
||||
workflow/ workflow seam + node:vm script engine + the workflow tool
|
||||
todo/ the todo_write tool
|
||||
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
|
||||
session-persistence/ persistence seam + JSONL/SQLite backends
|
||||
|
||||
@@ -43,6 +43,7 @@ Dependency rule: extension plugins depend on interfaces, never on `dsh-agent-loo
|
||||
| `ctx.compact` | dsh-compact | compaction: detect pressure, summarize an older range |
|
||||
| `ctx.web` | dsh-web | search/fetch provider registries + `WebError` taxonomy |
|
||||
| `ctx.subagents` | dsh-subagent | named provider registry for delegating to child agents |
|
||||
| `ctx.workflows` | dsh-workflow | script-driven multi-agent orchestration: `start()` runs a workflow script |
|
||||
|
||||
All registrations go through `ctx.effect()` and return disposers, so hot-reload and fiber disposal clean up automatically (full service interfaces: the generated [services catalog](cordis-catalog/services.md)).
|
||||
|
||||
|
||||
@@ -313,6 +313,68 @@ Types: [ToolExecution](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:76`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `workflow/*`
|
||||
|
||||
### `workflow/agent-end` — emit
|
||||
|
||||
One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'].
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/agent-start` — emit
|
||||
|
||||
One `agent()` call started a child run. Paired with Events['workflow/agent-end'] by `agent.seq`.
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:83`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/end` — emit
|
||||
|
||||
A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start'].
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:101`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/log` — emit
|
||||
|
||||
The script emitted a narration line (a `log(message)` call).
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/phase` — emit
|
||||
|
||||
The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics.
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/start` — emit
|
||||
|
||||
A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end'].
|
||||
|
||||
```ts cordis-catalog
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## Inherited events (cordis core + loader/hmr/timer)
|
||||
|
||||
The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence.
|
||||
|
||||
@@ -227,6 +227,22 @@ async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchRe
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts)
|
||||
|
||||
## `ctx.workflows` — `WorkflowService` (abstract seam)
|
||||
|
||||
Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
|
||||
- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`).
|
||||
- The `workflow/*` events fire through emitWorkflowEvent (data snapshots, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles.
|
||||
- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle, and abandons a stuck script rather than hanging its caller (the engine documents what abandonment leaves behind).
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:188`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## Inherited `ctx` members (cordis core + loader/hmr/timer)
|
||||
|
||||
The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Workflow
|
||||
|
||||
The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident).
|
||||
|
||||
Interface: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The implementation is [dsh-workflow-vm](../../packages/workflow/workflow-vm) (an in-process `node:vm` engine); the model-facing consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows RFC](../rfc/implemented/feature/2026-07-05-dynamic-workflows.md).
|
||||
|
||||
Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts)
|
||||
|
||||
## The start request
|
||||
|
||||
What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, args }` plus the calling agent; the engine validates the script's meta block BEFORE the body runs. `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). `args` must be plain host-realm JSON data; the engine exposes it to the script as the `args` global.
|
||||
|
||||
```ts type-equiv
|
||||
interface WorkflowStartRequest {
|
||||
script: string
|
||||
args?: unknown
|
||||
parent: Agent
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
## The script's identity: `WorkflowMeta`
|
||||
|
||||
The validated `export const meta` block (Claude Code dynamic-workflows format — a PURE object literal heading the script). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied.
|
||||
|
||||
```ts type-equiv
|
||||
interface WorkflowMeta {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
phases?: WorkflowPhase[]
|
||||
}
|
||||
```
|
||||
|
||||
## The terminal result: `WorkflowResult`
|
||||
|
||||
The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script's materialized return value — plain host-realm JSON data (`null` when the script returned nothing) — meaningful only for `completed`. `stopReason` is a CLOSED union (engine-owned; consumers may exhaust it): `completed` | `cancelled` | `error`. A non-`completed` reason carries the failure in `error`, and the consumer maps it to an `isError` tool result rather than reporting partial output as success.
|
||||
|
||||
```ts type-equiv
|
||||
interface WorkflowResult {
|
||||
value: unknown
|
||||
stopReason: WorkflowStopReason
|
||||
error?: string
|
||||
agentsStarted: number
|
||||
}
|
||||
```
|
||||
|
||||
## A live run: `WorkflowRun`
|
||||
|
||||
The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result. `dispose()` cancels, waits a bounded grace for the script to settle, then abandons it (the engine documents the abandonment semantics); it never hangs on a stuck script.
|
||||
|
||||
```ts type-equiv
|
||||
interface WorkflowRun {
|
||||
readonly id: WorkflowRunId
|
||||
readonly meta: WorkflowMeta
|
||||
readonly result: Promise<WorkflowResult>
|
||||
cancel(reason?: string): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Failure discipline: `WorkflowError.fatal`
|
||||
|
||||
Hook misuse inside a script — bad arguments, unknown/deferred `agent()` options, a schema outside the [structured-output subset](../../packages/core/tools/README.md), a tripped cap, a seam start failure, cancellation — throws a `WorkflowError` with `fatal: true`. The `parallel()`/`pipeline()` combinators RE-THROW fatal errors instead of mapping the item to `null`: a typo'd option must kill the script loudly, never dissolve into something that reads as an ordinary child failure. The per-item `null` is reserved for child-run failures (a non-`completed` stop reason) and ordinary in-stage script errors.
|
||||
|
||||
## Events
|
||||
|
||||
The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](../cordis-catalog/events.md)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — mirroring `subagent/start`/`subagent/end`.
|
||||
@@ -48,6 +48,9 @@ graph TD
|
||||
tools --> agent
|
||||
tools --> llm
|
||||
tools --> system-prompt
|
||||
workflow --> agent
|
||||
workflow --> brand
|
||||
workflow --> llm
|
||||
acp --> agent
|
||||
acp --> llm
|
||||
acp --> session
|
||||
@@ -83,6 +86,10 @@ graph TD
|
||||
tool-web --> system-prompt
|
||||
tool-web --> tools
|
||||
tool-web --> web
|
||||
tool-workflow --> agent
|
||||
tool-workflow --> llm
|
||||
tool-workflow --> tools
|
||||
tool-workflow --> workflow
|
||||
agent-core --> agent
|
||||
agent-core --> agent-loop
|
||||
agent-core --> invariants
|
||||
@@ -112,6 +119,12 @@ graph TD
|
||||
tool-subagent --> llm
|
||||
tool-subagent --> subagent
|
||||
tool-subagent --> tools
|
||||
workflow-vm --> agent
|
||||
workflow-vm --> brand
|
||||
workflow-vm --> llm
|
||||
workflow-vm --> subagent
|
||||
workflow-vm --> tools
|
||||
workflow-vm --> workflow
|
||||
acp-agent --> acp
|
||||
acp-agent --> agent-core
|
||||
acp-agent --> app-boot
|
||||
@@ -159,6 +172,7 @@ graph TD
|
||||
| `session-persistence-jsonl` | `session`, `session-persistence` |
|
||||
| `session-persistence-sqlite` | `session`, `session-persistence` |
|
||||
| `tools` | `agent`, `llm`, `system-prompt` |
|
||||
| `workflow` | `agent`, `brand`, `llm` |
|
||||
| `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` |
|
||||
| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
|
||||
| `hooks-codex` | `agent`, `hook-protocol`, `llm`, `session`, `tools` |
|
||||
@@ -167,12 +181,14 @@ graph TD
|
||||
| `tool-fs` | `fs`, `llm`, `session`, `system-prompt`, `tools` |
|
||||
| `tool-todo` | `agent`, `session`, `tools` |
|
||||
| `tool-web` | `llm`, `system-prompt`, `tools`, `web` |
|
||||
| `tool-workflow` | `agent`, `llm`, `tools`, `workflow` |
|
||||
| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
|
||||
| `hooks-claude` | `agent`, `hook-protocol`, `llm`, `session`, `subagent`, `tools` |
|
||||
| `subagent-acp` | `agent`, `llm`, `subagent` |
|
||||
| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent`, `tools` |
|
||||
| `subagent-mock` | `agent`, `llm`, `subagent` |
|
||||
| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` |
|
||||
| `workflow-vm` | `agent`, `brand`, `llm`, `subagent`, `tools`, `workflow` |
|
||||
| `acp-agent` | `acp`, `agent-core`, `app-boot`, `session-persistence-jsonl` |
|
||||
| `stdio-agent` | `agent`, `agent-core`, `app-boot`, `llm`, `session`, `session-persistence-jsonl` |
|
||||
| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` |
|
||||
|
||||
@@ -95,6 +95,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
|
||||
| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
|
||||
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
|
||||
| [Dynamic workflows — a script-driven multi-agent orchestration seam](implemented/feature/2026-07-05-dynamic-workflows.md) | 2026-07-05 |
|
||||
|
||||
### Simplification
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# RFC: Dynamic workflows — a script-driven multi-agent orchestration seam
|
||||
|
||||
- **Status**: implemented
|
||||
- **Class**: feature
|
||||
- **First proposed**: 2026-07-05
|
||||
|
||||
## Problem
|
||||
|
||||
The harness can delegate ONE task to ONE child (`dsh-tool-subagent`), but work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — forces the model to orchestrate turn by turn: every intermediate result lands in the parent context, the plan lives nowhere durable, and coordination costs a model round-trip per step. Claude Code ships this capability as [dynamic workflows](https://code.claude.com/docs/en/workflows): the model writes a JavaScript orchestration script, a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results.
|
||||
|
||||
## Proposal
|
||||
|
||||
A workflow capability family at `packages/workflow/` in the bash seam shape (interface / implementation / consumer), plus the structured-output foundation it needs on the subagent seam.
|
||||
|
||||
### The script contract (Claude Code-compatible)
|
||||
|
||||
A script is `export const meta = {...}` (a PURE object literal: `name`, `description`, optional `whenToUse`/`phases`) followed by a plain-JS body with top-level `await`, ending in `return <json-value>`. The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages; `Date.now()`/`Math.random()`/argless `new Date()` throw (kept banned so future resume support cannot break script compatibility).
|
||||
|
||||
One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest.
|
||||
|
||||
### The seam (dsh-workflow)
|
||||
|
||||
`ctx.workflows` is an abstract `WorkflowService` in the bash shape — one engine per context, no named-provider registry (engines are deployment swaps, not co-residents). `start(request)` throws synchronously for a script that cannot begin; a returned `WorkflowRun`'s `result` NEVER rejects (failures resolve as `stopReason: 'error' | 'cancelled'`). The `workflow/*` events are observe-only emits carrying DATA SNAPSHOTS (id + meta; `workflow/end` omits the result value), per-listener contained, mirroring `subagent/start`/`subagent/end` — control stays with the run's holder. Vocabulary details: [core-data-structures/workflow.md](../../../core-data-structures/workflow.md).
|
||||
|
||||
### The engine (dsh-workflow-vm): in-process node:vm
|
||||
|
||||
**Why node:vm and not isolated-vm/worker threads**: isolated-vm is in maintenance mode, needs `--no-node-snapshot` on EVERY consumer process (including the published bins) on Node ≥ 20, and falls back to node-gyp source builds; a worker-thread engine turns every hook into RPC and complicates the per-file coverage gate. Scripts are model-written — the same trust level as the model's existing bash access — so genuine sandboxing is not the current requirement. The interface/implementation split exists precisely so a hardened engine can swap in later. Accepted, documented limitations: vm is not a security boundary, and the vm timeout covers only the initial synchronous slice — a pathological synchronous spin after the first await cannot be killed in-process; `dispose()` cancels, waits a bounded grace, then abandons.
|
||||
|
||||
**Meta extraction**: a string/comment-aware brace scanner (template interpolation rejected) finds the literal; it is evaluated ALONE in an empty, timed vm context; the result must materialize to plain JSON data and pass shape validation (unknown fields rejected loud); the statement is blanked line-preservingly so stacks keep script line numbers.
|
||||
|
||||
**Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized.
|
||||
|
||||
**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
|
||||
|
||||
### The consumer (dsh-tool-workflow)
|
||||
|
||||
A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by a textual `meta.name` sniff (presentation is a pure function of args). The tool description IS the model-facing authoring spec. Examples load it with guidance to use workflows only on explicit user request — the harness has no ultracode-style effort gate.
|
||||
|
||||
### The foundation: structured output on the subagent seam
|
||||
|
||||
`agent({schema})` needs `SubagentStartRequest.outputSchema` to actually work; it was vocabulary without an implementation (`outputSchema: false` everywhere). Implemented in `dsh-subagent-inprocess` for both in-process backends: a globally registered `structured_output` capture tool whose per-child schema is enforced by a `prepend: true` `agent/request` listener doing FINAL-REQUEST enforcement (post-processing `await next()` — cooperative mutation would not survive a downstream listener returning a replacement request), an `agent/turn-continuation` veto after capture (no wasted extra model step), validation-retry in-turn via `ToolArgsError`, and a clean-finish nudge loop (`structuredNudgeRetries`). Lifetime is refcounted by backends (plugin lifetime) AND live runs (start → settle). The seam's `outputSchema` type became the raw JSON-Schema SUBSET (`StructuredOutputSchema` in dsh-tools: single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`; anything unenforced is rejected loud) — the schema travels verbatim to the model as the forced tool's parameters, so the wire format, not the author DSL, is the right vocabulary.
|
||||
|
||||
## What was rejected
|
||||
|
||||
- **Background execution as the default** (CC's shape): deferred; foreground-synchronous matches `dsh-tool-subagent`'s cut, and background semantics should be designed ONCE across bash/subagent/workflow rather than per-tool.
|
||||
- **Workflow-layer JSON parsing for `agent({schema})`**: duplicating a seam concern at one consumer while the seam's capability flag stayed dishonestly `false`.
|
||||
- **Meta as tool parameters instead of `export const meta`**: zero parsing, but scripts stop being self-contained artifacts and CC-authored scripts stop being drop-in.
|
||||
- **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss.
|
||||
|
||||
## Deferred (documented non-goals of this cut)
|
||||
|
||||
- **Background collection** (start tool → run id → completion notice → collect), designed alongside bash/subagent background unification.
|
||||
- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — the determinism bans already keep scripts resume-compatible.
|
||||
- **Saved/bundled workflows** (a `.deepseek/workflows/` registry, slash-command surface) and **script persistence to a run directory** (the tool-call event already records the script durably).
|
||||
- **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred).
|
||||
- **Engine hardening**: a worker-thread or isolated-vm engine behind the same seam (kills synchronous spins; adds memory limits).
|
||||
- **ACP progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it.
|
||||
- **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`).
|
||||
@@ -260,6 +260,45 @@ Record and update a structured task list for the current work. Send the ENTIRE l
|
||||
|
||||
Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-workflow`
|
||||
|
||||
### `workflow`
|
||||
|
||||
Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
|
||||
|
||||
The script MUST begin with `export const meta = {...}` — a PURE object literal (no variables, calls, or template interpolation) with required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.
|
||||
|
||||
Script-body hooks:
|
||||
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
|
||||
- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.
|
||||
- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.
|
||||
- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.
|
||||
|
||||
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
|
||||
|
||||
Constraints: concurrency and total-agent caps apply; `Date.now()`, `Math.random()`, and argless `new Date()` throw (pass timestamps via `args`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`)."
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"script"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/tool-workflow/src/index.ts`](../../packages/workflow/tool-workflow/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
### `web_fetch`
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
subagent_fork instead when the subtask needs THIS conversation's
|
||||
context: the child inherits the log so far.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a
|
||||
workflow or for large multi-agent orchestration: you write a
|
||||
JavaScript script (its description documents the exact format) that
|
||||
fans work out across many subagents with phases and structured
|
||||
results. For one or two delegations, prefer plain subagent calls.
|
||||
|
||||
For multi-step work, use the todo_write tool to track a task list:
|
||||
send the WHOLE list each call (it replaces the previous one), keep at
|
||||
most one task in_progress (exactly one while work remains), and mark a
|
||||
@@ -93,6 +99,18 @@
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
|
||||
# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent
|
||||
# backend above, plus the model-facing `workflow` tool. The model writes a
|
||||
# JavaScript orchestration script (meta + body); the engine runs it in-process
|
||||
# and fans agent() calls out as spawn children.
|
||||
- id: workflow-vm
|
||||
name: '@deepseek-ai/dsh-workflow-vm'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
# The model-facing todo_write tool: whole-list task tracking written to the
|
||||
# session log (todo/write), surfaced to the ACP client as a `plan` update.
|
||||
- id: tool-todo
|
||||
|
||||
@@ -78,6 +78,10 @@ const SCENARIOS: Scenario[] = [
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 },
|
||||
// The workflow tool: the model writes a one-child orchestration script; the
|
||||
// child runs as a spawn subagent inside the vm engine (its session is the
|
||||
// child fixture), and the tool result carries the script's return value.
|
||||
{ name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
// Hook matrix — one scenario per hook point × its headline Decision outcome,
|
||||
// across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in
|
||||
// workspace/). The block scenarios need no model call: a UserPromptSubmit hook
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{"type":"session","version":0,"id":"d6d69d2a-3933-445f-a84d-d8c1b941f5ce","createdAt":1783227490354,"cwd":"/tmp/acp-snap-cwd-I14oAK","parentSession":"fe74cfdb-40b4-45bd-b6fe-efd2b244c415"}
|
||||
{"type":"turn/start","seq":0,"time":1783227490354,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783227490354,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":1783227490355,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1783227491136,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1783227491137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783227491218,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1783227491246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783227491247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1783227491276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1783227491304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":24,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":25,"time":1783227491336,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2707,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783227491366,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783227491366,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"usage":{"inputTokens":2707,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783227491367,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783227491367,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,152 @@
|
||||
{"type":"session","version":0,"id":"fe74cfdb-40b4-45bd-b6fe-efd2b244c415","createdAt":1783227488560,"cwd":"/tmp/acp-snap-cwd-I14oAK"}
|
||||
{"type":"turn/start","seq":0,"time":1783227488563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783227488564,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted and this EXACT script (copy it verbatim):\nexport const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":1783227488565,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1783227489485,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1783227489486,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783227489604,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1783227489638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783227489639,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783227489665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783227489665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1783227489666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1783227489666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":1783227489692,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exact"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":1783227489693,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" provided"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":1783227489720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1783227489720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1783227489774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1783227489775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
|
||||
{"type":"assistant/chunk","seq":24,"time":1783227489781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":25,"time":1783227489781,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783227489811,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783227489841,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783227489841,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1783227489842,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":1783227489869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}}
|
||||
{"type":"assistant/chunk","seq":36,"time":1783227489869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":37,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":38,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":""}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"{"}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":1783227489961,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"script"}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":": "}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1783227489991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"export"}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" const"}}}
|
||||
{"type":"assistant/chunk","seq":47,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" meta"}}}
|
||||
{"type":"assistant/chunk","seq":48,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" ="}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" {"}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":1783227490021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" name"}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":1783227490049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":":"}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":1783227490049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" '"}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"sn"}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"apshot"}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"-flow"}}}
|
||||
{"type":"assistant/chunk","seq":56,"time":1783227490050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"',"}}}
|
||||
{"type":"assistant/chunk","seq":57,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" description"}}}
|
||||
{"type":"assistant/chunk","seq":58,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":":"}}}
|
||||
{"type":"assistant/chunk","seq":59,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" '"}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"one"}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":1783227490078,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" child"}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1783227490107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" for"}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" the"}}}
|
||||
{"type":"assistant/chunk","seq":64,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" snapshot"}}}
|
||||
{"type":"assistant/chunk","seq":65,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"'"}}}
|
||||
{"type":"assistant/chunk","seq":66,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" }\\n"}}}
|
||||
{"type":"assistant/chunk","seq":67,"time":1783227490108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"phase"}}}
|
||||
{"type":"assistant/chunk","seq":68,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"('"}}}
|
||||
{"type":"assistant/chunk","seq":69,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"Run"}}}
|
||||
{"type":"assistant/chunk","seq":70,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"')\\n"}}}
|
||||
{"type":"assistant/chunk","seq":71,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"const"}}}
|
||||
{"type":"assistant/chunk","seq":72,"time":1783227490137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":73,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" ="}}}
|
||||
{"type":"assistant/chunk","seq":74,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" await"}}}
|
||||
{"type":"assistant/chunk","seq":75,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" agent"}}}
|
||||
{"type":"assistant/chunk","seq":76,"time":1783227490166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"('"}}}
|
||||
{"type":"assistant/chunk","seq":77,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"Reply"}}}
|
||||
{"type":"assistant/chunk","seq":78,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" with"}}}
|
||||
{"type":"assistant/chunk","seq":79,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" exactly"}}}
|
||||
{"type":"assistant/chunk","seq":80,"time":1783227490196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" the"}}}
|
||||
{"type":"assistant/chunk","seq":81,"time":1783227490224,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" word"}}}
|
||||
{"type":"assistant/chunk","seq":82,"time":1783227490224,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" WF"}}}
|
||||
{"type":"assistant/chunk","seq":83,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"_CH"}}}
|
||||
{"type":"assistant/chunk","seq":84,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"ILD"}}}
|
||||
{"type":"assistant/chunk","seq":85,"time":1783227490225,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":86,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" and"}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" nothing"}}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" else"}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":".')\\n"}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"return"}}}
|
||||
{"type":"assistant/chunk","seq":91,"time":1783227490253,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" {"}}}
|
||||
{"type":"assistant/chunk","seq":92,"time":1783227490278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":93,"time":1783227490279,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":" }"}}}
|
||||
{"type":"assistant/chunk","seq":94,"time":1783227490279,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"\""}}}
|
||||
{"type":"assistant/chunk","seq":95,"time":1783227490314,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","argumentsDelta":"}"}}}
|
||||
{"type":"assistant/chunk","seq":96,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the workflow tool with the exact script provided, args omitted, and then reply with \"WORKFLOW_DONE\" after it returns."}}}}
|
||||
{"type":"assistant/chunk","seq":97,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":98,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3124,"outputTokens":125,"cacheReadTokens":0,"reasoningTokens":33}}}}
|
||||
{"type":"assistant/chunk","seq":99,"time":1783227490347,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":100,"time":1783227490349,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the workflow tool with the exact script provided, args omitted, and then reply with \"WORKFLOW_DONE\" after it returns."},{"type":"tool-call","id":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}],"usage":{"inputTokens":3124,"outputTokens":125,"cacheReadTokens":0,"reasoningTokens":33}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":101,"time":1783227490349,"data":{"turn":1,"step":1,"callId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","name":"workflow","arguments":"{\"script\": \"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}
|
||||
{"type":"tool/result","seq":102,"time":1783227491372,"data":{"turn":1,"step":1,"callId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[101],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":103,"time":1783227491373,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":104,"time":1783227491373,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":105,"time":1783227491987,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":106,"time":1783227491988,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
|
||||
{"type":"assistant/chunk","seq":107,"time":1783227492202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}}
|
||||
{"type":"assistant/chunk","seq":108,"time":1783227492230,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}}
|
||||
{"type":"assistant/chunk","seq":109,"time":1783227492231,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
|
||||
{"type":"assistant/chunk","seq":110,"time":1783227492231,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":111,"time":1783227492259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
|
||||
{"type":"assistant/chunk","seq":112,"time":1783227492260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":113,"time":1783227492287,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":114,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}}
|
||||
{"type":"assistant/chunk","seq":115,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}}
|
||||
{"type":"assistant/chunk","seq":116,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}}
|
||||
{"type":"assistant/chunk","seq":117,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}}
|
||||
{"type":"assistant/chunk","seq":118,"time":1783227492288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
|
||||
{"type":"assistant/chunk","seq":119,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}
|
||||
{"type":"assistant/chunk","seq":120,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
|
||||
{"type":"assistant/chunk","seq":121,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
|
||||
{"type":"assistant/chunk","seq":122,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
|
||||
{"type":"assistant/chunk","seq":123,"time":1783227492317,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
|
||||
{"type":"assistant/chunk","seq":124,"time":1783227492345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
|
||||
{"type":"assistant/chunk","seq":125,"time":1783227492345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
|
||||
{"type":"assistant/chunk","seq":126,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
|
||||
{"type":"assistant/chunk","seq":127,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
|
||||
{"type":"assistant/chunk","seq":128,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
|
||||
{"type":"assistant/chunk","seq":129,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}}
|
||||
{"type":"assistant/chunk","seq":130,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}}
|
||||
{"type":"assistant/chunk","seq":131,"time":1783227492374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}}
|
||||
{"type":"assistant/chunk","seq":132,"time":1783227492411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}}
|
||||
{"type":"assistant/chunk","seq":133,"time":1783227492412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":134,"time":1783227492412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
|
||||
{"type":"assistant/chunk","seq":135,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
|
||||
{"type":"assistant/chunk","seq":136,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}}
|
||||
{"type":"assistant/chunk","seq":137,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
|
||||
{"type":"assistant/chunk","seq":138,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":139,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}}
|
||||
{"type":"assistant/chunk","seq":140,"time":1783227492435,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}}
|
||||
{"type":"assistant/chunk","seq":141,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}}
|
||||
{"type":"assistant/chunk","seq":142,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}}
|
||||
{"type":"assistant/chunk","seq":143,"time":1783227492464,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":144,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."}}}}
|
||||
{"type":"assistant/chunk","seq":145,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":146,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":87,"outputTokens":38,"cacheReadTokens":3200,"reasoningTokens":32}}}}
|
||||
{"type":"assistant/chunk","seq":147,"time":1783227492465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":148,"time":1783227492465,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"usage":{"inputTokens":87,"outputTokens":38,"cacheReadTokens":3200,"reasoningTokens":32}},"sourceEventSeqs":[105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":149,"time":1783227492465,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":150,"time":1783227492465,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,75 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exact"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" provided"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" args"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" omitted"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"export const meta = { name: 'snapshot-flow', description: 'one child for the snapshot' }\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }"}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OvqFfefZ8c0hnuo9Eq9f8866","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WF"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_CH"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WORK"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FL"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OW"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -46,7 +46,7 @@
|
||||
# under ./.sessions); unset starts a fresh session each run.
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).'
|
||||
welcome: 'agent REPL ready. Give it a coding task (its tools are read, write, edit, bash, subagent, workflow, and todo_write).'
|
||||
systemPrompt: |
|
||||
You are coding-agent, a CLI coding assistant.
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
subagent_fork instead when the subtask needs THIS conversation's
|
||||
context: the child inherits the log so far.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a
|
||||
workflow or for large multi-agent orchestration: you write a
|
||||
JavaScript script (its description documents the exact format) that
|
||||
fans work out across many subagents with phases and structured
|
||||
results. For one or two delegations, prefer plain subagent calls.
|
||||
|
||||
Check the [exit code: N] marker on every command; investigate
|
||||
failures before moving on. Verify your work by running the code or
|
||||
tests. Keep answers brief and factual.
|
||||
@@ -120,6 +126,18 @@
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
|
||||
# Dynamic workflows: the node:vm engine (ctx.workflows) over the spawn subagent
|
||||
# backend above, plus the model-facing `workflow` tool. The model writes a
|
||||
# JavaScript orchestration script (meta + body); the engine runs it in-process
|
||||
# and fans agent() calls out as spawn children.
|
||||
- id: workflow-vm
|
||||
name: '@deepseek-ai/dsh-workflow-vm'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
# The model-facing todo_write tool: whole-list task tracking written to the
|
||||
# session log (todo/write), rendered as a stdio checklist / ACP plan.
|
||||
- id: tool-todo
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"exclude": ["duplicates"],
|
||||
"ignoreWorkspaces": ["vendor/*"],
|
||||
"exclude": [
|
||||
"duplicates"
|
||||
],
|
||||
"ignoreWorkspaces": [
|
||||
"vendor/*"
|
||||
],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": [
|
||||
@@ -11,55 +15,138 @@
|
||||
"examples/acp-agent/tests/**/*.e2e.ts",
|
||||
"examples/acp-agent/tests/**/*.snapshot.ts"
|
||||
],
|
||||
"project": ["scripts/**/*.ts", "examples/**/*.ts"]
|
||||
"project": [
|
||||
"scripts/**/*.ts",
|
||||
"examples/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/*/*": {
|
||||
"entry": ["tests/**/*.spec.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/util/brand": {
|
||||
"project": ["src/**/*.ts"],
|
||||
"ignoreDependencies": ["cordis"]
|
||||
"project": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"cordis"
|
||||
]
|
||||
},
|
||||
"packages/llm/llm-deepseek": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/llm/llm-pi-ai": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/web/web-search-exa": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/web/web-search-perplexity": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/web/web-search-deepseek": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/ui/acp-agent": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/ui/stdio-agent": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/subagent/subagent-spawn": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/subagent/subagent-acp": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts",
|
||||
"tests/mock-acp-server.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/fs/tool-fs": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/workflow/workflow-vm": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the node:vm engine, and the model-facing `workflow` tool | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
|
||||
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -10,8 +10,8 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import * as spawn from '../../subagent-spawn/src/index.ts'
|
||||
import * as fork from '../../subagent-fork/src/index.ts'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as fork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import {
|
||||
acquireStructuredRuntime,
|
||||
STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# workflow/ — dynamic-workflow capability family
|
||||
|
||||
The workflow seam: a model-written JavaScript orchestration script that fans out subagents at scale (phases, structured per-agent results, concurrency caps), modeled on Claude Code's dynamic workflows. A capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) in the bash shape: ONE engine implementation per context registers as `ctx.workflows`; the model-facing tool consumes it.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` |
|
||||
| `workflow-vm/` | In-process `node:vm` engine: parses the script, injects the hooks, drives `ctx.subagents` | (provides `ctx.workflows`) |
|
||||
| `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The seam split exists for engine hardening: `node:vm` is in-process and cannot kill a pathological synchronous spin — a worker-thread or isolated-vm engine swaps in behind the same interface if that ever matters.
|
||||
|
||||
The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md).
|
||||
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-tool-workflow
|
||||
|
||||
The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. Pure schema + lifecycle shaping over [`ctx.workflows`](../workflow/README.md) — script parsing, execution, caps, and cancellation live behind the seam, so a hardened engine swaps in without touching what the model sees.
|
||||
|
||||
## What the model sees
|
||||
|
||||
Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, determinism bans, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason — never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. The completed result renders the meta name, the agent count, and the return value as JSON, truncated at `maxResultChars` with an explicit notice.
|
||||
|
||||
## Render intent
|
||||
|
||||
Decided up front (per the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, the name sniffed TEXTUALLY from `args.script` (presentation must be a pure function of args, so it cannot ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `toolName` | `workflow` | The model-facing tool name to register. |
|
||||
| `maxResultChars` | `50000` | Rendered-result ceiling; longer JSON is truncated with a notice. |
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-workflow",
|
||||
"description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workflow": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The model-facing `workflow` tool: run a JavaScript orchestration script that
|
||||
* fans out subagents, and return the script's final value. Pure schema +
|
||||
* lifecycle shaping — script parsing, execution, caps, and cancellation live
|
||||
* behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine
|
||||
* swaps in without touching what the model sees.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute`
|
||||
* starts a run and awaits `run.result` inside a `try/finally` that always
|
||||
* disposes the run, so the script and its children are torn down on every
|
||||
* path. A non-`completed` stop reason maps to an `isError` tool result (by
|
||||
* throwing) rather than returning partial output as success. Background
|
||||
* collection is deferred to the cross-tool background redesign.
|
||||
*
|
||||
* Render intent (decided up front, per the render-intent RFC): a `generic`
|
||||
* card whose title carries the script's `meta.name`, sniffed textually from
|
||||
* the args — presentation must be a pure function of `args`, so it cannot ask
|
||||
* the engine to parse.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-workflow
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
|
||||
|
||||
export const name = 'tool-workflow'
|
||||
export const inject = ['tools', 'workflows']
|
||||
|
||||
/** Config: the model-facing tool name plus result rendering caps. */
|
||||
export interface Config {
|
||||
/** The model-facing tool name to register (default `workflow`). */
|
||||
toolName?: string
|
||||
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
|
||||
maxResultChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
toolName: z.string().default('workflow'),
|
||||
maxResultChars: z.natural().min(1).default(50_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* The script-authoring contract, embedded in the tool description. This IS the
|
||||
* model-facing spec: the meta block, the hooks and their exact semantics, the
|
||||
* determinism bans, and the supported schema subset.
|
||||
*/
|
||||
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
|
||||
|
||||
The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
|
||||
|
||||
Script-body hooks:
|
||||
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
|
||||
- \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
|
||||
- \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
|
||||
- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
|
||||
|
||||
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`.
|
||||
|
||||
Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`
|
||||
|
||||
type WorkflowCallArgs = { script: string; args?: Record<string, unknown> }
|
||||
|
||||
/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */
|
||||
function sniffMetaName(script: string): string | undefined {
|
||||
const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script)
|
||||
return match?.[2]
|
||||
}
|
||||
|
||||
/** The pending-state card: a generic card titled by the script's meta name. */
|
||||
function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
|
||||
const name = sniffMetaName(args.script)
|
||||
return {
|
||||
card: 'generic',
|
||||
title: name !== undefined ? `workflow: ${name}` : 'workflow',
|
||||
rawInput: args.script,
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed-state card: keep the pending title; render the result content as-is. */
|
||||
function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
|
||||
void args
|
||||
void result
|
||||
return { card: 'generic' }
|
||||
}
|
||||
|
||||
/** A non-`completed` stop reason means the script did not finish cleanly. */
|
||||
function stopReasonError(result: WorkflowResult): string | undefined {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return undefined
|
||||
case 'cancelled':
|
||||
return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}`
|
||||
case 'error':
|
||||
return `workflow run failed: ${result.error ?? 'unknown error'}`
|
||||
/* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
|
||||
default:
|
||||
return `workflow run ended abnormally (${String(result.stopReason satisfies never)})`
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
|
||||
function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number): string {
|
||||
// The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
|
||||
const rendered = JSON.stringify(result.value, null, 2)
|
||||
const clipped = rendered.length > maxChars
|
||||
? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
|
||||
: rendered
|
||||
return `workflow "${run.meta.name}" completed (${result.agentsStarted} agent${result.agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxResultChars = config.maxResultChars ?? 50_000
|
||||
ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'workflow',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
script: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`).',
|
||||
},
|
||||
args: {
|
||||
type: 'object',
|
||||
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
|
||||
},
|
||||
},
|
||||
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 children to. Fail loud rather than guess.
|
||||
throw new Error('workflow tool requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
|
||||
// Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here
|
||||
// and become isError results via the registry — the model sees the
|
||||
// violation list and can correct the script.
|
||||
const run: WorkflowRun = ctx.workflows.start({
|
||||
script: args.script,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the script is in flight, cancel the whole run. The
|
||||
// engine also receives `signal` directly, but an explicit bridge keeps
|
||||
// the tool's contract local (and covers an engine that ignores it).
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before
|
||||
// this line — cancel explicitly in that case.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
|
||||
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.
|
||||
throw new Error(error)
|
||||
}
|
||||
return [{ type: 'text', text: renderResult(run, result, maxResultChars) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach run quiescence — never leak a live script or children.
|
||||
await run.dispose()
|
||||
}
|
||||
},
|
||||
presentCall: args => presentWorkflowCall(args),
|
||||
presentResult: (args, result) => presentWorkflowResult(args, result),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import * as toolWorkflow from '../src/index.ts'
|
||||
|
||||
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
|
||||
class StubEngine extends WorkflowService {
|
||||
requests: WorkflowStartRequest[] = []
|
||||
cancels: string[] = []
|
||||
disposed = 0
|
||||
settle!: (result: WorkflowResult) => void
|
||||
startError: Error | undefined
|
||||
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
if (this.startError) throw this.startError
|
||||
this.requests.push(request)
|
||||
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: WorkflowRunId('run-1'),
|
||||
meta: { name: 'stub-flow', description: 'd' },
|
||||
result,
|
||||
cancel: (reason?: string) => {
|
||||
this.cancels.push(reason ?? 'cancelled')
|
||||
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
|
||||
},
|
||||
dispose: () => {
|
||||
this.disposed += 1
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
await ctx.plugin(toolWorkflow, config ?? {})
|
||||
const engine = ctx.workflows as StubEngine
|
||||
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
|
||||
return { ctx, engine, parent }
|
||||
}
|
||||
|
||||
const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1"
|
||||
|
||||
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId('call-1'),
|
||||
name: 'workflow',
|
||||
arguments: args,
|
||||
...extra?.agent ? { agent: extra.agent } : {},
|
||||
...extra?.signal ? { signal: extra.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('dsh-tool-workflow', () => {
|
||||
it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent })
|
||||
expect(engine.requests[0]!.signal).toBe(controller.signal)
|
||||
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(false)
|
||||
const rendered = (result.content[0] as { text: string }).text
|
||||
expect(rendered).toContain('workflow "stub-flow" completed (7 agents)')
|
||||
expect(rendered).toContain('"findings"')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a cancelled run distinctly (with and without a reason)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
|
||||
|
||||
const bare = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
|
||||
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
|
||||
expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
|
||||
})
|
||||
|
||||
it('an error result without a message renders the unknown-error fallback', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
|
||||
expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
|
||||
})
|
||||
|
||||
it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
controller.abort()
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('applies raw-config fallbacks when loaded without schemastery defaults (direct apply)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
// Direct apply with an empty RAW config: the `??` fallbacks resolve the
|
||||
// tool name and render cap without schemastery having filled them.
|
||||
toolWorkflow.apply(ctx, {})
|
||||
expect(ctx.tools.get('workflow')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
engine.startError = new Error('script must begin with `export const meta = {...}`')
|
||||
const result = await execute(ctx, { script: 'nope' }, { agent: parent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('must begin with')
|
||||
})
|
||||
|
||||
it('requires a calling agent (fails loud without exec.agent)', async () => {
|
||||
const { ctx, engine } = await setup()
|
||||
const result = await execute(ctx, { script: SCRIPT })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
|
||||
expect(engine.requests.length).toBe(0)
|
||||
})
|
||||
|
||||
it('validates its own arguments via the schema DSL (missing script)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await execute(ctx, {}, { agent: parent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
|
||||
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
|
||||
const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
|
||||
const rendered = ((await pending).content[0] as { text: string }).text
|
||||
expect(rendered).toContain('[truncated:')
|
||||
expect(rendered.length).toBeLessThan(400)
|
||||
})
|
||||
|
||||
it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' })
|
||||
expect(ctx.tools.get('orchestrate')).toBeDefined()
|
||||
expect(ctx.tools.get('workflow')).toBeUndefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('orchestrate')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => {
|
||||
const { ctx } = await setup()
|
||||
const tool = ctx.tools.get('workflow')!
|
||||
const view = tool.presentCall!({ script: SCRIPT })
|
||||
expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
|
||||
const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' })
|
||||
expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' })
|
||||
})
|
||||
|
||||
it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
|
||||
const { ctx } = await setup()
|
||||
const tool = ctx.tools.get('workflow')!
|
||||
expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' })
|
||||
// defineTool soft-validates presentation args: a malformed logged shape
|
||||
// falls back to undefined instead of throwing mid-replay.
|
||||
expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in toolWorkflow).toBe(false)
|
||||
expect(toolWorkflow.name).toBe('tool-workflow')
|
||||
expect(toolWorkflow.inject).toEqual(['tools', 'workflows'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolWorkflow) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolWorkflow)
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# @deepseek-ai/dsh-workflow-vm
|
||||
|
||||
The first [`WorkflowService`](../workflow/README.md) implementation: an in-process **`node:vm` engine**. It parses the Claude Code-format script (`export const meta = {...}` + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans `agent()` calls out to [`ctx.subagents`](../../subagent/README.md).
|
||||
|
||||
## The script contract it executes
|
||||
|
||||
- **Meta extraction** (`extractMeta`): a string/comment-aware brace scanner finds the leading `export const meta` literal (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (`name`/`description` required; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers.
|
||||
- **Hooks**: `agent(prompt, {label, phase, schema, model})` (schema = the [structured-output subset](../../core/tools/README.md), forwarded as `outputSchema`; result = validated object, or final text without a schema; a failed child resolves `null`), `parallel(thunks)`, `pipeline(items, ...stages)` with NO cross-stage barrier and `(prev, item, index)` stage callbacks, `phase(title)`, `log(message)`, and the `args` global. Anything else — `effort`/`isolation`/`agentType`, unknown options, malformed arguments, schemas outside the subset — throws a FATAL `WorkflowError` that `parallel`/`pipeline` re-throw rather than nulling (see the seam README's failure discipline).
|
||||
- **Determinism bans**: `Date.now()`, `Math.random()`, and argless `new Date()` throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context.
|
||||
|
||||
## Realm discipline
|
||||
|
||||
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by `materializeFromRealm`: a descriptor walk that never invokes accessors and rejects loud everything JSON cannot carry (accessors, exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying into host containers via `defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values ENTERING the realm (`args`, `agent()` results) are rebuilt INSIDE the realm through the context's own `JSON.parse`, so the script never holds an object whose prototype chain reaches host intrinsics.
|
||||
|
||||
## Limits, cancellation, disposal
|
||||
|
||||
Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`. Once a run settles, stray children a script fired without awaiting are aborted too. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those).
|
||||
|
||||
**Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop).
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `provider` | `spawn` | The `ctx.subagents` provider children run on. |
|
||||
| `maxConcurrentAgents` | `0` (auto) | Concurrent `agent()` ceiling; `0` resolves to `min(16, max(1, cores - 2))`. |
|
||||
| `maxTotalAgents` | `1000` | Total `agent()` calls one run may start (runaway-loop backstop). |
|
||||
| `maxItemsPerCall` | `4096` | Items accepted by one `parallel()`/`pipeline()` call. |
|
||||
| `syncTimeoutMs` | `5000` | vm timeout for the initial synchronous slice and the meta evaluation. |
|
||||
| `disposeGraceMs` | `5000` | How long `dispose()` waits for a cancelled script before abandoning it. |
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-workflow-vm",
|
||||
"description": "node:vm workflow engine: executes model-written orchestration scripts over ctx.subagents",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workflow": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The `node:vm` workflow engine: the first {@link WorkflowService}
|
||||
* implementation. Parses the Claude Code-format script (meta + body), runs the
|
||||
* body in a fresh in-process vm context with the workflow hooks injected, and
|
||||
* fans `agent()` calls out to `ctx.subagents`.
|
||||
*
|
||||
* Engine limitations, documented as the accepted cost of the in-process
|
||||
* mechanism (the interface/implementation seam exists precisely so a
|
||||
* worker-thread or isolated-vm engine can swap in if these ever matter):
|
||||
*
|
||||
* - vm is NOT a security boundary. Scripts are model-written — the same trust
|
||||
* level as the model's bash access — and the realm-boundary materialization
|
||||
* is correctness containment, not a sandbox.
|
||||
* - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script;
|
||||
* a pathological synchronous spin after the first await cannot be killed
|
||||
* in-process. `dispose()` therefore waits a bounded grace and then ABANDONS
|
||||
* a stuck script: its pending hook promises are already rejected and its
|
||||
* settlement is contained (no unhandled rejection), but an abandoned
|
||||
* synchronous spin would still occupy the event loop.
|
||||
*
|
||||
* Plugin export shape: a default-exported {@link WorkflowService} subclass
|
||||
* (the class-based service form, like `dsh-bash-local`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { extractMeta } from './meta.ts'
|
||||
import { WorkflowExecution, type ExecutionLimits } from './runtime.ts'
|
||||
|
||||
export { extractMeta, type ExtractedScript } from './meta.ts'
|
||||
export { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts'
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** The `ctx.subagents` provider children run on (default `spawn`). */
|
||||
provider?: string
|
||||
/** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
|
||||
maxConcurrentAgents?: number
|
||||
/** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
|
||||
maxTotalAgents?: number
|
||||
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
|
||||
maxItemsPerCall?: number
|
||||
/** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */
|
||||
syncTimeoutMs?: number
|
||||
/** How long `dispose()` waits for a cancelled script to settle before abandoning it (default 5000 ms). */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The vm engine service. `start()` validates the script up front (meta +
|
||||
* body compile) and returns a {@link WorkflowRun} whose `result` never
|
||||
* rejects; the `workflow/*` events fire around the run per the seam contract.
|
||||
*/
|
||||
export class VmWorkflowEngine extends WorkflowService {
|
||||
static inject = ['subagents']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().default('spawn'),
|
||||
maxConcurrentAgents: z.natural().default(0),
|
||||
maxTotalAgents: z.natural().min(1).default(1000),
|
||||
maxItemsPerCall: z.natural().min(1).default(4096),
|
||||
syncTimeoutMs: z.natural().min(1).default(5000),
|
||||
disposeGraceMs: z.natural().default(5000),
|
||||
})
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// schemastery (static Config) has already filled the defaulted fields;
|
||||
// the assertion records that resolution, not a hidden fallback.
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script. Throws {@link WorkflowError}
|
||||
* synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot
|
||||
* begin; once a run is returned, every failure resolves through
|
||||
* `result.stopReason` instead.
|
||||
* @param request - the script, its `args`, the parent agent, and an
|
||||
* optional cancel signal.
|
||||
* @returns the live run (its `result` resolves when the script settles).
|
||||
*/
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs)
|
||||
const id = WorkflowRunId(randomUUID())
|
||||
// The event payloads and the run handle get SEPARATE meta clones: a
|
||||
// listener mutating its snapshot must not corrupt the holder's view.
|
||||
const info: WorkflowRunInfo = { id, meta: structuredClone(meta) }
|
||||
const limits: ExecutionLimits = {
|
||||
provider: this.config.provider,
|
||||
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
|
||||
? Math.min(16, Math.max(1, availableParallelism() - 2))
|
||||
: this.config.maxConcurrentAgents,
|
||||
maxTotalAgents: this.config.maxTotalAgents,
|
||||
maxItemsPerCall: this.config.maxItemsPerCall,
|
||||
syncTimeoutMs: this.config.syncTimeoutMs,
|
||||
}
|
||||
const execution = new WorkflowExecution(
|
||||
this.ctx,
|
||||
meta,
|
||||
body,
|
||||
request.parent,
|
||||
request.args,
|
||||
request.signal,
|
||||
limits,
|
||||
{
|
||||
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
|
||||
log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
|
||||
agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
|
||||
agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
|
||||
},
|
||||
)
|
||||
|
||||
this.emitWorkflowEvent('workflow/start', info)
|
||||
const result: Promise<WorkflowResult> = execution.drive()
|
||||
// `workflow/end` fires as the (never-rejecting) result settles, with the
|
||||
// outcome DATA only — the value stays with the run's holder.
|
||||
void result.then((settled) => {
|
||||
this.emitWorkflowEvent('workflow/end', info, {
|
||||
stopReason: settled.stopReason,
|
||||
...settled.error !== undefined ? { error: settled.error } : {},
|
||||
agentsStarted: settled.agentsStarted,
|
||||
})
|
||||
})
|
||||
|
||||
let disposed: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
meta: structuredClone(meta),
|
||||
result,
|
||||
cancel(reason?: string): void {
|
||||
execution.cancel(reason)
|
||||
},
|
||||
dispose: (): Promise<void> => {
|
||||
// Idempotent: cancel, then wait min(settle, grace). `result` never
|
||||
// rejects, so the race needs no rejection handling; an unsettled
|
||||
// script past the grace is abandoned per the module contract.
|
||||
disposed ??= (async () => {
|
||||
execution.cancel('workflow disposed')
|
||||
await Promise.race([result, sleep(this.config.disposeGraceMs)])
|
||||
})()
|
||||
return disposed
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
timer.unref()
|
||||
})
|
||||
}
|
||||
|
||||
export default VmWorkflowEngine
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Meta-block extraction: turn a Claude Code-format workflow script —
|
||||
* `export const meta = {...}` followed by a plain-JS body — into a validated
|
||||
* {@link WorkflowMeta} plus the body with the meta statement blanked
|
||||
* line-preservingly (error stacks keep the script's own line numbers).
|
||||
*
|
||||
* The scanner is a small string/comment-aware brace matcher, not a JS parser:
|
||||
* it only has to find the END of the meta object literal, and the literal is
|
||||
* contractually PURE (no interpolation, no computed values). Template strings
|
||||
* are tolerated as plain quotes but `${` inside one is rejected up front —
|
||||
* interpolation is where "literal" stops being checkable by evaluation. The
|
||||
* extracted text is then evaluated ALONE in an empty, timed vm context (a
|
||||
* non-literal reference throws there; an expression can still RUN, so the
|
||||
* result — not the source — is the contract: it must materialize to plain
|
||||
* JSON data and pass the shape validation).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/meta
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
|
||||
/** The result of {@link extractMeta}: the validated meta and the runnable body. */
|
||||
export interface ExtractedScript {
|
||||
meta: WorkflowMeta
|
||||
/** The script with the meta statement blanked (newlines preserved). */
|
||||
body: string
|
||||
}
|
||||
|
||||
const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/
|
||||
|
||||
/**
|
||||
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
||||
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
||||
* index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
|
||||
* interpolation (`${` inside a backtick string) or an unterminated literal.
|
||||
*/
|
||||
function scanObjectLiteral(source: string, start: number): number {
|
||||
let depth = 0
|
||||
let index = start
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '/' && source[index + 1] === '/') {
|
||||
const end = source.indexOf('\n', index)
|
||||
index = end === -1 ? source.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (ch === '/' && source[index + 1] === '*') {
|
||||
const end = source.indexOf('*/', index + 2)
|
||||
if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
|
||||
index = end + 2
|
||||
continue
|
||||
}
|
||||
if (ch === '\'' || ch === '"' || ch === '`') {
|
||||
index = scanString(source, index, ch)
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[') depth += 1
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
|
||||
function scanString(source: string, start: number, quote: string): number {
|
||||
let index = start + 1
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (quote === '`' && ch === '$' && source[index + 1] === '{') {
|
||||
throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
if (ch === quote) return index + 1
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
|
||||
function blankSpan(source: string, from: number, to: number): string {
|
||||
const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
|
||||
return source.slice(0, from) + blanked + source.slice(to)
|
||||
}
|
||||
|
||||
/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
|
||||
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
|
||||
const violations: string[] = []
|
||||
/* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
return { violations: ['meta must be an object literal'] }
|
||||
}
|
||||
const record = meta as Record<string, unknown>
|
||||
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
|
||||
}
|
||||
if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
|
||||
if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
|
||||
if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
|
||||
const phases: WorkflowPhase[] = []
|
||||
if (record.phases !== undefined) {
|
||||
if (!Array.isArray(record.phases)) {
|
||||
violations.push('meta.phases must be an array')
|
||||
} else {
|
||||
record.phases.forEach((phase, index) => {
|
||||
if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
|
||||
violations.push(`meta.phases[${index}] must be an object`)
|
||||
return
|
||||
}
|
||||
const entry = phase as Record<string, unknown>
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
|
||||
}
|
||||
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
|
||||
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
|
||||
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
|
||||
if (violations.length === 0) {
|
||||
phases.push({
|
||||
title: entry.title as string,
|
||||
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
|
||||
...entry.model !== undefined ? { model: entry.model as string } : {},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return { violations }
|
||||
return {
|
||||
violations,
|
||||
meta: {
|
||||
name: record.name as string,
|
||||
description: record.description as string,
|
||||
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
|
||||
...record.phases !== undefined ? { phases } : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate the leading `export const meta = {...}` statement.
|
||||
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
|
||||
* or unscannable, `META_INVALID` when the literal evaluates to something
|
||||
* outside the meta contract (non-JSON data, wrong shape, unknown fields).
|
||||
* @param script - the full script text.
|
||||
* @param evalTimeoutMs - the vm timeout for evaluating the extracted literal.
|
||||
* @returns the validated meta and the line-preservingly blanked body.
|
||||
*/
|
||||
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
|
||||
const match = META_PREFIX.exec(script)
|
||||
if (!match) {
|
||||
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalStart = match[0].length
|
||||
if (script[literalStart] !== '{') {
|
||||
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalEnd = scanObjectLiteral(script, literalStart)
|
||||
const literal = script.slice(literalStart, literalEnd)
|
||||
|
||||
let evaluated: unknown
|
||||
try {
|
||||
// An EMPTY context: any non-literal reference (a variable, a call) throws
|
||||
// here. The result — data only — is what the contract checks; a getter or
|
||||
// IIFE can still run, which is why the timeout and the materialization
|
||||
// below are part of the same boundary.
|
||||
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${String(error)}`, 'META_INVALID', { cause: error })
|
||||
}
|
||||
let data: unknown
|
||||
try {
|
||||
data = materializeFromRealm(evaluated, 'meta')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
|
||||
}
|
||||
const { meta, violations } = validateMetaShape(data)
|
||||
if (meta === undefined) {
|
||||
throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID')
|
||||
}
|
||||
|
||||
// Blank the whole statement (including a trailing semicolon, if any) so the
|
||||
// body compiles standalone with its original line numbers.
|
||||
let statementEnd = literalEnd
|
||||
while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1
|
||||
if (script[statementEnd] === ';') statementEnd += 1
|
||||
return { meta, body: blankSpan(script, 0, statementEnd) }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Realm-boundary materialization for the vm engine.
|
||||
*
|
||||
* Values produced INSIDE the script realm (the meta literal, hook arguments,
|
||||
* the script's return value) must become plain host-realm JSON data before the
|
||||
* host touches them. The repo's `isJsonValue` guard cannot run first: it is
|
||||
* prototype-strict (any cross-realm object fails it) and it INVOKES getters
|
||||
* (letting realm code run outside the vm's timed window). So this module walks
|
||||
* own-property DESCRIPTORS — never invoking accessors — and copies data into
|
||||
* host containers, rejecting loud everything JSON cannot carry:
|
||||
* accessor properties, non-plain prototypes, functions, symbols (keys or
|
||||
* values), bigints, non-finite numbers, `undefined` values, cycles, sparse
|
||||
* arrays, and arrays with non-index own properties.
|
||||
*
|
||||
* Host objects are built with `Object.defineProperty` into a fresh `{}` —
|
||||
* never plain `target[key] =` assignment, which a `"__proto__"` key would turn
|
||||
* into prototype mutation instead of a data property.
|
||||
*
|
||||
* The host→realm direction deliberately does NOT live here: a host object
|
||||
* handed into the realm would expose host intrinsics through its prototype
|
||||
* chain, so the engine rebuilds inbound values INSIDE the realm via the
|
||||
* context's own `JSON.parse` (see the runtime).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/realm
|
||||
*/
|
||||
|
||||
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
|
||||
export class MaterializeError extends Error {
|
||||
constructor(public readonly path: string, public readonly reason: string) {
|
||||
super(`${path}: ${reason}`)
|
||||
this.name = 'MaterializeError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object's prototype chain is data-shaped: `null`, or a prototype
|
||||
* whose own prototype is `null` (the realm's `Object.prototype` — which we
|
||||
* cannot compare by identity across realms). A `Date`/`Map`/class instance
|
||||
* has a longer chain and is rejected.
|
||||
*/
|
||||
function hasPlainPrototype(value: object): boolean {
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
if (proto === null) return true
|
||||
return Object.getPrototypeOf(proto) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `value` (typically from the vm realm) into plain host JSON data.
|
||||
* Throws {@link MaterializeError} naming the offending path for anything JSON
|
||||
* cannot carry losslessly. Accessors are detected via descriptors and NEVER
|
||||
* invoked. `undefined` is accepted only at the ROOT (a script with no
|
||||
* `return` value) — the caller decides what it means; an `undefined` nested
|
||||
* INSIDE a container is a violation.
|
||||
* @param value - the realm value to materialize.
|
||||
* @param root - the path label for the root value (error messages).
|
||||
* @returns the host-realm copy (plain objects/arrays/scalars only).
|
||||
*/
|
||||
export function materializeFromRealm(value: unknown, root = 'value'): unknown {
|
||||
if (value === undefined) return undefined
|
||||
return materialize(value, root, new Set())
|
||||
}
|
||||
|
||||
function materialize(value: unknown, path: string, seen: Set<object>): unknown {
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return value
|
||||
case 'number': {
|
||||
if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
|
||||
return value
|
||||
}
|
||||
case 'bigint':
|
||||
throw new MaterializeError(path, 'bigints are not JSON data')
|
||||
case 'function':
|
||||
throw new MaterializeError(path, 'functions cannot cross the workflow realm boundary')
|
||||
case 'symbol':
|
||||
throw new MaterializeError(path, 'symbols cannot cross the workflow realm boundary')
|
||||
case 'undefined':
|
||||
throw new MaterializeError(path, 'undefined is not JSON data')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
if (value === null) return null
|
||||
const objectValue: object = value
|
||||
if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
|
||||
seen.add(objectValue)
|
||||
try {
|
||||
if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
|
||||
return materializeObject(objectValue, path, seen)
|
||||
} finally {
|
||||
seen.delete(objectValue)
|
||||
}
|
||||
}
|
||||
|
||||
function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
|
||||
const out: unknown[] = []
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, index)
|
||||
if (descriptor === undefined) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
|
||||
if (!('value' in descriptor)) throw new MaterializeError(`${path}[${index}]`, 'accessor properties cannot cross the workflow realm boundary')
|
||||
out.push(materialize(descriptor.value, `${path}[${index}]`, seen))
|
||||
}
|
||||
// Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
|
||||
// silently dropped by JSON — reject them instead.
|
||||
for (const key of Object.keys(value)) {
|
||||
const index = Number(key)
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length) {
|
||||
throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
|
||||
}
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
|
||||
if (!hasPlainPrototype(value)) {
|
||||
throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
||||
// Non-enumerable own props never reach JSON output — skip them, matching
|
||||
// JSON.stringify's contract exactly (documented in the module doc).
|
||||
if (!descriptor.enumerable) continue
|
||||
if (!('value' in descriptor)) {
|
||||
throw new MaterializeError(`${path}.${key}`, 'accessor properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
// defineProperty, never assignment: a "__proto__" key must become an OWN
|
||||
// data property of the copy, not a prototype mutation.
|
||||
Object.defineProperty(out, key, {
|
||||
value: materialize(descriptor.value, `${path}.${key}`, seen),
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Per-run execution state for the vm workflow engine: the script context and
|
||||
* its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the
|
||||
* concurrency semaphore and caps, cancellation, and the drive loop that turns
|
||||
* a script settlement into a {@link WorkflowResult}.
|
||||
*
|
||||
* Realm discipline (see also ./realm.ts): values ENTERING the host from the
|
||||
* script (hook options, schemas, the return value) are materialized via
|
||||
* descriptor walks; values ENTERING the realm from the host (`args`, agent()
|
||||
* results) are rebuilt INSIDE the realm through the context's own
|
||||
* `JSON.parse`, so the script never holds an object whose prototype chain
|
||||
* reaches host intrinsics. Realm functions (pipeline stages, parallel thunks)
|
||||
* are called, not materialized — their values stay realm-side.
|
||||
*
|
||||
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
||||
* unsupported options/schemas, tripped caps, seam start failures,
|
||||
* cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item
|
||||
* `null` is reserved for child-run failures and ordinary in-stage script
|
||||
* errors. Every hook-returned promise gets a no-op rejection consumer
|
||||
* attached, so a script that drops a promise (fires an `agent()` without
|
||||
* awaiting it) cannot surface an unhandled rejection when cancellation
|
||||
* rejects it — the app boot layer exits the process on unhandled rejections.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/runtime
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowMeta,
|
||||
WorkflowResult,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
|
||||
/** The per-run knobs the engine resolves from its Config. */
|
||||
export interface ExecutionLimits {
|
||||
/** The `ctx.subagents` provider name to start children on. */
|
||||
provider: string
|
||||
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
|
||||
maxConcurrentAgents: number
|
||||
/** Total `agent()` calls per run (the runaway-loop backstop). */
|
||||
maxTotalAgents: number
|
||||
/** Items accepted by one `parallel()`/`pipeline()` call. */
|
||||
maxItemsPerCall: number
|
||||
/** vm timeout for the script's initial synchronous slice. */
|
||||
syncTimeoutMs: number
|
||||
}
|
||||
|
||||
/** The engine-side observers the execution reports progress through. */
|
||||
export interface ExecutionObserver {
|
||||
phase(title: string): void
|
||||
log(message: string): void
|
||||
agentStart(info: WorkflowAgentInfo): void
|
||||
agentEnd(info: WorkflowAgentEndInfo): void
|
||||
}
|
||||
|
||||
/** The `agent()` options the script may pass; everything else rejects loud. */
|
||||
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model'])
|
||||
/** Deferred Claude Code options we name explicitly in the rejection message. */
|
||||
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
|
||||
|
||||
/** The in-context prelude that bans the nondeterminism sources (kept even though resume is deferred, so scripts stay resume-compatible). */
|
||||
const DETERMINISM_PRELUDE = `
|
||||
{
|
||||
const banned = (name) => () => {
|
||||
throw new Error(name + ' is not available in workflow scripts (runs must stay deterministic for future resume support; pass timestamps in via args)')
|
||||
}
|
||||
Math.random = banned('Math.random()')
|
||||
Date.now = banned('Date.now()')
|
||||
const RealDate = Date
|
||||
globalThis.Date = new Proxy(RealDate, {
|
||||
construct(target, args, newTarget) {
|
||||
if (args.length === 0) banned('argless new Date()')()
|
||||
return Reflect.construct(target, args, newTarget)
|
||||
},
|
||||
apply: banned('Date()'),
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
|
||||
function outputText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a script failure for the result: prefer the stack (it carries the
|
||||
* script's own line numbers via the compile lineOffset), then the message.
|
||||
* STRUCTURAL detection, not `instanceof Error` — a realm-thrown Error is not
|
||||
* an instance of the host Error class.
|
||||
*/
|
||||
function errorText(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const maybe = error as { stack?: unknown; message?: unknown }
|
||||
if (typeof maybe.stack === 'string' && maybe.stack.length > 0) return maybe.stack
|
||||
if (typeof maybe.message === 'string') return maybe.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** A short display label derived from the prompt when the script passes none. */
|
||||
function defaultLabel(prompt: string): string {
|
||||
const newline = prompt.indexOf('\n')
|
||||
const line = newline === -1 ? prompt : prompt.slice(0, newline)
|
||||
return line.length <= 48 ? line : `${line.slice(0, 47)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* One live script execution. Constructed per run by the engine; `drive()` is
|
||||
* called exactly once and NEVER rejects — every failure becomes a
|
||||
* {@link WorkflowResult} with a non-`completed` stop reason.
|
||||
*/
|
||||
export class WorkflowExecution {
|
||||
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
|
||||
private started = 0
|
||||
private activeSlots = 0
|
||||
private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
|
||||
private cancelReason: string | undefined
|
||||
private cancelError: WorkflowError | undefined
|
||||
private readonly controller = new AbortController()
|
||||
private currentPhase: string | undefined
|
||||
private readonly context: vm.Context
|
||||
private readonly realmJsonParse: (text: string) => unknown
|
||||
private readonly compiled: vm.Script
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
meta: WorkflowMeta,
|
||||
body: string,
|
||||
private readonly parent: Agent,
|
||||
args: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
private readonly limits: ExecutionLimits,
|
||||
private readonly observer: ExecutionObserver,
|
||||
) {
|
||||
// Compile FIRST: a body syntax error must throw out of the constructor
|
||||
// (the engine maps it to SCRIPT_PARSE) before any realm state exists.
|
||||
// lineOffset compensates for the wrapper line, so stack traces carry the
|
||||
// script's own line numbers (the meta statement was blanked, not removed).
|
||||
try {
|
||||
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
||||
filename: `workflow:${meta.name}`,
|
||||
lineOffset: -1,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
|
||||
}
|
||||
|
||||
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
|
||||
vm.runInContext(DETERMINISM_PRELUDE, this.context)
|
||||
// The realm's own JSON.parse — the host→realm rebuild channel.
|
||||
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown }
|
||||
this.realmJsonParse = (text: string) => realmJson.parse(text)
|
||||
|
||||
const globals: Record<string, unknown> = {
|
||||
agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
|
||||
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
|
||||
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
|
||||
phase: (title: unknown) => { this.phase(title) },
|
||||
log: (message: unknown) => { this.log(message) },
|
||||
args: this.toRealm(args),
|
||||
}
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
// Data properties on the contextified global; frozen shape not required —
|
||||
// a script overwriting its own hooks only sabotages itself.
|
||||
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
this.cancel('workflow start signal already aborted')
|
||||
} else {
|
||||
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run has been cancelled. A METHOD, not an inline property
|
||||
* read: `cancel()` mutates `cancelReason` concurrently (a signal listener,
|
||||
* a raced dispose), and an inline read after an `await` gets narrowed by
|
||||
* control flow into an always-false comparison.
|
||||
*/
|
||||
private isCancelled(): boolean {
|
||||
return this.cancelReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: children abort (the shared signal), waiting `agent()`
|
||||
* slots reject, and every future hook call throws `CANCELLED` — the script
|
||||
* dies at its next await. Idempotent; the first reason wins.
|
||||
*/
|
||||
cancel(reason?: string): void {
|
||||
if (this.cancelReason !== undefined) return
|
||||
this.cancelReason = reason ?? 'workflow cancelled'
|
||||
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
|
||||
this.controller.abort(this.cancelReason)
|
||||
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the script to settlement. Resolves — never rejects — with the run's
|
||||
* {@link WorkflowResult}: the materialized return value on `completed`, the
|
||||
* failure message on `error`, and `cancelled` when the script died of
|
||||
* cancellation. After settlement, any stray children a script fired without
|
||||
* awaiting are aborted (their `agent()` wrappers dispose them).
|
||||
*/
|
||||
async drive(): Promise<WorkflowResult> {
|
||||
try {
|
||||
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
|
||||
const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
|
||||
const value = raw === undefined ? null : this.materializeResult(raw)
|
||||
return { value, stopReason: 'completed', agentsStarted: this.started }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
|
||||
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
|
||||
}
|
||||
return { value: null, stopReason: 'error', error: errorText(error), agentsStarted: this.started }
|
||||
} finally {
|
||||
// Reap strays: a script that fired agent() calls without awaiting them
|
||||
// leaves live children behind after settlement — abort them all. (The
|
||||
// per-call wrappers dispose each child; the contain() consumer keeps
|
||||
// their rejections from going unhandled.)
|
||||
if (this.cancelReason === undefined) this.cancel('workflow settled')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a no-op rejection consumer WITHOUT changing what the caller
|
||||
* receives: if the script drops the promise (no await), cancellation cannot
|
||||
* become an unhandled rejection (the app boot layer exits the process on
|
||||
* those); if the script does await it, it still observes the rejection.
|
||||
*/
|
||||
private contain<T>(promise: Promise<T>): Promise<T> {
|
||||
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
|
||||
return promise
|
||||
}
|
||||
|
||||
private cancelledError(): WorkflowError {
|
||||
// cancel() arms cancelError before any caller can observe isCancelled()
|
||||
// === true; the fallback guards the type, not a reachable path.
|
||||
/* v8 ignore next */
|
||||
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
|
||||
}
|
||||
|
||||
/** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */
|
||||
private toRealm(value: unknown): unknown {
|
||||
if (value === undefined) return undefined
|
||||
if (value === null) return null
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
|
||||
return this.realmJsonParse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
|
||||
private materializeResult(raw: unknown): unknown {
|
||||
try {
|
||||
return materializeFromRealm(raw, 'workflow result')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(
|
||||
`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
|
||||
'RESULT_UNSERIALIZABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
|
||||
* (see {@link cancel}); the callers guard their own entry and post-acquire
|
||||
* windows, so no cancelled-precheck is duplicated here.
|
||||
*/
|
||||
private acquireSlot(): Promise<void> {
|
||||
if (this.activeSlots < this.limits.maxConcurrentAgents) {
|
||||
this.activeSlots += 1
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.slotWaiters.push({
|
||||
resolve: () => {
|
||||
this.activeSlots += 1
|
||||
resolve()
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private releaseSlot(): void {
|
||||
this.activeSlots -= 1
|
||||
const next = this.slotWaiters.shift()
|
||||
if (next) next.resolve()
|
||||
}
|
||||
|
||||
/** The `agent(prompt, opts)` hook. */
|
||||
private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
|
||||
throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const opts = this.readAgentOptions(rawOpts)
|
||||
if (this.started >= this.limits.maxTotalAgents) {
|
||||
throw new WorkflowError(
|
||||
`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`,
|
||||
'AGENT_CAP',
|
||||
)
|
||||
}
|
||||
this.started += 1
|
||||
const seq = this.started
|
||||
const label = opts.label ?? defaultLabel(rawPrompt)
|
||||
const phase = opts.phase ?? this.currentPhase
|
||||
|
||||
await this.acquireSlot()
|
||||
try {
|
||||
// No cancelled re-check here: a cancel cannot interleave between a
|
||||
// waiter's resolution and this continuation (single-threaded, no await
|
||||
// between them), and a child started moments after a cancel still dies
|
||||
// via the shared abort signal — the CANCELLED mapping below covers it.
|
||||
let run
|
||||
try {
|
||||
run = this.ctx.subagents.start(this.limits.provider, {
|
||||
prompt: [{ type: 'text', text: rawPrompt }],
|
||||
parent: this.parent,
|
||||
signal: this.controller.signal,
|
||||
...opts.schema !== undefined ? { outputSchema: opts.schema } : {},
|
||||
...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error })
|
||||
}
|
||||
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id }
|
||||
this.observer.agentStart(info)
|
||||
try {
|
||||
const result = await run.result
|
||||
if (result.stopReason === 'completed') {
|
||||
if (opts.schema !== undefined) {
|
||||
// The provider honored outputSchema (capability-gated at start), so
|
||||
// a completed run without a structured value is a child failure.
|
||||
if (result.structured === undefined) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return this.toRealm(result.structured)
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return outputText(result.output)
|
||||
}
|
||||
// A cancelled RUN kills the script; a child that failed for its own
|
||||
// reasons resolves null (scripts .filter(Boolean) per the CC contract).
|
||||
if (this.isCancelled()) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
||||
throw this.cancelledError()
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
} finally {
|
||||
this.releaseSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize + validate the `agent()` options bag from the realm. */
|
||||
private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } {
|
||||
if (rawOpts === undefined) return {}
|
||||
let opts: unknown
|
||||
try {
|
||||
opts = materializeFromRealm(rawOpts, 'agent() options')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
|
||||
}
|
||||
if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
|
||||
throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const record = opts as Record<string, unknown>
|
||||
for (const key of Object.keys(record)) {
|
||||
if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
|
||||
if (DEFERRED_AGENT_OPTIONS.has(key)) {
|
||||
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
for (const key of ['label', 'phase', 'model'] as const) {
|
||||
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
||||
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
}
|
||||
let schema: StructuredOutputSchema | undefined
|
||||
if (record.schema !== undefined) {
|
||||
try {
|
||||
assertSupportedOutputSchema(record.schema)
|
||||
schema = record.schema
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
|
||||
if (!(error instanceof OutputSchemaError)) throw error
|
||||
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
||||
}
|
||||
}
|
||||
return {
|
||||
...record.label !== undefined ? { label: record.label as string } : {},
|
||||
...record.phase !== undefined ? { phase: record.phase as string } : {},
|
||||
...record.model !== undefined ? { model: record.model as string } : {},
|
||||
...schema !== undefined ? { schema } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
|
||||
private async parallel(rawThunks: unknown): Promise<unknown[]> {
|
||||
if (!Array.isArray(rawThunks)) {
|
||||
throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawThunks.length, 'parallel()')
|
||||
const thunks = rawThunks.map((thunk, index) => {
|
||||
if (typeof thunk !== 'function') {
|
||||
throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return thunk as () => unknown
|
||||
})
|
||||
return Promise.all(thunks.map(async (thunk) => {
|
||||
try {
|
||||
return await thunk()
|
||||
} catch (error: unknown) {
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
|
||||
private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
|
||||
if (!Array.isArray(rawItems)) {
|
||||
throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawItems.length, 'pipeline()')
|
||||
if (rawStages.length === 0) {
|
||||
throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const stages = rawStages.map((stage, index) => {
|
||||
if (typeof stage !== 'function') {
|
||||
throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return stage as (previous: unknown, item: unknown, index: number) => unknown
|
||||
})
|
||||
return Promise.all(rawItems.map(async (item: unknown, index) => {
|
||||
let value: unknown = item
|
||||
try {
|
||||
for (const stage of stages) {
|
||||
value = await stage(value, item, index)
|
||||
}
|
||||
return value
|
||||
} catch (error: unknown) {
|
||||
// An ordinary stage throw drops the ITEM to null and skips its
|
||||
// remaining stages; a fatal error kills the whole script.
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private assertItemCap(length: number, hook: string): void {
|
||||
if (length > this.limits.maxItemsPerCall) {
|
||||
throw new WorkflowError(
|
||||
`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
|
||||
'ITEM_CAP',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
|
||||
private phase(title: unknown): void {
|
||||
if (typeof title !== 'string' || title.length === 0) {
|
||||
throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.currentPhase = title
|
||||
this.observer.phase(title)
|
||||
}
|
||||
|
||||
/** The `log(message)` hook: narration to observers. */
|
||||
private log(message: unknown): void {
|
||||
if (typeof message !== 'string') {
|
||||
throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.observer.log(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import VmWorkflowEngine from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
/**
|
||||
* The whole in-process stack, keyless: the vm engine drives the REAL spawn
|
||||
* backend (with its structured runtime) on a real agent loop; the scripted
|
||||
* mock MODEL is the only mocked boundary. This is the integration guard the
|
||||
* per-hook unit tests (which stub the subagent seam) structurally cannot give.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
await ctx.plugin(VmWorkflowEngine, {})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
describe('dsh-workflow-vm over the real in-process stack', () => {
|
||||
it('runs a two-stage workflow: a plain child, then a schema child through the structured runtime', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('the file list is a.ts'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { verdict: 'real', confidence: 0.9 }),
|
||||
])
|
||||
const childIds: string[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => { childIds.push(agent.childId) })
|
||||
const run = ctx.workflows.start({
|
||||
script: `export const meta = { name: 'integration', description: 'plain + structured children' }
|
||||
phase('Read')
|
||||
const prose = await agent('read the repo')
|
||||
phase('Judge')
|
||||
const judged = await agent('judge: ' + prose, {
|
||||
schema: { type: 'object', properties: { verdict: { type: 'string', enum: ['real', 'bogus'] }, confidence: { type: 'number' } }, required: ['verdict'] },
|
||||
})
|
||||
return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
|
||||
parent,
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toEqual({ prose: 'the file list is a.ts', verdict: 'real', confidence: 0.9 })
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
await run.dispose()
|
||||
// Both children were disposed to quiescence — no live child agents remain.
|
||||
expect(childIds.length).toBe(2)
|
||||
for (const childId of childIds) {
|
||||
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('a child that fails against its schema (nudges exhausted) reaches the script as null', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('prose only'),
|
||||
textResponse('still prose after the nudge'),
|
||||
])
|
||||
const run = ctx.workflows.start({
|
||||
script: `export const meta = { name: 'null-path', description: 'schema failure maps to null' }
|
||||
const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
|
||||
return { got: judged === null ? 'null' : 'value' }`,
|
||||
parent,
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toEqual({ got: 'null' })
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import { extractMeta } from '../src/meta.ts'
|
||||
|
||||
const TIMEOUT = 1000
|
||||
|
||||
/** Extract and expect success. */
|
||||
function ok(script: string) {
|
||||
return extractMeta(script, TIMEOUT)
|
||||
}
|
||||
|
||||
/** The WorkflowError a bad script produces (throws if it extracts cleanly). */
|
||||
function bad(script: string): WorkflowError {
|
||||
try {
|
||||
extractMeta(script, TIMEOUT)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowError) return error
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected extraction to fail')
|
||||
}
|
||||
|
||||
describe('extractMeta', () => {
|
||||
it('extracts a full meta block and blanks the statement line-preservingly', () => {
|
||||
const script = `export const meta = {
|
||||
name: 'audit-routes',
|
||||
description: 'Audit every route',
|
||||
whenToUse: 'when auditing',
|
||||
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
|
||||
}
|
||||
const x = 1
|
||||
return x`
|
||||
const { meta, body } = ok(script)
|
||||
expect(meta).toEqual({
|
||||
name: 'audit-routes',
|
||||
description: 'Audit every route',
|
||||
whenToUse: 'when auditing',
|
||||
phases: [{ title: 'Scan', detail: 'find files' }, { title: 'Fix', model: 'deepseek-v4-pro' }],
|
||||
})
|
||||
// Same line count; the statement's characters blanked; the body intact.
|
||||
expect(body.split('\n').length).toBe(script.split('\n').length)
|
||||
expect(body.split('\n')[6]).toBe('const x = 1')
|
||||
expect(body).not.toContain('export')
|
||||
})
|
||||
|
||||
it('allows leading line and block comments before the meta statement', () => {
|
||||
const script = `// a workflow
|
||||
/* multi
|
||||
line */
|
||||
export const meta = { name: 'x', description: 'y' }
|
||||
return 1`
|
||||
expect(ok(script).meta.name).toBe('x')
|
||||
})
|
||||
|
||||
it('handles braces inside strings and comments while scanning', () => {
|
||||
const script = `export const meta = {
|
||||
name: 'tricky', // } not a close {
|
||||
/* } also not } */
|
||||
description: "has { braces } and 'quotes'",
|
||||
}
|
||||
return 2`
|
||||
expect(ok(script).meta.description).toBe("has { braces } and 'quotes'")
|
||||
})
|
||||
|
||||
it('tolerates template-quoted strings WITHOUT interpolation, escapes included', () => {
|
||||
const script = 'export const meta = { name: `plain`, description: `esc \\` tick` }\nreturn 1'
|
||||
expect(ok(script).meta.name).toBe('plain')
|
||||
})
|
||||
|
||||
it('consumes a trailing semicolon after the literal, spaces included', () => {
|
||||
const { body } = ok("export const meta = { name: 'x', description: 'y' };\nreturn 1")
|
||||
expect(body).not.toContain(';')
|
||||
expect(body.split('\n')[1]).toBe('return 1')
|
||||
const spaced = ok("export const meta = { name: 'x', description: 'y' } ;\nreturn 1")
|
||||
expect(spaced.body).not.toContain(';')
|
||||
})
|
||||
|
||||
it('rejects a script that does not begin with the meta statement (SCRIPT_PARSE)', () => {
|
||||
expect(bad('const a = 1').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = [1]').code).toBe('SCRIPT_PARSE')
|
||||
})
|
||||
|
||||
it('rejects template interpolation in the meta block as impure (SCRIPT_PARSE)', () => {
|
||||
const error = bad('export const meta = { name: `w-${1}`, description: "d" }\nreturn 1')
|
||||
expect(error.code).toBe('SCRIPT_PARSE')
|
||||
expect(error.message).toContain('pure literal')
|
||||
})
|
||||
|
||||
it('rejects unbalanced literals, unterminated strings, and unterminated comments (SCRIPT_PARSE)', () => {
|
||||
expect(bad('export const meta = { name: "x", description: "y"').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = { name: "x').code).toBe('SCRIPT_PARSE')
|
||||
expect(bad('export const meta = { /* open').code).toBe('SCRIPT_PARSE')
|
||||
// A line comment running to EOF (no newline) leaves the literal unbalanced.
|
||||
expect(bad('export const meta = { name: "x" // eof comment').code).toBe('SCRIPT_PARSE')
|
||||
})
|
||||
|
||||
it('rejects a literal referencing variables or calls (META_INVALID via the empty realm)', () => {
|
||||
const error = bad('export const meta = { name: someVariable, description: "d" }\nreturn 1')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('pure literal')
|
||||
expect(bad('export const meta = { name: compute(), description: "d" }').code).toBe('META_INVALID')
|
||||
})
|
||||
|
||||
it('rejects a literal evaluating to non-JSON data (META_INVALID via materialization)', () => {
|
||||
const error = bad('export const meta = { name: "x", description: "d", phases: [{ get title() { return "t" } }] }')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('JSON data')
|
||||
})
|
||||
|
||||
it('rejects shape violations with EVERY violation listed (META_INVALID)', () => {
|
||||
const error = bad('export const meta = { description: 7, bogus: 1 }\nreturn 1')
|
||||
expect(error.code).toBe('META_INVALID')
|
||||
expect(error.message).toContain('meta.name must be a non-empty string')
|
||||
expect(error.message).toContain('meta.description must be a non-empty string')
|
||||
expect(error.message).toContain('meta.bogus is not a recognized field')
|
||||
})
|
||||
|
||||
it('rejects malformed whenToUse and phases shapes precisely', () => {
|
||||
expect(bad('export const meta = { name: "x", description: "d", whenToUse: 3 }').message)
|
||||
.toContain('meta.whenToUse must be a string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: "no" }').message)
|
||||
.toContain('meta.phases must be an array')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [3] }').message)
|
||||
.toContain('meta.phases[0] must be an object')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{}] }').message)
|
||||
.toContain('meta.phases[0].title must be a non-empty string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", extra: 1 }] }').message)
|
||||
.toContain('meta.phases[0].extra is not a recognized field')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", detail: 1 }] }').message)
|
||||
.toContain('meta.phases[0].detail must be a string')
|
||||
expect(bad('export const meta = { name: "x", description: "d", phases: [{ title: "t", model: 1 }] }').message)
|
||||
.toContain('meta.phases[0].model must be a string')
|
||||
})
|
||||
|
||||
it('stops scanning at the balanced literal — trailing expression text stays in the body', () => {
|
||||
// The scanner extracts exactly `{ valueOf: null }`; the ` && 3` is body
|
||||
// text (which would fail compilation later, but extraction sees only the
|
||||
// literal and reports its unknown field).
|
||||
expect(bad('export const meta = { valueOf: null } && 3').message)
|
||||
.toContain('meta.valueOf is not a recognized field')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as vm from 'node:vm'
|
||||
import { materializeFromRealm, MaterializeError } from '../src/realm.ts'
|
||||
|
||||
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
|
||||
function inRealm(expression: string): unknown {
|
||||
return vm.runInNewContext(`(${expression})`)
|
||||
}
|
||||
|
||||
/** The MaterializeError message for a value that must be rejected (throws if accepted). */
|
||||
function rejection(value: unknown): string {
|
||||
try {
|
||||
materializeFromRealm(value)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MaterializeError) return error.message
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the value to be rejected')
|
||||
}
|
||||
|
||||
describe('materializeFromRealm', () => {
|
||||
it('copies realm objects/arrays/scalars into host plain data', () => {
|
||||
const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }")
|
||||
const out = materializeFromRealm(value) as Record<string, unknown>
|
||||
expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] })
|
||||
// The copy is HOST data: prototypes are the host intrinsics.
|
||||
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
|
||||
expect(Array.isArray(out.list)).toBe(true)
|
||||
// And it round-trips through JSON byte-identically (the whole point).
|
||||
expect(JSON.parse(JSON.stringify(out))).toEqual(out)
|
||||
})
|
||||
|
||||
it('accepts undefined ONLY at the root (a valueless script return)', () => {
|
||||
expect(materializeFromRealm(undefined)).toBeUndefined()
|
||||
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
|
||||
})
|
||||
|
||||
it('never invokes accessors: a counting getter is rejected, not read', () => {
|
||||
const counter = inRealm(`
|
||||
(() => {
|
||||
globalThis.reads = 0
|
||||
return { get x() { globalThis.reads += 1; return 1 } }
|
||||
})()
|
||||
`)
|
||||
expect(rejection(counter)).toContain('accessor properties cannot cross')
|
||||
// The getter body never ran — descriptor inspection only.
|
||||
expect((counter as { x?: unknown }).x).toBe(1) // sanity: reading DOES run it…
|
||||
expect(rejection(counter)).toContain('accessor') // …but materialization still never did
|
||||
})
|
||||
|
||||
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
|
||||
const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')')
|
||||
const out = materializeFromRealm(value) as Record<string, unknown>
|
||||
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
|
||||
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
|
||||
expect(out.ok).toBe(2)
|
||||
// The host Object.prototype was NOT touched.
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => {
|
||||
expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn')
|
||||
expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed')
|
||||
expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s')
|
||||
expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big')
|
||||
expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]')
|
||||
const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()")
|
||||
expect(rejection(taggedArray)).toContain('symbol-keyed')
|
||||
})
|
||||
|
||||
it('rejects non-finite numbers and undefined values inside containers', () => {
|
||||
expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite')
|
||||
expect(rejection(inRealm('[Infinity]'))).toContain('non-finite')
|
||||
})
|
||||
|
||||
it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => {
|
||||
expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype')
|
||||
expect(rejection(inRealm('new Map()'))).toContain('exotic prototype')
|
||||
expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()')))
|
||||
.toContain('exotic prototype')
|
||||
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
|
||||
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
|
||||
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
|
||||
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
|
||||
})
|
||||
|
||||
it('rejects sparse arrays, accessor elements, and non-index array properties', () => {
|
||||
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
|
||||
expect(rejection(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 1 }); return a })()')))
|
||||
.toContain('accessor')
|
||||
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
|
||||
.toContain('non-index')
|
||||
})
|
||||
|
||||
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
|
||||
const value = inRealm(`(() => {
|
||||
const o = { visible: 1 }
|
||||
Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false })
|
||||
return o
|
||||
})()`)
|
||||
expect(materializeFromRealm(value)).toEqual({ visible: 1 })
|
||||
})
|
||||
|
||||
it('works on plain host values too (the boundary is realm-agnostic)', () => {
|
||||
expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] })
|
||||
expect(materializeFromRealm('str')).toBe('str')
|
||||
expect(materializeFromRealm(3)).toBe(3)
|
||||
expect(materializeFromRealm(false)).toBe(false)
|
||||
expect(materializeFromRealm(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,614 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { WorkflowResult, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow'
|
||||
import * as vmEngineModule from '../src/index.ts'
|
||||
import VmWorkflowEngine, { type Config } from '../src/index.ts'
|
||||
|
||||
/** A minimal parent stand-in: the engine only threads it through to the provider. */
|
||||
function fakeParent(): Agent {
|
||||
return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent
|
||||
}
|
||||
|
||||
/** One controllable child run: the test (or auto mode) settles it. */
|
||||
interface ControlledRun {
|
||||
request: SubagentStartRequest
|
||||
settle(result: SubagentResult): void
|
||||
cancelled: string | undefined
|
||||
disposed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A scripted in-test provider over the REAL SubagentService registry: `auto`
|
||||
* settles each run via the reply function on a microtask; `manual` piles runs
|
||||
* up in `runs` for the test to settle (concurrency/cancellation tests). A run
|
||||
* aborts (settles `aborted`) when the request signal fires, like the real
|
||||
* in-process backends.
|
||||
*/
|
||||
class StubProvider implements SubagentProvider {
|
||||
readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true }
|
||||
readonly runs: ControlledRun[] = []
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly reply?: (request: SubagentStartRequest, index: number) => SubagentResult,
|
||||
) {}
|
||||
|
||||
start(request: SubagentStartRequest): SubagentRun {
|
||||
let settle!: (result: SubagentResult) => void
|
||||
const result = new Promise<SubagentResult>((resolve) => { settle = resolve })
|
||||
const controlled: ControlledRun = { request, settle, cancelled: undefined, disposed: false }
|
||||
this.runs.push(controlled)
|
||||
const index = this.runs.length - 1
|
||||
request.signal?.addEventListener('abort', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true })
|
||||
if (this.reply) {
|
||||
const reply = this.reply
|
||||
queueMicrotask(() => { settle(reply(request, index)) })
|
||||
}
|
||||
return {
|
||||
id: AgentId(`stub-child-${index}`),
|
||||
result,
|
||||
cancel: (reason?: string) => {
|
||||
controlled.cancelled = reason ?? 'cancelled'
|
||||
settle({ output: [], stopReason: 'aborted' })
|
||||
},
|
||||
dispose: () => {
|
||||
controlled.disposed = true
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Text-reply helper for auto providers. */
|
||||
function text(reply: string): SubagentResult {
|
||||
return { output: [{ type: 'text', text: reply }], stopReason: 'completed' }
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
config?: Config
|
||||
reply?: (request: SubagentStartRequest, index: number) => SubagentResult
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
async function setup(options?: SetupOptions) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const provider = new StubProvider('stub', options?.manual ? undefined : options?.reply ?? (() => text('stub reply')))
|
||||
ctx.subagents.registerProvider(provider)
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'stub', ...options?.config })
|
||||
return { ctx, provider, parent: fakeParent() }
|
||||
}
|
||||
|
||||
/** Wrap a body in the minimal valid meta header. */
|
||||
function script(body: string, metaExtra = ''): string {
|
||||
return `export const meta = { name: 'test-flow', description: 'a test workflow'${metaExtra} }\n${body}`
|
||||
}
|
||||
|
||||
/** Start + await one run, disposing on the way out. */
|
||||
async function run(ctx: Context, parent: Agent, source: string, args?: unknown): Promise<WorkflowResult> {
|
||||
const handle = ctx.workflows.start({ script: source, parent, ...args !== undefined ? { args } : {} })
|
||||
try {
|
||||
return await handle.result
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-workflow-vm', () => {
|
||||
describe('script execution', () => {
|
||||
it('runs a script end-to-end: agent() text results, phases, log, args, return value', async () => {
|
||||
const { ctx, parent, provider } = await setup({ reply: (_request, index) => text(`answer-${index}`) })
|
||||
const events: [string, unknown[]][] = []
|
||||
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
|
||||
ctx.on(name, (...payload: unknown[]) => { events.push([name, payload]) })
|
||||
}
|
||||
const result = await run(ctx, parent, script(`
|
||||
phase('Scan')
|
||||
log('starting with ' + args.files.length + ' files')
|
||||
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
|
||||
phase('Report')
|
||||
return { answers, count: args.files.length }
|
||||
`, ", phases: [{ title: 'Scan' }, { title: 'Report' }]"), { files: ['a.ts', 'b.ts'] })
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'], count: 2 })
|
||||
expect(provider.runs.every(r => r.disposed)).toBe(true)
|
||||
|
||||
const names = events.map(([name]) => name)
|
||||
expect(names[0]).toBe('workflow/start')
|
||||
expect(names).toContain('workflow/phase')
|
||||
expect(names).toContain('workflow/log')
|
||||
expect(names.at(-1)).toBe('workflow/end')
|
||||
const info = events[0]![1][0] as WorkflowRunInfo
|
||||
expect(info.meta.name).toBe('test-flow')
|
||||
const end = events.at(-1)![1][1] as Record<string, unknown>
|
||||
expect(end).toEqual({ stopReason: 'completed', agentsStarted: 2 })
|
||||
expect('value' in end).toBe(false)
|
||||
})
|
||||
|
||||
it('agent-start/end events carry seq, label (defaulted from the prompt), phase, and outcome', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const starts: unknown[] = []
|
||||
const ends: unknown[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => starts.push(agent))
|
||||
ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent))
|
||||
await run(ctx, parent, script(`
|
||||
phase('Find')
|
||||
await agent('a prompt that is quite long and will surely get truncated down to a display label\\n'
|
||||
+ 'with a second line the label must not include')
|
||||
await agent('short', { label: 'named', phase: 'Custom' })
|
||||
return null
|
||||
`))
|
||||
expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find', childId: 'stub-child-0' })
|
||||
expect((starts[0] as { label: string }).label.length).toBeLessThanOrEqual(48)
|
||||
expect((starts[0] as { label: string }).label).not.toContain('second line')
|
||||
expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
|
||||
expect(ends[0]).toMatchObject({ seq: 1, outcome: 'completed' })
|
||||
})
|
||||
|
||||
it('agent({schema}) forwards outputSchema to the provider and returns the structured value into the realm', async () => {
|
||||
const { ctx, parent, provider } = await setup({
|
||||
reply: () => ({ output: [], structured: { files: ['x.ts', 'y.ts'] }, stopReason: 'completed' }),
|
||||
})
|
||||
const result = await run(ctx, parent, script(`
|
||||
const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } }, required: ['files'] } })
|
||||
return { first: found.files[0], count: found.files.length }
|
||||
`))
|
||||
expect(result.value).toEqual({ first: 'x.ts', count: 2 })
|
||||
expect(provider.runs[0]!.request.outputSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: { files: { type: 'array', items: { type: 'string' } } },
|
||||
required: ['files'],
|
||||
})
|
||||
})
|
||||
|
||||
it('model option maps to agentOptions.model on the start request', async () => {
|
||||
const { ctx, parent, provider } = await setup()
|
||||
await run(ctx, parent, script("return await agent('p', { model: 'deepseek-v4-pro' })"))
|
||||
expect(provider.runs[0]!.request.agentOptions).toEqual({ model: 'deepseek-v4-pro' })
|
||||
})
|
||||
|
||||
it('a failed child resolves null (scripts filter), never throwing into the script', async () => {
|
||||
const { ctx, parent } = await setup({
|
||||
reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok'),
|
||||
})
|
||||
const result = await run(ctx, parent, script(`
|
||||
const results = await parallel([() => agent('one'), () => agent('two')])
|
||||
return results
|
||||
`))
|
||||
expect(result.value).toEqual([null, 'ok'])
|
||||
})
|
||||
|
||||
it('a schema run that completes WITHOUT a structured value is a child failure (null + failed outcome)', async () => {
|
||||
const { ctx, parent } = await setup({ reply: () => text('prose, no structure') })
|
||||
const ends: unknown[] = []
|
||||
ctx.on('workflow/agent-end', (_info, agent) => ends.push(agent))
|
||||
const result = await run(ctx, parent, script(`
|
||||
return await agent('p', { schema: { type: 'object' } })
|
||||
`))
|
||||
expect(result.value).toBeNull()
|
||||
expect(ends[0]).toMatchObject({ outcome: 'failed' })
|
||||
})
|
||||
|
||||
it('a script with no return value resolves value: null', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("await agent('p')"))
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('combinator semantics', () => {
|
||||
it('pipeline has NO cross-stage barrier: a fast item finishes stage 2 while a slow item holds stage 1', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
const out = await pipeline(['slow', 'fast'],
|
||||
(prev, item) => agent('s1 ' + item),
|
||||
(prev, item) => agent('s2 ' + item + ' after ' + prev),
|
||||
)
|
||||
return out
|
||||
`),
|
||||
parent: fakeParent(),
|
||||
})
|
||||
// Both items enter stage 1 concurrently.
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(2) })
|
||||
// Settle only the FAST item's stage 1 → its stage 2 starts with no barrier.
|
||||
provider.runs[1]!.settle(text('fast-1'))
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(3) })
|
||||
expect((provider.runs[2]!.request.prompt[0] as { text: string }).text).toBe('s2 fast after fast-1')
|
||||
// The slow item is still sitting in stage 1.
|
||||
provider.runs[2]!.settle(text('fast-2'))
|
||||
provider.runs[0]!.settle(text('slow-1'))
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(4) })
|
||||
provider.runs[3]!.settle(text('slow-2'))
|
||||
const result = await handle.result
|
||||
expect(result.value).toEqual(['slow-2', 'fast-2'])
|
||||
await handle.dispose()
|
||||
void parent
|
||||
})
|
||||
|
||||
it('pipeline stage callbacks receive (prev, item, index); an ordinary stage throw nulls the ITEM and skips its remaining stages', async () => {
|
||||
const { ctx, parent, provider } = await setup({ reply: request => text(`ok:${(request.prompt[0] as { text: string }).text}`) })
|
||||
const result = await run(ctx, parent, script(`
|
||||
const out = await pipeline([10, 20],
|
||||
(prev, item, index) => {
|
||||
if (item === 10) throw new Error('ordinary failure')
|
||||
return agent('stage1-' + item + '-' + index)
|
||||
},
|
||||
(prev) => agent('stage2 saw ' + prev),
|
||||
)
|
||||
return out
|
||||
`))
|
||||
expect(result.stopReason).toBe('completed')
|
||||
const prompts = provider.runs.map(r => (r.request.prompt[0] as { text: string }).text)
|
||||
// Item 10 never reached stage 1's agent nor stage 2.
|
||||
expect(prompts).toEqual(['stage1-20-1', 'stage2 saw ok:stage1-20-1'])
|
||||
expect(result.value).toEqual([null, 'ok:stage2 saw ok:stage1-20-1'])
|
||||
})
|
||||
|
||||
it('parallel maps a throwing thunk to null and never rejects for ordinary errors', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script(`
|
||||
return await parallel([
|
||||
() => { throw new Error('boom') },
|
||||
() => agent('fine'),
|
||||
() => 'plain value',
|
||||
])
|
||||
`))
|
||||
expect(result.value).toEqual([null, 'stub reply', 'plain value'])
|
||||
})
|
||||
|
||||
it('FATAL errors propagate through parallel AND pipeline instead of dissolving into null', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const viaParallel = await run(ctx, parent, script(`
|
||||
return await parallel([() => agent('x', { isolation: 'worktree' })])
|
||||
`))
|
||||
expect(viaParallel.stopReason).toBe('error')
|
||||
expect(viaParallel.error).toContain('"isolation" is deferred')
|
||||
|
||||
const viaPipeline = await run(ctx, parent, script(`
|
||||
return await pipeline([1], () => agent('x', { bogus: true }))
|
||||
`))
|
||||
expect(viaPipeline.stopReason).toBe('error')
|
||||
expect(viaPipeline.error).toContain('"bogus" is not recognized')
|
||||
})
|
||||
|
||||
it('validates combinator arguments loudly (non-array, non-function, missing stages)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect((await run(ctx, parent, script("return await parallel('no')"))).error).toContain('parallel() requires an array')
|
||||
expect((await run(ctx, parent, script('return await parallel([3])'))).error).toContain('item 0 is not a function')
|
||||
expect((await run(ctx, parent, script("return await pipeline('no', () => 1)"))).error).toContain('pipeline() requires an items array')
|
||||
expect((await run(ctx, parent, script('return await pipeline([1])'))).error).toContain('at least one stage')
|
||||
expect((await run(ctx, parent, script("return await pipeline([1], 'x')"))).error).toContain('stage 0 is not a function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('caps and option validation', () => {
|
||||
it('trips the total-agent cap with a message naming the config knob', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', maxTotalAgents: 2 } })
|
||||
const result = await run(ctx, parent, script(`
|
||||
await agent('1'); await agent('2'); await agent('3')
|
||||
return 'unreachable'
|
||||
`))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('total agent cap (2)')
|
||||
expect(result.error).toContain('maxTotalAgents')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
})
|
||||
|
||||
it('trips the per-call item cap for parallel and pipeline', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', maxItemsPerCall: 2 } })
|
||||
expect((await run(ctx, parent, script('return await parallel([() => 1, () => 2, () => 3])'))).error)
|
||||
.toContain('over the per-call cap (2)')
|
||||
expect((await run(ctx, parent, script('return await pipeline([1, 2, 3], (x) => x)'))).error)
|
||||
.toContain('maxItemsPerCall')
|
||||
})
|
||||
|
||||
it('enforces the concurrency ceiling: never more than maxConcurrentAgents children in flight', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 2 } })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script("return await parallel([1, 2, 3, 4, 5].map((n) => () => agent('job ' + n)))"),
|
||||
parent,
|
||||
})
|
||||
// Only 2 children may exist until one settles.
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(2) })
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(provider.runs.length).toBe(2)
|
||||
// Settle children in arrival order; after each settle at most ONE more
|
||||
// child may enter — the window never exceeds the ceiling.
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBeGreaterThan(index) })
|
||||
expect(provider.runs.length).toBeLessThanOrEqual(Math.min(index + 2, 5))
|
||||
provider.runs[index]!.settle(text(`r${index}`))
|
||||
}
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(5)
|
||||
expect(result.value).toEqual(['r0', 'r1', 'r2', 'r3', 'r4'])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('rejects malformed agent() arguments and option types loudly', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect((await run(ctx, parent, script('return await agent(42)'))).error).toContain('non-empty prompt string')
|
||||
expect((await run(ctx, parent, script("return await agent('')"))).error).toContain('non-empty prompt string')
|
||||
expect((await run(ctx, parent, script("return await agent('p', 'opts')"))).error).toContain('options must be an object')
|
||||
expect((await run(ctx, parent, script("return await agent('p', { label: 3 })"))).error).toContain('"label" must be a string')
|
||||
expect((await run(ctx, parent, script("return await agent('p', { effort: 'high' })"))).error).toContain('"effort" is deferred')
|
||||
})
|
||||
|
||||
it('rejects options that are not plain JSON data (an accessor smuggled into opts)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("return await agent('p', { get label() { return 'x' } })"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('options must be plain JSON data')
|
||||
})
|
||||
|
||||
it('validates phase() and log() arguments loudly', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect((await run(ctx, parent, script('phase(3)'))).error).toContain('phase() requires a non-empty title string')
|
||||
expect((await run(ctx, parent, script("phase('')"))).error).toContain('phase() requires a non-empty title string')
|
||||
expect((await run(ctx, parent, script('log(3)'))).error).toContain('log() requires a message string')
|
||||
})
|
||||
|
||||
it('rejects an unsupported schema via the shared subset assertion (UNSUPPORTED_SCHEMA)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("return await agent('p', { schema: { type: 'object', oneOf: [] } })"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('outside the supported subset')
|
||||
expect(result.error).toContain('oneOf')
|
||||
})
|
||||
|
||||
it('wraps a provider start failure as a fatal AGENT_START error (a missing provider cannot dissolve into null)', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } })
|
||||
const result = await run(ctx, parent, script("return await pipeline([1], () => agent('p'))"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('could not start a child on provider "nonexistent"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('determinism bans and realm isolation', () => {
|
||||
it('Date.now, Math.random, and argless new Date throw; parameterized Date stays usable', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect((await run(ctx, parent, script('return Date.now()'))).error).toContain('Date.now() is not available')
|
||||
expect((await run(ctx, parent, script('return Math.random()'))).error).toContain('Math.random() is not available')
|
||||
expect((await run(ctx, parent, script('return new Date().toISOString()'))).error).toContain('argless new Date()')
|
||||
const ok = await run(ctx, parent, script('return new Date(0).getTime()'))
|
||||
expect(ok.value).toBe(0)
|
||||
})
|
||||
|
||||
it('args cross into the realm as data: mutating them (or their prototype chain) cannot reach host intrinsics', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const hostArgs = { files: ['a.ts'], nested: { deep: [1, 2] } }
|
||||
const result = await run(ctx, parent, script(`
|
||||
args.files.push('b.ts')
|
||||
Object.getPrototypeOf(args).polluted = 'realm-only'
|
||||
return { count: args.files.length, deep: args.nested.deep[1] }
|
||||
`), hostArgs)
|
||||
expect(result.value).toEqual({ count: 2, deep: 2 })
|
||||
// The host copy is untouched, and the HOST Object.prototype was never reachable.
|
||||
expect(hostArgs.files).toEqual(['a.ts'])
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('scalar/null args pass through directly; absent args leave the global undefined', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect((await run(ctx, parent, script('return args * 2'), 21)).value).toBe(42)
|
||||
expect((await run(ctx, parent, script('return args === null'), null)).value).toBe(true)
|
||||
expect((await run(ctx, parent, script('return typeof args'))).value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const withDate = await run(ctx, parent, script('return { when: new Date(0) }'))
|
||||
expect(withDate.stopReason).toBe('error')
|
||||
expect(withDate.error).toContain('not plain JSON data')
|
||||
const withFn = await run(ctx, parent, script('return { fn: () => 1 }'))
|
||||
expect(withFn.error).toContain('not plain JSON data')
|
||||
})
|
||||
|
||||
it('kills a synchronous spin in the initial slice via the vm timeout', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } })
|
||||
const result = await run(ctx, parent, script('while (true) {}'))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error?.toLowerCase()).toContain('timed out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle: parse errors, cancellation, disposal', () => {
|
||||
it('start() throws synchronously for an unparseable script or invalid meta', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
expect(() => ctx.workflows.start({ script: 'const x = 1', parent })).toThrow(/must begin with/)
|
||||
expect(() => ctx.workflows.start({ script: script('return ((('), parent })).toThrow(/does not parse/)
|
||||
})
|
||||
|
||||
it('cancel() aborts in-flight children and settles the run cancelled', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const handle = ctx.workflows.start({ script: script("return await agent('long job')"), parent })
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
handle.cancel('user stopped it')
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(result.error).toContain('user stopped it')
|
||||
expect(provider.runs[0]!.disposed).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('an already-aborted request signal cancels before any child starts', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const handle = ctx.workflows.start({ script: script("return await agent('never')"), parent, signal: controller.signal })
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(provider.runs.length).toBe(0)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('the signal aborting mid-run cancels like cancel()', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const controller = new AbortController()
|
||||
const handle = ctx.workflows.start({ script: script("return await agent('job')"), parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
controller.abort()
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('reports a non-Error script throw (a thrown string) faithfully', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("throw 'plain string failure'"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('plain string failure')
|
||||
})
|
||||
|
||||
it('a script Error surfaces its stack, carrying the script line numbers (lineOffset)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script("throw new Error('with stack')"))
|
||||
expect(result.stopReason).toBe('error')
|
||||
// Line 1 is the blanked meta statement; the throw sits on line 2.
|
||||
expect(result.error).toContain('workflow:test-flow:2')
|
||||
})
|
||||
|
||||
it('an object throw with neither stack nor message stringifies', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script('throw { code: 42 }'))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('falls back to the message for an Error whose stack was stripped', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await run(ctx, parent, script(`
|
||||
const e = new Error('stackless failure')
|
||||
e.stack = undefined
|
||||
throw e
|
||||
`))
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toBe('stackless failure')
|
||||
})
|
||||
|
||||
it('a waiter resumed by a release RACING a cancel still dies at the post-acquire check', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true, config: { provider: 'stub', maxConcurrentAgents: 1 } })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script("return await parallel([() => agent('a'), () => agent('b')])"),
|
||||
parent,
|
||||
})
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
|
||||
// Same synchronous block: the release resolves b's waiter, then the
|
||||
// cancel lands BEFORE b's continuation runs — b must not start a child.
|
||||
provider.runs[0]!.settle(text('a-done'))
|
||||
handle.cancel('raced')
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(provider.runs.length).toBe(1)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a dropped agent() promise cannot become an unhandled rejection when cancellation lands', async () => {
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
agent('dropped, never awaited')
|
||||
return await agent('awaited')
|
||||
`),
|
||||
parent,
|
||||
})
|
||||
await vi.waitFor(() => { expect(provider.runs.length).toBe(2) })
|
||||
handle.cancel()
|
||||
await handle.result
|
||||
await handle.dispose()
|
||||
// Let any stray rejection reach the process hook before asserting.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(unhandled).toEqual([])
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose() abandons a stuck script after the grace instead of hanging (result stays pending)', async () => {
|
||||
const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 30 } })
|
||||
const handle = ctx.workflows.start({
|
||||
// No hooks involved: an unsettleable await the engine cannot reject.
|
||||
script: script("await new Promise(() => {})\nreturn 'unreachable'"),
|
||||
parent,
|
||||
})
|
||||
const before = Date.now()
|
||||
await handle.dispose()
|
||||
expect(Date.now() - before).toBeLessThan(1000)
|
||||
const settled = await Promise.race([handle.result.then(() => 'settled'), Promise.resolve('pending')])
|
||||
expect(settled).toBe('pending')
|
||||
})
|
||||
|
||||
it('dispose() is idempotent and settles cleanly after a completed run', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const handle = ctx.workflows.start({ script: script('return 1'), parent })
|
||||
await handle.result
|
||||
await handle.dispose()
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('strays: children fired without await are aborted once the script settles', async () => {
|
||||
const { ctx, parent, provider } = await setup({ manual: true })
|
||||
const handle = ctx.workflows.start({
|
||||
script: script(`
|
||||
agent('stray')
|
||||
return 'done without awaiting'
|
||||
`),
|
||||
parent,
|
||||
})
|
||||
const result = await handle.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
await vi.waitFor(() => {
|
||||
expect(provider.runs.length).toBe(1)
|
||||
expect(provider.runs[0]!.disposed).toBe(true)
|
||||
})
|
||||
await handle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('service surface', () => {
|
||||
it('run ids are unique per start; the run handle and event payloads hold SEPARATE meta clones', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
let eventMeta: WorkflowRunInfo | undefined
|
||||
ctx.on('workflow/start', (info) => { eventMeta = info })
|
||||
const first = ctx.workflows.start({ script: script('return 1'), parent })
|
||||
const second = ctx.workflows.start({ script: script('return 2'), parent })
|
||||
expect(first.id).not.toBe(second.id)
|
||||
// Mutating a listener's snapshot cannot corrupt the holder's view.
|
||||
eventMeta!.meta.name = 'corrupted'
|
||||
expect(second.meta.name).toBe('test-flow')
|
||||
await Promise.all([first.result, second.result])
|
||||
await first.dispose()
|
||||
await second.dispose()
|
||||
})
|
||||
|
||||
it('unregisters ctx.workflows when the engine fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(VmWorkflowEngine, {})
|
||||
expect(ctx.get('workflows')).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the class-plugin export shape (default = the engine service class)', () => {
|
||||
expect(vmEngineModule.default).toBe(VmWorkflowEngine)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped: unknown = loader.unwrapExports(vmEngineModule)
|
||||
expect(unwrapped).toBe(VmWorkflowEngine)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import VmWorkflowEngine from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the workflow engine: a REAL script drives REAL spawn
|
||||
* children against the live DeepSeek API — one plain child and one schema'd
|
||||
* child through the real structured-output runtime — and the run's value,
|
||||
* events, and child sessions are asserted from the outside (never the
|
||||
* script's self-report alone). Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
})
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
const built = new Context()
|
||||
await built.plugin(LlmService)
|
||||
await built.plugin(SessionStore)
|
||||
await built.plugin(SystemPrompt)
|
||||
await built.plugin(ToolRegistry)
|
||||
await built.plugin(AgentRegistry)
|
||||
await built.plugin(AgentLoop, { agents: [] })
|
||||
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
await built.plugin(SubagentService)
|
||||
await built.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
||||
await built.plugin(VmWorkflowEngine, { provider: 'spawn' })
|
||||
await built.plugin(ToolWorkflow, {})
|
||||
return built
|
||||
}
|
||||
|
||||
const SCRIPT = `export const meta = {
|
||||
name: 'e2e-arithmetic',
|
||||
description: 'two real children: one prose, one structured',
|
||||
phases: [{ title: 'Ask' }, { title: 'Judge' }],
|
||||
}
|
||||
phase('Ask')
|
||||
log('asking the prose child')
|
||||
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
|
||||
phase('Judge')
|
||||
const judged = await agent(
|
||||
'Here is an answer to the question "what is 2+2": ' + prose
|
||||
+ ' — report whether it contains the number 4 and your confidence between 0 and 1.',
|
||||
{ schema: { type: 'object', properties: { containsFour: { type: 'boolean' }, confidence: { type: 'number' } }, required: ['containsFour'] } },
|
||||
)
|
||||
return { prose, containsFour: judged === null ? null : judged.containsFour }`
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => {
|
||||
it('runs a two-phase script over real children, one through the structured runtime', async () => {
|
||||
ctx = await harness()
|
||||
const parentHandle = ctx.agents.create({
|
||||
agentId: AgentId('wf-e2e-parent'),
|
||||
sessionId: 'wf-e2e-session' as never,
|
||||
agentOptions: { model: 'deepseek-v4-flash' },
|
||||
})
|
||||
|
||||
const events: string[] = []
|
||||
const childIds: string[] = []
|
||||
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
|
||||
ctx.on(name, (...payload: unknown[]) => {
|
||||
events.push(name)
|
||||
if (name === 'workflow/agent-start') childIds.push((payload[1] as { childId: string }).childId)
|
||||
})
|
||||
}
|
||||
|
||||
const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
const value = result.value as { prose: string; containsFour: boolean | null }
|
||||
// World checks: the prose child really answered (a real completion), and
|
||||
// the structured child judged it against the REAL schema-forced tool.
|
||||
expect(value.prose.length).toBeGreaterThan(0)
|
||||
expect(value.containsFour).toBe(true)
|
||||
|
||||
expect(events[0]).toBe('workflow/start')
|
||||
expect(events.at(-1)).toBe('workflow/end')
|
||||
expect(events.filter(name => name === 'workflow/phase').length).toBe(2)
|
||||
expect(events.filter(name => name === 'workflow/agent-start').length).toBe(2)
|
||||
expect(childIds.length).toBe(2)
|
||||
// The children were disposed to quiescence after collection.
|
||||
for (const childId of childIds) {
|
||||
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
|
||||
}
|
||||
await parentHandle.dispose()
|
||||
}, 240_000)
|
||||
|
||||
it('the workflow TOOL runs the same path through the real registry pipeline', async () => {
|
||||
ctx = await harness()
|
||||
const parentHandle = ctx.agents.create({
|
||||
agentId: AgentId('wf-e2e-tool-parent'),
|
||||
sessionId: 'wf-e2e-tool-session' as never,
|
||||
agentOptions: { model: 'deepseek-v4-flash' },
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('wf-e2e-call'),
|
||||
name: 'workflow',
|
||||
arguments: {
|
||||
script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' }
|
||||
const answer = await agent('Reply with exactly one word: the capital of France.')
|
||||
return { answer }`,
|
||||
},
|
||||
agent: parentHandle.agent,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const text = (result.content[0] as { text: string }).text
|
||||
expect(text).toContain('workflow "e2e-tool" completed (1 agent)')
|
||||
expect(text.toLowerCase()).toContain('paris')
|
||||
await parentHandle.dispose()
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# @deepseek-ai/dsh-workflow
|
||||
|
||||
The **workflow seam** (`ctx.workflows`): an abstract service defining WHAT a workflow engine does — execute a model-written orchestration script that fans out subagents — without saying HOW. The bash-shaped third of the [workflow family](../README.md): implementations subclass `WorkflowService` and register as the `workflows` service (one per context); [`dsh-workflow-vm`](../workflow-vm/README.md) is the first, and [`dsh-tool-workflow`](../tool-workflow/README.md) is the model-facing consumer.
|
||||
|
||||
## Service: `WorkflowService` (abstract)
|
||||
|
||||
`start(request: WorkflowStartRequest): WorkflowRun` — parse and execute a script. Throws synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot begin; once a run is returned, its `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`). `dispose()` must reach quiescence within a bounded grace (cancel → wait → abandon), never hanging its caller.
|
||||
|
||||
The protected `emitWorkflowEvent` helper dispatches the `workflow/*` events with PER-LISTENER containment (a throwing subscriber is logged, never propagated, and cannot starve later listeners) — the same guarantee as the subagent seam's lifecycle emits.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
- `WorkflowStartRequest` — `{ script, args?, parent: Agent, signal? }`. `parent` is REQUIRED: every child the script spawns is attributed to it. `args` must be plain host-realm JSON data.
|
||||
- `WorkflowMeta` / `WorkflowPhase` — the script's validated `export const meta` block (Claude Code format: required `name`/`description`, optional `whenToUse`/`phases`).
|
||||
- `WorkflowRun` — `{ id, meta, result, cancel(reason?), dispose() }`; the consumer awaits `result` and MUST `dispose` on every path.
|
||||
- `WorkflowResult` — `{ value, stopReason: 'completed'|'cancelled'|'error', error?, agentsStarted }`; `value` is the script's materialized return (plain JSON data; `null` for no return).
|
||||
- `WorkflowError` — `HarnessError` with a `WorkflowErrorCode` and a `fatal` flag driving the combinator discipline: a fatal error (bad hook arguments, unsupported options/schemas, tripped caps, seam start failures, cancellation) always propagates through `parallel()`/`pipeline()` instead of dissolving into a per-item `null`. `isFatalWorkflowError(error)` is the catch-site predicate.
|
||||
|
||||
## Events
|
||||
|
||||
All observe-only emits carrying DATA SNAPSHOTS (`WorkflowRunInfo` = id + meta) — never the live `WorkflowRun`, so a listener cannot gain `cancel`/`dispose`; control stays with the `start()` caller:
|
||||
|
||||
- `workflow/start`(info) / `workflow/end`(info, resultInfo) — run lifecycle; `resultInfo` deliberately omits the value.
|
||||
- `workflow/phase`(info, title) / `workflow/log`(info, message) — script narration.
|
||||
- `workflow/agent-start`(info, agent) / `workflow/agent-end`(info, agent + outcome) — one pair per `agent()` call, correlated by `seq`.
|
||||
|
||||
## Non-goals (this cut)
|
||||
|
||||
Background collection, journaling/resume, saved workflows, nested `workflow()`, token budgets — see the [RFC's deferred section](../../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md).
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-workflow",
|
||||
"description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The workflow capability seam (`ctx.workflows`): an abstract service defining
|
||||
* WHAT a workflow engine does — execute a model-written orchestration script
|
||||
* that fans out subagents — without saying HOW. Implementations subclass
|
||||
* {@link WorkflowService} and register as the `workflows` service (one
|
||||
* implementation per context, cordis' standard duplicate-service behavior);
|
||||
* `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the
|
||||
* first. Future engines (a worker-thread or isolated-vm sandbox) swap in
|
||||
* without touching the model-facing tool that consumes them
|
||||
* (`@deepseek-ai/dsh-tool-workflow`).
|
||||
*
|
||||
* The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they
|
||||
* carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun}
|
||||
* — a listener must not gain `cancel`/`dispose`; control stays with the
|
||||
* `start()` caller holding the run. Every emit is per-listener contained (a
|
||||
* throwing subscriber is logged, never propagated), so one bad observer can
|
||||
* neither strand a live run nor starve later listeners.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
} from './types.ts'
|
||||
|
||||
export { WorkflowRunId } from './types.ts'
|
||||
export type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowAgentOutcome,
|
||||
WorkflowMeta,
|
||||
WorkflowPhase,
|
||||
WorkflowResult,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
WorkflowStopReason,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workflows: WorkflowService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A workflow run started — the script's meta block validated, the body
|
||||
* about to execute. Paired with {@link Events['workflow/end']}.
|
||||
* @param info - the run's identity snapshot (id + meta).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
/**
|
||||
* The script entered a phase (a `phase(title)` call) — progress grouping
|
||||
* for observers; no execution semantics.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param title - the phase title, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
/**
|
||||
* The script emitted a narration line (a `log(message)` call).
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param message - the logged message, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
/**
|
||||
* One `agent()` call started a child run. Paired with
|
||||
* {@link Events['workflow/agent-end']} by `agent.seq`.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call's sequence number, label, phase, and child id.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
/**
|
||||
* One `agent()` call settled (clean result, child failure, or run
|
||||
* cancellation). Paired with {@link Events['workflow/agent-start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call identity plus its outcome.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
/**
|
||||
* A workflow run settled (any stop reason). Fired when
|
||||
* {@link WorkflowRun.result} resolves. Paired with
|
||||
* {@link Events['workflow/start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param result - the outcome data (stop reason, error, agent count) —
|
||||
* deliberately WITHOUT the result value (see {@link WorkflowResultInfo}).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
/** The full set of `workflow/*` event names {@link WorkflowService.emitWorkflowEvent} dispatches. */
|
||||
export type WorkflowEventName =
|
||||
| 'workflow/start'
|
||||
| 'workflow/phase'
|
||||
| 'workflow/log'
|
||||
| 'workflow/agent-start'
|
||||
| 'workflow/agent-end'
|
||||
| 'workflow/end'
|
||||
|
||||
/**
|
||||
* The workflow-seam error codes. Every one of these is FATAL when it reaches
|
||||
* a script (see {@link WorkflowError.fatal}): the combinators re-throw it
|
||||
* instead of dissolving it into an ordinary per-item `null`.
|
||||
*
|
||||
* - `SCRIPT_PARSE` — the script (or its meta statement) does not parse.
|
||||
* - `META_INVALID` — the meta block evaluated but fails the shape contract.
|
||||
* - `INVALID_ARGUMENT` — a hook was called with malformed arguments.
|
||||
* - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support
|
||||
* (deferred: `effort`/`isolation`/`agentType`) or does not know.
|
||||
* - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output
|
||||
* subset (see dsh-tools).
|
||||
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
|
||||
* - `AGENT_START` — the subagent seam refused to start a child.
|
||||
* - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not
|
||||
* plain JSON data.
|
||||
* - `CANCELLED` — the run was cancelled; pending and future hooks reject
|
||||
* with this (the script-kill mechanism).
|
||||
*/
|
||||
export type WorkflowErrorCode =
|
||||
| 'SCRIPT_PARSE'
|
||||
| 'META_INVALID'
|
||||
| 'INVALID_ARGUMENT'
|
||||
| 'UNSUPPORTED_OPTION'
|
||||
| 'UNSUPPORTED_SCHEMA'
|
||||
| 'AGENT_CAP'
|
||||
| 'ITEM_CAP'
|
||||
| 'AGENT_START'
|
||||
| 'RESULT_UNSERIALIZABLE'
|
||||
| 'CANCELLED'
|
||||
|
||||
/**
|
||||
* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the
|
||||
* `code` is machine-routable taxonomy. `fatal` drives the combinator
|
||||
* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd
|
||||
* option or a tripped cap must kill the script loudly), and reserve the
|
||||
* per-item `null` for child-run failures and ordinary in-stage script errors.
|
||||
* Every {@link WorkflowErrorCode} is fatal in this cut; the flag exists so the
|
||||
* distinction is explicit at every catch site rather than implied.
|
||||
*/
|
||||
export class WorkflowError extends HarnessError {
|
||||
/** Whether combinators must propagate this error instead of nulling the item. */
|
||||
readonly fatal: boolean
|
||||
|
||||
constructor(message: string, code: WorkflowErrorCode, options?: ErrorOptions & { fatal?: boolean }) {
|
||||
super(message, code, options)
|
||||
this.name = 'WorkflowError'
|
||||
this.fatal = options?.fatal ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether combinators must re-throw `error` instead of mapping the item to `null`. */
|
||||
export function isFatalWorkflowError(error: unknown): boolean {
|
||||
return error instanceof WorkflowError && error.fatal
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract workflow execution service. Subclass, implement {@link start}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.workflows` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link start} throws synchronously for a request that cannot begin (an
|
||||
* unparseable script, an invalid meta block). Once it returns a
|
||||
* {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with
|
||||
* `stopReason: 'error'` (or `'cancelled'`).
|
||||
* - The `workflow/*` events fire through {@link emitWorkflowEvent} (data
|
||||
* snapshots, per-listener containment); `workflow/end` fires exactly once
|
||||
* per started run, after `result` is settled or as it settles.
|
||||
* - `dispose()` reaches quiescence within a bounded grace: it cancels, waits
|
||||
* for the script to settle, and abandons a stuck script rather than
|
||||
* hanging its caller (the engine documents what abandonment leaves behind).
|
||||
*/
|
||||
export abstract class WorkflowService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workflows')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script.
|
||||
* @param request - the script, its `args`, the parent agent, and an
|
||||
* optional cancel signal.
|
||||
* @returns the live run; its `result` resolves when the script settles.
|
||||
*/
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
|
||||
/**
|
||||
* Emit one `workflow/*` lifecycle event with PER-LISTENER containment:
|
||||
* dispatch each subscriber individually and log (never propagate) a thrown
|
||||
* one, so one bad subscriber can neither fail the engine mid-run, surface as
|
||||
* an unhandled rejection on a detached settle hook, nor starve the listeners
|
||||
* registered after it (cordis `emit` halts on the first throw — same
|
||||
* guarantee as the subagent seam's lifecycle emits).
|
||||
* @param name - the `workflow/*` event to dispatch.
|
||||
* @param args - the event's payload, matching its declared signature.
|
||||
*/
|
||||
protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) {
|
||||
try {
|
||||
// The declared workflow/* signatures are all void-returning emits; the
|
||||
// dispatch callback applies the payload tuple.
|
||||
;(callback as (...payload: unknown[]) => void)(...args)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowService
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Workflow seam vocabulary: the request/run/result types a workflow engine
|
||||
* consumes and produces, plus the payload shapes of the `workflow/*` events.
|
||||
* Types only (plus the id-brand factory), per the package convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Identifies one workflow run. */
|
||||
export type WorkflowRunId = Branded<'WorkflowRunId'>
|
||||
|
||||
/** Brand a string as a {@link WorkflowRunId}. */
|
||||
export function WorkflowRunId(id: string): WorkflowRunId {
|
||||
return id as WorkflowRunId
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase declared in a script's `meta.phases` (progress vocabulary only —
|
||||
* phases group agents in observers/UIs; they impose no execution structure).
|
||||
*/
|
||||
export interface WorkflowPhase {
|
||||
/** The phase title; `phase()` calls match against it by exact string. */
|
||||
title: string
|
||||
/** Optional one-line description of what the phase does. */
|
||||
detail?: string
|
||||
/** Optional model override this phase is expected to use (informational). */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The script's `export const meta` block, validated by the engine before the
|
||||
* body runs. `name`/`description` are required; the rest is optional
|
||||
* annotation. Matches the Claude Code dynamic-workflows script format.
|
||||
*/
|
||||
export interface WorkflowMeta {
|
||||
/** Short kebab-case workflow name (display + persistence key). */
|
||||
name: string
|
||||
/** One-line description of what the workflow does. */
|
||||
description: string
|
||||
/** Optional guidance on when this workflow applies (shown in listings). */
|
||||
whenToUse?: string
|
||||
/** Optional phase declarations matched by `phase()` calls. */
|
||||
phases?: WorkflowPhase[]
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller asks for when starting a workflow run. `parent` is REQUIRED —
|
||||
* every `agent()` the script spawns is attributed to it (cwd, lineage, depth
|
||||
* flow through the subagent seam). `args` must be plain host-realm JSON data;
|
||||
* the engine exposes it to the script as the `args` global.
|
||||
*/
|
||||
export interface WorkflowStartRequest {
|
||||
/** The full script text: `export const meta = {...}` + a plain-JS body. */
|
||||
script: string
|
||||
/** Optional input exposed verbatim to the script as the `args` global. */
|
||||
args?: unknown
|
||||
/** The agent on whose behalf the run executes (parent of every child). */
|
||||
parent: Agent
|
||||
/** Cancels the run when aborted (the tool's `exec.signal`). */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run settled. CLOSED union (engine-owned, consumers may exhaust):
|
||||
* `completed` = the script ran to its final `return`; `cancelled` = the run
|
||||
* was cancelled (caller `cancel()`/signal); `error` = the script threw, a
|
||||
* fatal `WorkflowError` propagated, or the result failed materialization.
|
||||
*/
|
||||
export type WorkflowStopReason = 'completed' | 'cancelled' | 'error'
|
||||
|
||||
/**
|
||||
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
|
||||
* the script's materialized return value (plain host-realm JSON data; `null`
|
||||
* when the script returned `undefined`) — meaningful only for `completed`.
|
||||
* A non-`completed` reason carries the failure in `error`; the consumer maps
|
||||
* it to an `isError` tool result rather than reporting partial output.
|
||||
*/
|
||||
export interface WorkflowResult {
|
||||
/** The script's return value (host JSON data; `null` for no return). */
|
||||
value: unknown
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started (across its whole lifetime). */
|
||||
agentsStarted: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle the consumer holds while a script executes. The consumer awaits
|
||||
* `result`, may `cancel` mid-flight, and MUST `dispose` on every path.
|
||||
* `result` does NOT reject — a script failure resolves with `stopReason:
|
||||
* 'error'` — so the consumer maps a non-`completed` reason to an `isError`
|
||||
* result. `dispose()` cancels, then waits a bounded grace for the script to
|
||||
* settle before abandoning it (the engine documents the abandonment
|
||||
* semantics); it never hangs on a stuck script.
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
readonly id: WorkflowRunId
|
||||
/** The validated meta block (available before the body runs). */
|
||||
readonly meta: WorkflowMeta
|
||||
readonly result: Promise<WorkflowResult>
|
||||
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */
|
||||
cancel(reason?: string): void
|
||||
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Identifying detail for a run, carried by every `workflow/*` event (a data snapshot, never the live run). */
|
||||
export interface WorkflowRunInfo {
|
||||
/** The run's id. */
|
||||
id: WorkflowRunId
|
||||
/** The run's validated meta block. */
|
||||
meta: WorkflowMeta
|
||||
}
|
||||
|
||||
/** One `agent()` call's identity within a run (the `workflow/agent-start` payload). */
|
||||
export interface WorkflowAgentInfo {
|
||||
/** 1-based sequence number of this `agent()` call within the run. */
|
||||
seq: number
|
||||
/** The display label (the `label` option, or a prompt snippet). */
|
||||
label: string
|
||||
/** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */
|
||||
phase?: string
|
||||
/** The child agent's id on the subagent seam. */
|
||||
childId: AgentId
|
||||
}
|
||||
|
||||
/** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */
|
||||
export type WorkflowAgentOutcome = 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
/** One `agent()` call's settlement (the `workflow/agent-end` payload). */
|
||||
export interface WorkflowAgentEndInfo extends WorkflowAgentInfo {
|
||||
/** How the call settled. */
|
||||
outcome: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled run's outcome as event data (the `workflow/end` payload): the
|
||||
* {@link WorkflowResult} minus `value` (a listener observing outcomes must not
|
||||
* receive a mutable alias of the caller's result value; a consumer that needs
|
||||
* the value holds the run and awaits `result`).
|
||||
*/
|
||||
export interface WorkflowResultInfo {
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started. */
|
||||
agentsStarted: number
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import WorkflowServiceDefault, {
|
||||
isFatalWorkflowError,
|
||||
WorkflowError,
|
||||
WorkflowRunId,
|
||||
WorkflowService,
|
||||
} from '../src/index.ts'
|
||||
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '../src/index.ts'
|
||||
|
||||
/** A minimal concrete subclass exposing the protected emit helper for tests. */
|
||||
class StubEngine extends WorkflowService {
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
void request
|
||||
throw new Error('not under test')
|
||||
}
|
||||
|
||||
emit(name: Parameters<WorkflowService['emitWorkflowEvent']>[0], ...args: unknown[]): void {
|
||||
this.emitWorkflowEvent(name, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
const INFO: WorkflowRunInfo = { id: WorkflowRunId('run-1'), meta: { name: 'w', description: 'd' } }
|
||||
|
||||
describe('dsh-workflow (interface)', () => {
|
||||
it('WorkflowRunId brands a string (identity at runtime)', () => {
|
||||
expect(WorkflowRunId('abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('WorkflowError carries code + fatal (default true) and reads as a HarnessError', () => {
|
||||
const error = new WorkflowError('cap hit', 'AGENT_CAP')
|
||||
expect(error.code).toBe('AGENT_CAP')
|
||||
expect(error.fatal).toBe(true)
|
||||
expect(error.name).toBe('WorkflowError')
|
||||
const soft = new WorkflowError('advisory', 'ITEM_CAP', { fatal: false })
|
||||
expect(soft.fatal).toBe(false)
|
||||
})
|
||||
|
||||
it('isFatalWorkflowError: true only for a fatal WorkflowError', () => {
|
||||
expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED'))).toBe(true)
|
||||
expect(isFatalWorkflowError(new WorkflowError('x', 'CANCELLED', { fatal: false }))).toBe(false)
|
||||
expect(isFatalWorkflowError(new Error('plain'))).toBe(false)
|
||||
expect(isFatalWorkflowError('string')).toBe(false)
|
||||
})
|
||||
|
||||
it('registers as ctx.workflows and unregisters when its fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(StubEngine)
|
||||
expect(ctx.get('workflows')).toBeInstanceOf(StubEngine)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('workflows')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emitWorkflowEvent dispatches to every listener with the payload tuple', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubEngine)
|
||||
const seen: unknown[][] = []
|
||||
ctx.on('workflow/log', (info, message) => { seen.push([info, message]) })
|
||||
ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
engine.emit('workflow/log', INFO, 'hello')
|
||||
engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' })
|
||||
expect(seen).toEqual([
|
||||
[INFO, 'hello'],
|
||||
[INFO, { seq: 1, label: 'l', childId: 'c' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('contains a throwing listener PER LISTENER: later listeners still run, nothing propagates', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubEngine)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const reached: string[] = []
|
||||
ctx.on('workflow/phase', () => { throw new Error('bad listener') })
|
||||
ctx.on('workflow/phase', (_info, title) => { reached.push(title) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow()
|
||||
expect(reached).toEqual(['Scan'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw')
|
||||
})
|
||||
|
||||
it('has the expected export surface (default = the abstract service class)', () => {
|
||||
expect(WorkflowServiceDefault).toBe(WorkflowService)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+95
@@ -635,6 +635,12 @@ importers:
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../subagent
|
||||
'@deepseek-ai/dsh-subagent-fork':
|
||||
specifier: workspace:^
|
||||
version: link:../subagent-fork
|
||||
'@deepseek-ai/dsh-subagent-spawn':
|
||||
specifier: workspace:^
|
||||
version: link:../subagent-spawn
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
@@ -1039,6 +1045,95 @@ importers:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/workflow/tool-workflow:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-workflow':
|
||||
specifier: workspace:^
|
||||
version: link:../workflow
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/workflow/workflow:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/workflow/workflow-vm:
|
||||
dependencies:
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-subagent-spawn':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent-spawn
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
'@deepseek-ai/dsh-workflow':
|
||||
specifier: workspace:^
|
||||
version: link:../workflow
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
vendor/cordis:
|
||||
dependencies:
|
||||
'@cordisjs/plugin-include':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1575,
|
||||
"AGENTS.md": 1590,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1890,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
|
||||
@@ -51,6 +51,8 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
|
||||
import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-vm'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog/tools.md'
|
||||
@@ -138,6 +140,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolTodo)
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-workflow',
|
||||
dir: 'tool-workflow',
|
||||
source: 'packages/workflow/tool-workflow/src/index.ts',
|
||||
async mount(ctx) {
|
||||
// The tool injects `workflows`; boot the vm engine over a scripted
|
||||
// subagent provider to satisfy it. The schema does not depend on which
|
||||
// provider backs the engine.
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentMock, { name: 'mock' })
|
||||
await ctx.plugin(VmWorkflowEngine, { provider: 'mock' })
|
||||
await ctx.plugin(ToolWorkflow)
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-web',
|
||||
dir: 'tool-web',
|
||||
|
||||
@@ -1,82 +1,365 @@
|
||||
{
|
||||
"comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.",
|
||||
"entries": [
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" }
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "Branded",
|
||||
"source": "packages/util/brand/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContentBlockMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "Message",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "MessageSourceMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "FinishReasonMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "GenerateOptions",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ToolSchema",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "SessionEvent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "Agent",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "HookContext",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "PromptDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "ContinuationDecision",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/core.md",
|
||||
"symbol": "SessionStartSource",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.md",
|
||||
"symbol": "StreamChunk",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.md",
|
||||
"symbol": "TokenUsage",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.md",
|
||||
"symbol": "ContentBlockMap",
|
||||
"source": "packages/llm/llm/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/llm-streaming.md",
|
||||
"symbol": "AppIdentity",
|
||||
"source": "packages/llm/llm/src/attribution.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SessionEventMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "TodoItem",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SessionEvent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "TurnTriggerMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "TurnEndReasonMap",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SurfaceEventType",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SurfaceOp",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SurfaceIntent",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "SurfaceNode",
|
||||
"source": "packages/core/session/src/surface.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "SessionHeader",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/persistence.md",
|
||||
"symbol": "CreateSessionOptions",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "ToolDefinition",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "SchemaProp",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "SchemaSpec",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "InferArgs",
|
||||
"source": "packages/core/tools/src/schema.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "ToolExecution",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "ToolExecutionResult",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "PreToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.md",
|
||||
"symbol": "PostToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashExecRequest",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashExecSpec",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashRunResult",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "CollectedOutput",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashTask",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashTaskRead",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsTarget",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsTargetKey",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsVersion",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsInfo",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsDirEntry",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsWriteIntent",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsWriteOutcome",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsEditRequest",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsEditOutcome",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsErrorCode",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FsPolicyExec",
|
||||
"source": "packages/fs/fs-policy/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/filesystem.md",
|
||||
"symbol": "FileReadOutcome",
|
||||
"source": "packages/fs/tool-fs/src/read-render.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.md",
|
||||
"symbol": "CompactionResult",
|
||||
"source": "packages/compact/compact/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentCapabilities",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentResult",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentStopReasonMap",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentRun",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentProvider",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebSearchRequest",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebSearchResult",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebSearchSource",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebFetchRequest",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebFetchResult",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebFetchBody",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/web.md",
|
||||
"symbol": "WebProviderStatus",
|
||||
"source": "packages/web/web/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.md",
|
||||
"symbol": "WorkflowStartRequest",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.md",
|
||||
"symbol": "WorkflowMeta",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.md",
|
||||
"symbol": "WorkflowResult",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/workflow.md",
|
||||
"symbol": "WorkflowRun",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,7 @@
|
||||
"./packages/fs/*/src",
|
||||
"./packages/compact/*/src",
|
||||
"./packages/subagent/*/src",
|
||||
"./packages/workflow/*/src",
|
||||
"./packages/web/*/src",
|
||||
"./packages/todo/*/src",
|
||||
"./packages/hooks/*/src",
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
{ "path": "./packages/subagent/subagent-spawn" },
|
||||
{ "path": "./packages/subagent/subagent-fork" },
|
||||
{ "path": "./packages/subagent/subagent-acp" },
|
||||
{ "path": "./packages/workflow/workflow" },
|
||||
{ "path": "./packages/workflow/workflow-vm" },
|
||||
{ "path": "./packages/workflow/tool-workflow" },
|
||||
{ "path": "./packages/todo/tool-todo" },
|
||||
{ "path": "./packages/hooks/hook-protocol" },
|
||||
{ "path": "./packages/hooks/hooks-claude" },
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
{ "path": "./packages/subagent/subagent-spawn" },
|
||||
{ "path": "./packages/subagent/subagent-fork" },
|
||||
{ "path": "./packages/subagent/subagent-acp" },
|
||||
{ "path": "./packages/workflow/workflow" },
|
||||
{ "path": "./packages/workflow/workflow-vm" },
|
||||
{ "path": "./packages/workflow/tool-workflow" },
|
||||
{ "path": "./packages/todo/tool-todo" },
|
||||
{ "path": "./packages/hooks/hook-protocol" },
|
||||
{ "path": "./packages/hooks/hooks-claude" },
|
||||
|
||||
Reference in New Issue
Block a user