dsh-debate
Stop asking your agent what it thinks. Make it hold a trial.
- Stars
- 1
- Language
- TypeScript
- Created
- Sep 2, 2026
- Updated
- Sep 3, 2026
Introduction
dsh-debate
Stop asking your agent what it thinks. Make it hold a trial.
A DeepSeek Harness plugin that answers a hard question by staging a formal debate — two adversarial subagents argue opposite sides with real research, and the main agent rules on the transcript.
English · 简体中文

Why
Ask an agent "should we migrate to X?" and you get one voice reasoning in one direction. It will find the arguments that support wherever it started, because nothing in the loop is paid to attack them.
dsh-debate puts an adversary in the loop. The FOR side and the AGAINST side are separate one-shot subagents that never see your conversation, never see the judge's opinion, and are each told to win. Both may read the workspace and search the web before speaking, so a claim is something a debater can show rather than something it remembers. Only after the last round does the main agent break its silence and rule.
What you get back is not a confident paragraph. It is a transcript where the strongest case against your idea was made on purpose, by something that wanted to beat it.
How it works
sequenceDiagram
autonumber
actor User
participant Judge as Judge<br/>your agent
participant For as FOR debater<br/>fresh child
participant Against as AGAINST debater<br/>fresh child
User->>Judge: Debate whether we should adopt X, 2 rounds
Judge->>Judge: debate_open — fix the motion and round count
loop each round — debate_round
Judge->>For: motion + full transcript so far
For->>For: read / glob / grep / web_search / web_fetch
For-->>Judge: speech FOR
Judge->>Against: motion + transcript, including the speech just made
Against->>Against: research and rebut
Against-->>Judge: speech AGAINST
end
Judge->>Judge: debate_verdict — assess round by round, rule once
Judge-->>User: winner, reasoning, and the whole transcript
Three tools split one debate across several model turns, so you watch it happen instead of waiting on one giant call:
| Tool | What it does |
|---|---|
debate_open | Fixes the motion and how many rounds each side gets. Returns a debate_id. |
debate_round | Spends exactly one round: a fresh FOR child speaks, then a fresh AGAINST child that has already read it. Returns both speeches. |
debate_verdict | The judge's only assessment: a round-by-round review, the winner (pro / con / draw), and why. Closes the debate. |
One round per call bounds a single call's spend at two children, streams the debate into the UI as it runs, and lets a failed round be retried without re-arguing the rounds before it.
Highlights
- Genuine adversaries, not role-play. Each speech is a fresh subagent with its own persona and no access to the judging conversation, so a debater argues its assigned side instead of drifting toward whatever the judge already believes.
- Debaters do research. The default tool set is
read,glob,grep,web_search,web_fetch— enough to check a claim about your repository or about the world, and cite it precisely enough for the judge to verify. - Read-only by construction. Nothing a debater is handed can write a file or run a command. A side that could act would be doing work on the judge's behalf while pretending to argue.
- Degrades instead of failing. A tool your deployment doesn't mount is dropped from the list and logged once, not fatal. Web search is withheld until its credential actually resolves, so no debater burns its single turn discovering an unauthenticated backend.
- One folded card in the Web UI. The whole debate collapses into a single Chat card that fills in round by round, with
zh/endictionaries built in. - Survives a reload. A debate is recorded in the
metaof thetool/resultevents the tools already write, so reopening the session rebuilds the card. The plugin declares no session event type of its own — see Design notes for why that matters. - Zero footprint on the harness. It installs into a
dshprofile as a bundle. Nothing in the harness installation or its checkout is modified.
What a debate looks like
Every screenshot below is one real two-round debate, argued against a live codebase. Nothing is mocked up.
The motion and the first round — each side's speech cites the files it actually opened, and Truncated marks a speech that reached maxSpeechLength:

The judge stays silent until the last round, then rules once:

