Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

390 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.