8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
390 lines
19 KiB
Markdown
390 lines
19 KiB
Markdown
# dsh as a cheap worker
|
||
|
||
Owner instruction, 2026-09-02 (Discord/session, verbatim): "你再帮我整个dsh,然后
|
||
协调通,让dsh里面的agent也当你的sub agent,他们还便宜" — stand up dsh, wire it
|
||
in so agents running inside dsh act as sub-agents for the coordinator, because
|
||
they are cheaper.
|
||
|
||
## What dsh is
|
||
|
||
**dsh = DeepSeek Harness**, DeepSeek's own official agent CLI —
|
||
`@deepseek-ai/dsh` on npm, MIT, built on the `@deepseek-ai/cordis` plugin
|
||
kernel (<https://github.com/deepseek-ai/deepseek-harness>). It is a different
|
||
agent runtime in the same slot `claude` (GOD_MOD) and the old `dsh web`
|
||
button occupied — see
|
||
`WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/script.py`'s
|
||
own comment on what it replaced, and `tools/aiextract_run.py`'s module
|
||
docstring, which documents dsh's local RPC wire contract in detail (dsh
|
||
serves `POST http://127.0.0.1:3080/api/<method>` with `session.create` /
|
||
`session.prompt` / `session.history` / `session.cancel`, confirmed against a
|
||
real reply on 2026-09-01).
|
||
|
||
Config lives under `$DSH_HOME` (default `~/.dsh`): `profiles/<name>/` (a
|
||
patch-layered plugin stack), `settings.yaml` (models/providers), and
|
||
credentials via a credentials service. `dsh --version` reports
|
||
`0.1.1-rc.2`.
|
||
|
||
## The route actually in use — NOT DeepSeek's own endpoint
|
||
|
||
**The owner's own words, 2026-09-02: "dsh我弄好了,不过用的是opencode go的API,
|
||
不过一样能用。"** — he got dsh working, but through an *opencode go* API
|
||
route rather than DeepSeek's official one, and it works the same either way.
|
||
Confirmed live on this machine by reading his config (never its secret
|
||
value) rather than assuming:
|
||
|
||
- **Profile**: `headless` — dsh's own one-shot mode, unchanged from the
|
||
original plan. `dsh --profile headless "task text"`: *"Answer one task,
|
||
print the final assistant message, and exit"* (its own `--help` text).
|
||
- **Route**: `~/.dsh/settings.yaml` carries a top-level `agent-default-model`
|
||
override that the built-in `dsh-agent-default-model` plugin (whose
|
||
compiled-in default is `provider: deepseek-official`) does not show in
|
||
`dsh --dump-config` — that command prints the composed *bundle* tree, not
|
||
the runtime settings-file layer, so the override is real but invisible to
|
||
that flag. The live override points at provider id **`openocde-go`**
|
||
(that is the literal string in his config, letters swapped from
|
||
"opencode" — his file, not a typo this repo should "fix") — declared
|
||
under `llm-pi-ai.providers.openocde-go` with `api: openai-completions`,
|
||
`baseURL: https://opencode.ai/zen/go/v1`.
|
||
- **Model**: `deepseek-v4-flash`, per that same `agent-default-model`
|
||
override. The provider's own model list also carries `glm-5.3-flash`,
|
||
`deepseek-v4-pro`, `deepseek-v4-flash-vision-exp`, and `hy4-preview` as
|
||
options, but `deepseek-v4-flash` is what a plain `dsh --profile headless`
|
||
call actually boots.
|
||
- **Credential mechanism**: dsh's own credentials service
|
||
(`~/.dsh/.credentials.yaml`, the `dsh-credentials-local` plugin), keyed by
|
||
the env-var name the provider declares (`apiKeyEnv: OPENOCDE_GO_API_KEY`)
|
||
— **not an OS environment variable**. `OPENOCDE_GO_API_KEY` is unset in
|
||
every shell on this machine; dsh resolves it through the credentials file
|
||
instead, which is why the original worker's assumption (`export
|
||
DEEPSEEK_API_KEY`) never applied here and a plain shell round trip works
|
||
without that variable being set anywhere visible to `echo $OPENOCDE_GO_API_KEY`.
|
||
|
||
None of this was changed to write this document — it is a description of
|
||
what was already there, confirmed with `dsh --dump-config`, `dsh --help`,
|
||
and reading (not printing) `settings.yaml` and `.credentials.yaml`'s key
|
||
*names*, never their values.
|
||
|
||
**This means the worker must not hardcode a profile.** A different machine
|
||
with a real `DEEPSEEK_API_KEY` and no `settings.yaml` override gets dsh's
|
||
stock `deepseek-official` route through the exact same `headless` profile
|
||
and the exact same `tools/dsh_worker.py` — the profile is the only thing the
|
||
CLI needs told, and it is now `$DSH_WORKER_PROFILE` / `--profile`,
|
||
defaulting to `headless`, never baked in.
|
||
|
||
## A Windows-specific bug this task found and fixed
|
||
|
||
`dsh` installs on Windows as a `dsh.CMD` shim (`npm install -g` puts it at
|
||
`%APPDATA%\npm\dsh.CMD`) that forwards its arguments to `node
|
||
<shim-dir>\node_modules\@deepseek-ai\dsh\lib\bin.js`. Handing that shim a
|
||
multi-line task through Python's `subprocess.run([...])` (no `shell=True`)
|
||
still routes through cmd.exe to resolve the `.CMD` file, and **cmd.exe's own
|
||
command-line parser treats a newline inside an argument as a command
|
||
separator — silently truncating the task at the first newline, exit 0, no
|
||
error.** Measured 2026-09-02: a two-line task reached dsh as its first line
|
||
alone, and dsh answered a real, well-formed reply to that truncated text —
|
||
the most dangerous shape of this bug, because nothing about the reply looks
|
||
wrong on its own; it just answers a different, shorter question than the one
|
||
sent.
|
||
|
||
`tools/dsh_worker.py`'s `_resolve_launch_cmd` now detects a `.cmd`/`.bat`
|
||
shim on Windows and calls `node.exe` on the resolved `bin.js` directly,
|
||
bypassing cmd.exe's argument parsing entirely. Off Windows, or when the
|
||
shim can't be resolved to a real `bin.js` next to it, the shim is invoked
|
||
exactly as before. There is also a plain `UnicodeDecodeError` this same code
|
||
path hit before the fix (Windows console codepage vs. UTF-8 dsh output) —
|
||
`subprocess.run(..., encoding='utf-8', errors='replace')` is now explicit
|
||
rather than left to `text=True`'s platform default.
|
||
|
||
## The worker: `tools/dsh_worker.py`
|
||
|
||
```
|
||
python tools/dsh_worker.py "task text on the command line"
|
||
python tools/dsh_worker.py --file task.md
|
||
echo "task text" | python tools/dsh_worker.py -
|
||
python tools/dsh_worker.py --profile headless --timeout 300 "task"
|
||
python tools/dsh_worker.py --plain "task text" # reply/reason only, no JSON
|
||
```
|
||
|
||
Prints exactly one JSON object to stdout and sets the exit code to match:
|
||
|
||
```
|
||
{"ok": true, "reply": "<the agent's final message>"} exit 0
|
||
{"ok": false, "refused": true, "reason": "<what went wrong>"} exit 1
|
||
```
|
||
|
||
`$DSH_WORKER_PROFILE` overrides the default profile (`headless`);
|
||
`$DSH_WORKER_TIMEOUT_S` overrides the default per-call timeout (300s). Both
|
||
are also settable per-call with `--profile` / `--timeout`. `--plain` is the
|
||
one non-default output shape — reply text only on success, reason text only
|
||
on refusal — meant for a human at a terminal; every caller that is code
|
||
should use the default JSON shape, which is the "structured mode" that lets
|
||
a caller tell a reply from a diagnostic without parsing prose. `--json` also
|
||
exists as an explicit no-op for a caller that wants to say so.
|
||
|
||
### Two real transcripts, captured 2026-09-02 against the live config above
|
||
|
||
**Trivial round trip:**
|
||
|
||
```
|
||
$ python tools/dsh_worker.py "reply with exactly: pong"
|
||
{"ok": true, "reply": "pong"}
|
||
$ echo $?
|
||
0
|
||
```
|
||
(3.5s wall clock.)
|
||
|
||
**Realistic task** — a mechanical rewrite with a checkable output, the exact
|
||
shape this worker is *for* (docstring text below is illustrative, not a real
|
||
file in this repo):
|
||
|
||
```
|
||
$ cat task.txt
|
||
Rewrite the following Python docstring in plain, non-jargon English, at
|
||
most 3 sentences. Reply with ONLY the rewritten text, nothing else.
|
||
|
||
Original docstring:
|
||
"""
|
||
Resolve the effective knowledge index for a job by merging DEFAULTS, the
|
||
catalogue layer, the plant layer, the job layer, and the project config
|
||
layer, in that order, and return the merged mapping along with a list of
|
||
provenance tuples (layer_name, key_path) for every key that was overridden
|
||
by a layer after DEFAULTS. Raises ConfigMissingError if a layer pointer is
|
||
present but the referenced file cannot be found on disk.
|
||
"""
|
||
|
||
$ python tools/dsh_worker.py --file task.txt
|
||
{"ok": true, "reply": "Merges the default settings with the catalogue,
|
||
plant, job, and project configuration layers in that order, then returns
|
||
the combined settings along with a list showing which keys each later
|
||
layer changed. Raises ConfigMissingError if a layer points to a file that
|
||
cannot be found."}
|
||
$ echo $?
|
||
0
|
||
```
|
||
(10.2s wall clock. The rewrite is accurate and reads well — see "What this
|
||
is actually good for" below for how far that generalizes.)
|
||
|
||
Both of these were refusals under the original worker before the Windows
|
||
argv fix and the credential setup landed: the first version of this file
|
||
recorded a real `MISSING_CREDENTIAL` refusal (correct behaviour for a
|
||
missing key, at the time it was true), and a naive re-test of the second
|
||
example — multi-line task text through the un-fixed worker — silently
|
||
truncated to the first line and returned a plausible-looking but wrong
|
||
answer, which is exactly the failure mode CLAUDE.md's "could not judge" vs.
|
||
"judged, found nothing" rule exists to prevent one layer further down than
|
||
the check that already guards it: this was a transport bug, not a dsh
|
||
refusal, so no `DshRefusal` fired for it, and the fix had to be in the argv
|
||
path, not the refusal table.
|
||
|
||
### Failure/refusal behaviour
|
||
|
||
| what happened | what the worker returns |
|
||
| --- | --- |
|
||
| dsh not on PATH | refusal naming the missing binary and the install command |
|
||
| empty task text | refusal, nothing sent |
|
||
| dsh exits non-zero | refusal quoting dsh's own stderr verbatim |
|
||
| dsh times out | refusal naming the timeout budget |
|
||
| the process cannot even start (e.g. permissions) | refusal naming the OS error |
|
||
| **dsh exits 0 but prints nothing** | **refusal** — "exited 0 but printed no reply ... could not judge, not judged-empty" |
|
||
|
||
The last row is still the one worth calling out by name: an exit-0,
|
||
empty-stdout result is the exact silent-pass shape CLAUDE.md's skip-probe
|
||
table exists to prevent for Ticket QC checks. This worker refuses that shape
|
||
rather than returning `{"ok": true, "reply": ""}`.
|
||
|
||
Tests: `WillTools.extension/tests/test_dsh_worker.py` — faking the
|
||
`which()` / `runner()` subprocess boundary, plus dedicated cases for
|
||
`_resolve_launch_cmd`'s Windows-shim resolution, so the argv-truncation
|
||
fix is covered without needing a real Windows `.cmd` shim on disk.
|
||
|
||
## The pool: `tools/dsh_pool.py`
|
||
|
||
`dsh_worker.py` alone is one task at a time. The actual point of a cheap
|
||
backend is running many small tasks *at once* — that is what
|
||
`tools/dsh_pool.py` adds: a bounded `ThreadPoolExecutor` fan-out over
|
||
`dsh_worker.run_task`, N tasks in, N results out, in input order, each
|
||
result independently `{"ok": true, ...}` or `{"ok": false, "refused": true,
|
||
...}`.
|
||
|
||
```
|
||
python tools/dsh_pool.py --tasks tasks.json --out results.json
|
||
python tools/dsh_pool.py --tasks tasks.json --max-workers 8 --task-timeout 120
|
||
```
|
||
|
||
`tasks.json` is a JSON array — either plain strings, or objects carrying a
|
||
`"task"` key plus whatever else the caller wants echoed back (an `"id"`, a
|
||
`"label"`) so results can be matched by identity rather than array position:
|
||
|
||
```json
|
||
[
|
||
{"id": "t1", "task": "Reply with exactly: one"},
|
||
{"id": "t2", "task": "Reply with exactly: two"},
|
||
{"id": "t3", "task": "What is 17 * 3? Reply with just the number."},
|
||
{"id": "t4", "task": "In one short sentence, what does 'refactor' mean to a programmer?"}
|
||
]
|
||
```
|
||
|
||
Concurrency cap: `--max-workers` / `$DSH_POOL_MAX_WORKERS` (default 4).
|
||
Per-task timeout: `--task-timeout` / `$DSH_POOL_TASK_TIMEOUT_S` (default:
|
||
same as `dsh_worker`'s own, 300s).
|
||
|
||
### A real fan-out run, captured 2026-09-02 (4 tasks, `--max-workers 4`)
|
||
|
||
```
|
||
$ python tools/dsh_pool.py --tasks tasks.json --max-workers 4
|
||
[
|
||
{"id": "t1", "ok": true, "reply": "one"},
|
||
{"id": "t2", "ok": true, "reply": "two"},
|
||
{"id": "t3", "ok": true, "reply": "51"},
|
||
{"id": "t4", "ok": true, "reply": "Refactoring means restructuring
|
||
existing code to improve its internal design (readability,
|
||
maintainability, performance) without changing its observable
|
||
behavior."}
|
||
]
|
||
$ echo $?
|
||
0
|
||
```
|
||
|
||
5.6s wall clock for 4 tasks that individually take several seconds each —
|
||
the fan-out is real, not serialized. `17 * 3 = 51` came back correct;
|
||
worth noting for the "what this is good for" section below, since a wrong
|
||
arithmetic answer here would have been a much cheaper thing to catch than
|
||
one buried in real work.
|
||
|
||
One task failing never takes the batch down: `_run_one` (inside
|
||
`dsh_pool.py`) catches both `DshRefusal` and any other exception per task
|
||
and turns it into that task's own refusal-shaped result, so task 3 raising
|
||
never prevents tasks 1, 2, and 4 from completing or reorders them.
|
||
|
||
The CLI's own exit code reports whether the *batch* ran to completion, not
|
||
whether every task inside it succeeded — a caller checks each result's
|
||
`"ok"` field for that, by design: a batch exit code that went non-zero on
|
||
any single refusal would make a script stop trusting the N-1 tasks that
|
||
actually worked.
|
||
|
||
Tests: `WillTools.extension/tests/test_dsh_pool.py` — ordering preserved
|
||
under out-of-order completion, one failure isolated (both a `DshRefusal`
|
||
and an arbitrary exception), a refusal shape kept distinguishable from a
|
||
short successful reply, the worker cap actually measured under load (not
|
||
just accepted as an unused argument), and the tasks-file loading/echo
|
||
behaviour.
|
||
|
||
## What to route to it, and what not to — grounded in what was actually observed here, not a guess about model quality
|
||
|
||
The two transcripts above and CLAUDE.md's own measured history line up
|
||
exactly, and the second is the harder-won lesson:
|
||
|
||
**A cheap-model extraction pass over one document drop in this repo's own
|
||
history carried a 42% page-error rate on independently sampled pages** —
|
||
inverted inequalities, misidentified parts, specs blurred into vagueness.
|
||
**A sub checking its own family's work reported zero errors on the same
|
||
material** where an independent auditor found that 42%. That is not "dsh is
|
||
bad" — the two transcripts in this document show it doing accurate, useful
|
||
work on scoped tasks (a docstring rewrite that reads correctly, correct
|
||
arithmetic, a fan-out where every one of four small, independent tasks came
|
||
back right). It is specifically that **a cheap model cannot be trusted to
|
||
audit itself, or anything else, at the standard this repo demands of a
|
||
number that lands somewhere consequential** — the checker has to be a
|
||
different pass, ideally a different model or a human, not the same model
|
||
grading its own paper.
|
||
|
||
**Route to dsh (cheap, routine, and — this is the load-bearing word —
|
||
CHECKABLE by something other than the same cheap model):**
|
||
- A well-scoped drafting task with a clear, verifiable output — a docstring
|
||
rewrite, a commit-message draft, a regex, a summary of a file — where the
|
||
caller (a human or a second, independent pass) reviews the output before
|
||
it lands anywhere that matters.
|
||
- Mechanical rewrites with a spec the caller can check itself — the
|
||
docstring example above is the template: input and output are both short
|
||
enough that verifying the rewrite is *faithful* takes seconds, even
|
||
though writing it took the model 10 seconds of "thinking."
|
||
- Wide fan-out where one bad answer in isolation is cheap: N similar small
|
||
tasks (dsh_pool.py's whole reason to exist) where a wrong result is one
|
||
wrong row a caller can eyeball or spot-check, not a silent poison in an
|
||
aggregate.
|
||
- Summarization and other reductions where "close enough, and skimmable
|
||
fast" is genuinely the bar, not "exactly right."
|
||
|
||
**Do NOT route to dsh, ever:**
|
||
- **Anything landing a NUMBER in a knowledge layer.** This is exactly what
|
||
`knowledge/library/RUNBOOK.md`'s three-stage discipline (extract cheap,
|
||
audit independently, land off the page) is built to prevent a cheap model
|
||
from doing unsupervised — a number from a dsh reply is a *note*, never a
|
||
landed value, and it lands only after a human or an independent pass
|
||
re-reads the primary source.
|
||
- **Any audit of another model's work — cheap or not.** The 42%-vs-zero
|
||
result above is the concrete, measured reason: self-audit (or
|
||
same-family audit) is not audit, and dsh grading dsh's own output (or a
|
||
Claude subagent's) inherits that exact blind spot.
|
||
- **Anything touching the live Revit bridge, a Transaction, or the open
|
||
model.** Those go through `tools/rvt.py` under a human's own session per
|
||
CLAUDE.md §1/§3 — not through a detached headless worker with no view of
|
||
what it is touching and no way to be stopped mid-mutation.
|
||
- **Anything needing the coordinator's actual conversation context.** A
|
||
headless `dsh --profile headless` call starts a fresh session with only
|
||
the task text handed to it — no memory of anything the coordinator has
|
||
done or decided this session, confirmed by this task's own transcripts
|
||
(each call above got exactly and only what was in the task string, no
|
||
more).
|
||
- Any task whose failure mode is expensive and hard to notice — a plausible
|
||
wrong number, a wrong fact presented fluently. Cheap-model output for that
|
||
class of task needs the same landing discipline
|
||
`knowledge/library/RUNBOOK.md` requires everywhere else: land only off
|
||
the primary source, never off a model's note, cheap or expensive.
|
||
|
||
## The exact command a caller uses
|
||
|
||
For one task:
|
||
|
||
```
|
||
python tools/dsh_worker.py "task text" --profile headless --timeout 300
|
||
```
|
||
|
||
(Omit `--profile` to use `$DSH_WORKER_PROFILE` or the `headless` default;
|
||
omit `--timeout` for `$DSH_WORKER_TIMEOUT_S` or 300s.) Parse stdout as one
|
||
JSON object; check `"ok"` before trusting `"reply"`.
|
||
|
||
For a batch:
|
||
|
||
```
|
||
python tools/dsh_pool.py --tasks tasks.json --out results.json \
|
||
--max-workers 4 --task-timeout 300
|
||
```
|
||
|
||
Parse `results.json` (or stdout, if `--out` is omitted) as a JSON array in
|
||
the same order as `tasks.json`; check each entry's `"ok"` independently —
|
||
one refused task must never be read as the whole batch having failed, and a
|
||
successful-but-short reply must never be mistaken for a refusal or vice
|
||
versa (both are enforced by construction: `"reply"` only appears on
|
||
`ok: true`, `"reason"`/`"refused"` only on `ok: false`).
|
||
|
||
## Cost, relative to a Claude subagent
|
||
|
||
DeepSeek's own published API pricing (api-docs.deepseek.com, checked
|
||
2026-09-02) for the V4 Flash / Vision-Exp tier: **off-peak input $0.22 /
|
||
output $0.66 per million tokens (cache-miss), roughly half that at peak
|
||
hours 01:00-04:00 and 06:00-10:00 UTC weekdays**, with cache-hit input far
|
||
cheaper still. That undercuts a Claude subagent's per-token API price by
|
||
roughly an order of magnitude on the numbers checked. The actual route in
|
||
use here goes through the opencode go API rather than DeepSeek's own
|
||
endpoint, so that published pricing is DeepSeek's own model cost, not
|
||
necessarily what opencode go itself charges for the same route — treat the
|
||
ratio as "cheaper, by a lot," not a guaranteed multiplier, and re-check
|
||
before quoting a precise number in anything the owner will rely on
|
||
financially.
|
||
|
||
## What is NOT built
|
||
|
||
This is a synchronous-per-task, bounded-concurrency CLI bridge. It is not:
|
||
- a persistent multi-turn session manager (that already exists, in
|
||
`tools/aiextract_run.py`'s `session.*` RPC client, for the one caller that
|
||
needs to poll a long-running vision pass);
|
||
- an automatic task router that decides *for* the coordinator which tasks go
|
||
to dsh vs. a Claude subagent — that decision is made by whoever is
|
||
dispatching work, using the routing section above;
|
||
- a retry/backoff layer — a refused task in a pool run stays refused; a
|
||
caller that wants a retry re-submits that one task itself, since retrying
|
||
automatically would blur "this dsh call failed" into "this dsh call
|
||
succeeded on attempt 2," which is a different, noisier signal than the
|
||
one this file is built to keep honest.
|