Requirements
- Node
^22.19or>=24, and pnpm. - A deepseek-harness checkout to build against. Every script locates it through
DSH_HARNESS; the variable may be omitted only when the checkout sits at../deepseek-harnessor../../deepseek-harness, which the scripts probe in that order. An npm-installeddshis not enough — the build reads harness sources (packages/client/web/src/platform.tsand thetsconfig.base*.jsonpath maps). - That checkout must have had
pnpm installrun in it.pnpm run typecheckadditionally needs it built (pnpm run buildthere), because the leaf configs resolve harness packages through their project references. - To install: either a
dshonPATH, or that same checkout, whose source launch the scripts use whendshis absent.
Nothing in this repository hardcodes a path: a clone carries no links, and setup writes them for whichever checkout that run resolved. Pointing at a missing or wrong directory fails at setup with the paths it tried.
Quick start
To install the published bundle into a profile, no checkout is needed — just a dsh on PATH:
dsh plugin --profile web add @shlouai/dsh-debate
To build and install from this checkout instead:
export DSH_HARNESS=/path/to/deepseek-harness # unnecessary if it is a sibling of this repository
pnpm install
pnpm run setup # link this repository to the harness checkout
pnpm run build # emit lib/
pnpm run install:profile # build, pack, and install into the `web` profile
Then start the profile and ask for a debate in plain language:
dsh --profile web
Debate whether we should replace our REST API with gRPC. Two rounds.
That dsh is the harness launcher, which this repository neither ships nor installs. Where none is on PATH, start the profile through the checkout's own source launch instead — the same one the scripts fall back to, and the one install:profile prints when it finishes:
pnpm --dir "$DSH_HARNESS" exec node --import tsx/esm apps/cli/src/bin.ts --profile web
Other commands
| Command | Effect |
|---|---|
pnpm run install:profile -- --profile headless | Install into another profile. The card is Web-only; the tools work anywhere. |
pnpm run install:profile -- --no-build | Install the current lib/ without rebuilding. |
pnpm run typecheck | Typecheck both faces against the harness sources. |
pnpm run shoot:docs | Re-shoot every image in docs/ from a real debate. Needs ffmpeg and a Chrome; it starts the profile itself, so run it after any change to the card's styles. |
pnpm run clean | Remove lib/ and the harness links. .pack/ is kept: a profile that installed this bundle records that tarball as its dependency spec. |
Running from source, without installing — inside the harness checkout:
pnpm dsh --profile web --patch <this repo>/src/debate/cordis.patch.yml
To remove it from a profile:
dsh plugin --profile web remove @shlouai/dsh-debate
dsh plugin reconciles dsh.profile.bundles from what is installed, so the layer leaves with the dependency. Without a dsh on PATH, prefix the source launch as above.
Configuration
The patch layer sets provider: spawn. Every other field takes its schema default and can be overridden from the profile's own cordis.patch.yml, which applies after this bundle's layer:
- id: debate
config:
provider: spawn
maxRounds: 8 # largest round count a debate may open with
maxSpeechLength: 2000 # characters per speech
debaterTools: # global tool names a debater may use; [] denies all
- read # the workspace: read a file,
- glob # find files by name,
- grep # search their contents
- web_search # the open web: search it,
- web_fetch # and fetch one page in full
searchTools: # of those, the ones a credential gates; [] gates none
- web_search
searchCredential: DEEPSEEK_API_KEY # the credential that enables them
| Field | Default | Meaning |
|---|---|---|
provider | (required) | Subagent backend to run debaters on. Must support persona and toolFilter; spawn, mounted by dsh-base, does. |
maxRounds | 8 | Ceiling on the round count a debate may open with. Each round costs two children. |
maxSpeechLength | 2000 | Characters per speech. Every later speech reads every earlier one, so an unbounded speech grows the next prompt quadratically. |
debaterTools | read-only set above | Allow list over the tools the judging agent itself can see. |
searchTools | [web_search] | Which of those stay withheld until the credential resolves. |
searchCredential | DEEPSEEK_API_KEY | Credential reference name — never a literal secret. |
debaterPersona | competitive-debater persona | Shadows the deployment's persona for both debaters; states the shared craft only. |
provider: spawn is what gives each debater a fresh child that never sees the judging conversation — the mechanism that makes a debater argue its assigned side rather than agree with the judge.
What a debater may do
debaterTools is applied to the child as a tools.restrict(), so a name absent from it is missing from the debater's prompt and refuses to run. The default is deliberately read-only: a debater researches its own case, and a side that could write files or run commands would be doing work on the judge's behalf while pretending to argue. debaterTools: [] restores argument alone.
Two consequences worth knowing:
- A name this deployment does not mount is skipped, not fatal.
tools.restrict()rejects an unknown tool name outright, which would fail every round rather than one call, so the list is narrowed to what the judge can actually see and the dropped names are logged once.dsh-basealone carries every default butweb_fetch(it configurestool-webwithfetch: false); thestandardagent preset, which awebprofile uses, adds it. - Each speech is one child's single turn, so research spends model calls inside that turn: a round costs two debaters, and each may search or read several times before it speaks.
Web search is conditional on a credential
web_search is mounted whether or not its backend can authenticate a query, so being available and being usable are different things for it. A debater is therefore given it only when searchCredential resolves to a value; otherwise that name is withheld and the debate runs on the tools that need no credential — read, glob, grep, and web_fetch, which retrieves a URL anonymously. A withheld tool is absent from the debater's prompt as well as its dispatch table, so a debater is never told about a search tool it cannot use, and one notice is logged per process rather than per speech.
Set the key in any layer of the credential plane, which is read in this order:
| Layer | Where | Takes effect |
|---|---|---|
| Inherited process environment | DEEPSEEK_API_KEY=… dsh --profile web | that launch |
| Provider-managed store | ~/.dsh/.credentials.yaml, under refs: (or the Web UI's Models page) | immediately — the store publishes external edits, so a key stored mid-debate reaches the next round |
Invoking directory's .env | DEEPSEEK_API_KEY=… | after restarting the profile |
Harness home's .env | ~/.dsh/.env | after restarting the profile |
The two .env layers are a boot snapshot, which is why they need the restart. Only the presence of the value is ever read here: the check asks the credential plane whether resolution would return something, so the secret does not enter this plugin, its logs, or the debate.
searchCredential is a credential-reference name, not a literal secret — never put a key in cordis.patch.yml. DEEPSEEK_API_KEY is the variable dsh-base wires its search backend to; a deployment that points web's searchProvider at Exa or Perplexity sets searchCredential: EXA_API_KEY or PERPLEXITY_API_KEY to match, and note that those two backends read only the environment layers and are absent from a stock profile's dependency closure. searchTools: [] removes the gate entirely, which is the right setting for a search backend that needs no key at all.
Repository layout
| Path | What it is |
|---|---|
src/debate/ | The orchestrator: the debate_open, debate_round, and debate_verdict tools and the presentation metadata they project onto their own results. |
src/debate-client/ | The browser half: a Conversation Node folding that metadata into one Chat card, its renderer, and its zh/en dictionaries. |
cordis.patch.yml | The profile patch layer, named by dsh.bundle.patch. It inserts the two host rows. |
src/debate/cordis.patch.yml | The same pair as .ts rows, for running from source without installing. |
tsdown.config.ts | Host build. The browser bundle has its own config at src/debate-client/tsdown.config.ts. |
tools/ | Setup, build, install, clean, and documentation-capture scripts. |
docs/ | The images the sections above embed. Every one is a capture of the live Web UI, so pnpm run shoot:docs regenerates them all rather than any being edited by hand. |
The two source directories came verbatim from the harness checkout they were written in and keep that layout so they still compile where they stand. The orchestrator is maintained here now; the browser half remains untouched.
Design notes
The decisions below are the non-obvious ones. Skip them unless you are extending the plugin or hit something surprising.
The harness shadow — why setup writes symlinks
src/debate and src/debate-client sit two levels below this repository root exactly as they sat below the harness root, and their unmodified sources reach the harness through ../../ — tsconfig.base.json, packages/, vendor/, scripts/. pnpm run setup creates those four names at this root as symlinks into the checkout, so the sources compile where they stand. The links are machine state and are gitignored; re-run setup after moving or reinstalling the checkout.
src/debate-client/node_modules holds React links for the same reason: no package manager installs that directory, so setup points it at whatever the harness workspace resolved for an in-tree client package.
The built layout — how one package ships a host row and a browser bundle
A profile bundle is imported by plain Node, which has no tsx hook, so cordis.patch.yml names built .js rows rather than the .ts sources:
lib/debate/plugin.js the orchestrator row
lib/debate-client/index.js the empty host half of the browser row
lib/debate-client/package.json copied verbatim: `dsh.client` + exports["./client"]
lib/debate-client/lib/client.js the browser bundle
There is no browser-specific row kind. dsh-client-modules scans host rows, walks each resolved module up to its nearest package.json, and serves the exports["./client"] bundle of any package declaring dsh.client. lib/debate-client/ reproduces the source layout closely enough that the manifest copies over unchanged and its ./client export still names the bundle beside it. The orchestrator row resolves to this package's own manifest, which declares no client half and is skipped.
Why installing packs a tarball — singleton classes and the dependency closure
tools/install.mjs packs before calling dsh plugin add. Installing this directory instead would link the profile back to the checkout, and Node would resolve @deepseek-ai/dsh-tools and @deepseek-ai/schemastery by walking out of this repository, where they are absent on purpose. A packed tarball becomes a real directory under the profile's node_modules, whose parent walk reaches the launcher's dependency closure at $DSH_HOME/profiles/node_modules and hands the plugin the running installation's own instances — a defineTool or schema from a second copy is a different class to every registry that receives it. They are declared as optional peers for the same reason, and autoInstallPeers is off here and in the profile so nothing installs a duplicate.
install:profile removes an existing install before adding the new tarball, which is what makes reinstalling pick a rebuild up: pnpm identifies this dependency by a path and a version that never change, so adding a repacked 0.1.0 over itself reports success while the profile keeps serving the lib/ it extracted the first time. On a first install the removal finds nothing and says so.
Where a debate is recorded — and why it is not a custom event type
A debate is durable in one place: the meta of the three tool/result events its own tool calls already write. Each tool declares an output.presentationMeta projection, which the tools plane persists verbatim beside the result, and the card is assembled from that.
This is the mechanism the built-in tools use for their own cards — read, edit, grep, web_search and the rest all project structured view data this way — and using it is what keeps a debate's session reloadable. The alternative — declaring debate/open, debate/speech, and debate/verdict as session event types — does not survive a reload on a released harness: KNOWN_SESSION_EVENT_TYPES is generated from the harness's own packages/, a downstream plugin's event type is outside it by construction, and persistence refuses to interpret a log containing an unknown type rather than silently dropping events from it. The envelope's ignorable marker exists for exactly that case but no public producer API sets it, so a plugin that logs its own event types makes every session that used it single-run.
Two consequences of reading the record from tool results:
- A projection is pure over one call's arguments and its output value. The tools plane replays it against a stored log, so it cannot consult the live debate the orchestrator holds in memory. This is why a speech's truncation flag is part of the tool's declared output rather than something the projection recomputes.
- A call nested inside a
run_codeprogram projects no metadata. The tools plane skipspresentationMetafor a non-top-level dispatch, so a debate driven from a program argues normally and shows no card. Called directly, as the tool descriptions instruct, every debate gets one.
FAQ
How much does a debate cost?
Two subagents per round, plus the judge's own turns. Each debater may also search or read several times inside its single turn. Two or three rounds is the useful range; maxRounds caps it at 8 by default.
Can the debaters change my code?
No. They are handed a read-only allow list, enforced as a tools.restrict() on the child, so anything that writes or executes is both absent from the debater's prompt and refused at dispatch.
Does a debate survive a page reload or a restarted session?
The card does — it is rebuilt from the persisted tool results. The live debate does not: debate_open mints a process-local record, so a restart mid-debate means starting a new one.
Can I use it without the Web UI? Yes. Install into any profile; the three tools work anywhere. Only the folded card is Web-only.
Can two sessions share a debate? No. A debate records the session that opened it, and any other session naming that id is refused.
Status
Experimental, and versioned 0.1.0 accordingly. The tool surface and the config schema may still change. It is published to npm as @shlouai/dsh-debate; the build itself still runs against a deepseek-harness checkout.
Contributing
Issues and pull requests are welcome — especially around debate formats (cross-examination, more than two sides, a jury of judges), additional subagent providers, and card design.
Before opening a PR:
pnpm run typecheck # both faces, against the harness sources
pnpm run build
Match the surrounding code: strict TypeScript, JSDoc on exported symbols, and comments that explain why rather than restate the line.
License
Released under the MIT License.