# dsh-taskforce [English](./README.md) | [简体中文](./README.zh-CN.md) Multi-agent orchestration plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). Splits long tasks across multiple purpose-built agents so no single agent's context accumulates the whole task: a planner decomposes work into a task graph, each worker runs in a **fresh one-shot context** holding only what its task needs, and workers return **structured reports** instead of transcripts. Core loop: `plan → dispatch (parallel) → structured reports → review → pass | redo`. Status: **full loop + cross-restart recovery + Web panel + adoption prompt** — plan → dispatch → structured reports → review (pass/fail) → reroute → re-dispatch → finalize all work; on top of that the task graph survives a restart (the host's own log is replayed into it) and the conversation composer shows an orchestration panel — both verified live. A system-prompt section exists to get the pipeline **chosen** for large requests that never name it: it was verified that the section reaches the model, positioned after the workflow and subagent sections, but the behavioural half — whether the agent actually calls `orchestrate_plan` when nobody names it — is **not verified yet** (the run that would settle it was blocked on an invalid API key). State is keyed per conversation; worker tool access is whitelist-constrained; worker profiles are configurable. 16 offline test groups pass (CI runs them on every push/PR). See `../DESIGN.zh.md`. > **Naming**: the repository and the local folder keep the historical name > `dsh-orchestra`; the GitHub repo and the package are `dsh-taskforce`. The tool > names `orchestrate_*`, the service name `orchestra` and the projection key > `orchestraGraph` are frozen **on purpose** — they appear in the host's own > tool-call log, so renaming them would break recovery for sessions recorded > before the change. Do not "tidy" them up. ## Install From a local checkout (run `pnpm install` inside this directory after cloning — the bundle has one npm dependency; see [Compatibility](#compatibility) for the dsh versions this was verified against): ```sh git clone https://github.com/LiSheng5/dsh-taskforce.git pnpm install dsh plugin --profile web add /dsh-taskforce ``` `` must be an absolute path. `dsh plugin add` also accepts git specs directly (`github:LiSheng5/dsh-taskforce`) — pnpm then resolves the dependency at install time. **Using the desktop app rather than the CLI?** It ships its own launcher (`resources\runtime\cli\bin\dsh.cmd` on Windows) and manages the `desktop` profile exclusively: booting or dumping that profile from the CLI is refused, installing into it is not. ```sh "\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add \dsh-taskforce ``` The app's own sidebar **Plugins** page installs the same way — a registry name, a git address, a tarball or an absolute local path — and the bundle is enabled by default. Restart the app to load it. ## Compatibility | dsh version | status | |---|---| | `0.1.7-rc.2` | developed against; full loop, restart recovery and Web panel verified | | `0.2.0-rc.2` | the desktop app's runtime; install, mount and prompt assembly verified headless (the section renders where it should, tool text reaches the model) — the full orchestration loop and the Web panel have not been re-run on it | dsh is a developer preview and officially announces breaking changes. After upgrading dsh, run the offline regression suite once — it needs no dsh, no API key, no network: ```sh node test/local-loop.mjs ``` The same suite runs in CI on every push and pull request (`.github/workflows/offline-regression.yml`, Node 20 / 22 / 24), so "run it once after upgrading dsh" and "CI blocks regressions" are the same command. Upgrading from a version that predates the restart-recovery fix? Conversations it touched need a one-time repair — see [Upgrading from a version that predates the fix](#upgrading-from-a-version-that-predates-the-fix). A composition without `@deepseek-ai/dsh-system-prompt` loses exactly one thing: the prompt section that makes the pipeline get chosen on its own. Every tool still works when it is named explicitly. ## Safety This plugin spawns **real sub-agents with real powers**: file reads/writes and shell access inside the session workspace, bounded by each worker profile's tool restrictions. Read [dsh's own SAFETY notes](https://github.com/deepseek-ai/deepseek-harness) before running orchestrations on workspaces you care about, keep the per-task timeout and attempt caps enabled, and treat worker reports as claims — that is what the reviewer is for. Workers also lose the host's agent-plane **control tools** by default (`send_message`, `interrupt_agent`, `list_agents`, `subagent`, `subagent_fork`): `send_message` would post a worker's text straight into the **orchestrator's session**, which is exactly what this plugin exists to prevent, and the others let a worker disturb its parent or start more agents. This list was calibrated by measurement (a real researcher worker reported exactly these tools); set `denyControlPlane: false` to keep them. ## Layout ``` index.js plugin entry: name / inject / apply client.js browser half: the panel above the composer (lazy __ModuleLoader__ factory) cordis.patch.yml bundle patch inserted into the profile composition src/protocol.js Task / Report / Verdict JSON schemas (host JSON-Schema subset) src/graph.js DAG validation, readiness, status transitions (CAS) src/service.js orchestration state (ctx.provide('orchestra')) src/events.js restart recovery: folds the host's tool/call + tool/result back into a graph src/router.js context router: the task book a worker receives src/profiles.js agent_type → persona / reasoning effort src/prompts.js every model-facing string: the adoption section + planner guidance src/json-schema.js argument validator (subset semantics) src/retry.js auto-retry for malformed tool-JSON stream failures src/tools/ plan / status / dispatch / review / reroute / finalize (+ ping) scripts/ maintenance: repair session logs written by older versions test/local-loop.mjs offline regression suite (fake host, no network) ``` ## Restart recovery The graph lives in memory, keyed per conversation, so a restart loses it. What brings it back is the session log the host already keeps: - dsh logs every tool call as `tool/call` (full arguments) and `tool/result` (rendered output), so orchestration state is durable in the host's own vocabulary — the plugin writes no session events of its own. - The plugin registers an `orchestraGraph` session projection (under `ctx.inject([...])`, so a composition without the projection registry is unaffected) that purely folds those events into the task graph, and loads it the first time a fresh process touches the conversation. - **A call's effect lands when its result settles.** The host writes `tool/call` before the tool body runs, so applying eagerly would apply the call being executed right now a second time. A dispatch that was cut short by a restart is therefore never half-applied either: its task stays where the orchestrator last saw it — ready to run again. - Without the projection service the plugin simply runs in-memory, as before. - Conversations recorded **before the `✓ — ` render contract** (early builds) recover their plan, attempts, verdicts and finalized flag but not their dispatch outcomes: the old digest carries no success marker, and inventing one would be worse than leaving the task where it stopped. Such a result still consumes its pending call, so it is never misreported as interrupted by a restart. `orchestrate_status` answers with the recovered graph after a restart. ### Upgrading from a version that predates the fix Early versions appended their own event types, which made those conversations **unloadable after a dsh restart** (`contains event type "orchestra/graph" … refusing to interpret the log`). The repair script adds the harness's own compatibility marker (`ignorable`) so they load again, backing up first: ```sh node scripts/repair-session-logs.mjs --dry-run # report first node scripts/repair-session-logs.mjs # repair (self-checked, backup kept) node scripts/repair-session-logs.mjs --restore # undo ``` ## Web UI panel Conversations that have an orchestration show a panel above the composer (a conversation that never planned anything renders nothing at all): ``` ▾ ▣ orchestra #1 · 1/2 · review 1 PASS ───────────────────────────────────── ✓ t1 researcher completed att 1 read package.json — name dsh-taskforce, version 0.1.0 … · t2 coder pending → ready ``` - It reads the **wire view** of the same `orchestraGraph` projection (`client.js` calls `useProjection('orchestraGraph')`), so it shows exactly what `orchestrate_status` reports, updates itself as events arrive, and after a restart shows the recovered graph. - Only ids, agent types, statuses, attempts, readiness, report summaries, diagnostics and issue counts cross to the browser; worker personas stay host-side. - The browser half is a **build-free plain script** (`client.js`, declared by `dsh.client.platform=web` with `immediately`). It inherits the host theme via `currentColor` and pulls in no stylesheet or third-party dependency. - Uninstalling the plugin removes the panel with its loader row; the rest of the conversation UI is untouched. ## Tools The plugin also registers a **system-prompt section** (`tool:orchestrate_plan`, order 2810) so the host agent reaches for the pipeline on a large request it was never told to name — measured behaviour before this was `todo_write` plus doing the work itself. It follows the host's own convention (workflow owns order 2600, subagent 2800), is registered under `ctx.inject(['systemPrompt'], …)` so a composition without that service merely loses the advocacy, and renders as an empty string for any agent that cannot see `orchestrate_plan` — which is every worker, because a spawned child's persona only shadows the deployment persona and global sections do reach it. All model-facing text lives in `src/prompts.js`. - `orchestrate_plan` — submit the task graph (mission_brief + tasks); validates fail-loud and returns the ready set. Its description is where the decomposition rules live: workstreams are how you *find* the cut but must be restated as file ownership, task counts scale with the request, the parallel-writer hazard, two worked skeletons, and the anti-patterns. - `orchestrate_status` — compact one-row-per-task view plus dispatchable ids, and the diagnostic for tasks that failed without producing a report. - `orchestrate_dispatch` — spawn ready tasks as isolated one-shot workers, block until they settle, return one report digest per task (≤4 concurrent). - `orchestrate_review` — run the reviewer over the reports; returns a verdict. - `orchestrate_reroute` — send review issues back to workers with guidance. - `orchestrate_finalize` — record the delivery summary and lock the graph. - `orchestra_ping` — liveness probe. ## Configuration (worker personas, models, limits) **The orchestrator writes per-task personas itself**: each task in a plan may carry a `persona` field — a role definition A tailors to that task ("你是数据库 迁移专家,只改 SQL 文件"). It becomes the worker's primary system prompt, layered above the profile's `discipline` (non-negotiable constraints that A cannot override: researchers don't write, reviewers don't fix, …). `model` and `reasoning_effort` are likewise per-task fields A sets at plan time. Two more ways to tune what each worker role is like — both take effect after restarting the dsh process: 1. **Edit `src/profiles.js` directly.** Each role has `role` (identity line), `discipline` (permanent constraints), `effort` (`off|low|high|max`), and `toolDenyPatterns` — regexes matched (case-insensitively) against the host's global tool names to hard-restrict that role's tools. 2. **Config overrides (no code edit).** Add a `config:` block to the plugin entry in the profile's `cordis.patch.yml` (e.g. `~/.dsh/profiles/web/cordis.patch.yml`): ```yaml - id: dsh-taskforce name: dsh-taskforce config: taskTimeoutMs: 1200000 maxConcurrentWorkers: 6 profiles: researcher: role: | 你是资深代码考古学家…… discipline: | 只读,不修改任何文件。 effort: off toolDenyPatterns: ["write", "bash"] coder: model: deepseek-chat ``` Config `role`/`discipline`/`model`/`effort` replace the builtin values; `toolDenyPatterns` are concatenated with the builtin ones. Engine knobs: `provider` (default `spawn`), `maxConcurrentWorkers` (4), `maxAttempts` (3), `maxReviewRounds` (3), `taskTimeoutMs` (600000). ## Plan-time worker programming The orchestrator is not limited to the four builtin worker types. In a plan it may **declare new worker types** — `orchestrate_plan` accepts a `workers` field where A programs one agent spec per type: ```jsonc "workers": [ { "type": "ui-builder", "role": "你是界面构建师,专注像素级还原。", // identity (required for new types) "discipline": "不得改动 src/api/ 下的文件。", // optional; generic floor otherwise "effort": "high", "model": "deepseek-flash", // optional defaults "toolDenyPatterns": ["bash"] } ] ``` - Tasks then reference `"agent_type": "ui-builder"`. Shadowing a builtin type (`"type": "coder"`, no role) replaces only the parts it names. - The layering stays: a task-level `persona` still layers above the declared role, and the declared `discipline` is always appended — A cannot write it away. - The review loop is not reprogrammable from plans: the reviewer always runs on the operator-configured profile. - Engine-level guarantees are unaffected: orchestration tools are always denied to workers, delegation depth prevents grandchild spawning, tasks are one-shot isolated sessions. ## Design notes - **Hand-built tool definitions, near-zero imports**: tool definitions match what `defineTool` compiles to; the only dependency is the npm-published `@deepseek-ai/schemastery` for the Config schema, so pnpm-local-path installs stay trivial. - **Context isolation**: workers are spawned (never forked) one-shot subagents; upstream information travels only as structured reports, never as history. - **Prompt text is layered and frozen**: a system-prompt section says WHEN to enter the pipeline, the `orchestrate_plan` description says HOW to decompose, and the parameter descriptions carry per-field rules — all of it in `src/prompts.js` as module constants, because sections and tool schemas are re-sent on every request and byte-stable text is what keeps the provider's prefix cache reusable. Both texts reach the orchestrator only: a worker gets an empty section and never sees the planner's schema, so `TASK_ITEM_SCHEMA` wording costs nothing per worker turn. The offline suite enforces a character budget on each, so the per-request cost cannot silently grow. - **Decompose by deliverable, never by role**: the planner guidance was settled against a survey of four other agent systems — Claude Code subagents, dsh's own workflow/subagent sections, Anthropic's multi-agent research system and ZCode. None of them splits work by role (UI / art / gameplay, or frontend / backend); each asks for a self-contained deliverable or a subsystem, and Anthropic scales the count to the request instead of fixing a range. The reason is mechanical: a role owns no files, so role-shaped tasks either collide in the one shared workspace or hand back prose. Workstreams are therefore taught as the way to *find* the cut, which must then be restated as file ownership — and the planner is told not to promise artifacts no worker can produce, since this host has no image, audio or video generation tool. - **Per-conversation state**: the orchestration graph is keyed by the calling agent's session id — simultaneous conversations never share state. - **Layered worker restriction**: orchestration tools are always denied to workers; profile deny-patterns are resolved against the host's global tool view at dispatch time (unknown names are never passed, so a bad pattern degrades instead of throwing). Grandchild delegation is impossible by delegation depth. Control-plane tools can only be denied by PATTERN — they are not ours and may not exist in every composition — so `resolveToolFilter` always enumerates first and matches second. - **Only the host's vocabulary reaches the log**: orchestration state rides the host's own tool-call records (`tool/call` + `tool/result`); the plugin adds host-log audit lines (`orchestra/graph`, …) and no session event type the harness would refuse to read back. - **Recovery replays the service itself**: the fold calls the same `submitPlan` / `markDispatched` / `settleReport` methods with the recorded arguments instead of reimplementing the state machine, so a recovered graph cannot drift from live semantics. ## License MIT — see [LICENSE](LICENSE).