Back to home@shlouai

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
GitHub repo

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.

License Node TypeScript Status

English · 简体中文

A debate running in the dsh Web UI: the motion is fixed, two subagents argue each round, and the judge rules at the end


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:

ToolWhat it does
debate_openFixes the motion and how many rounds each side gets. Returns a debate_id.
debate_roundSpends exactly one round: a fresh FOR child speaks, then a fresh AGAINST child that has already read it. Returns both speeches.
debate_verdictThe 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 / en dictionaries built in.
  • Survives a reload. A debate is recorded in the meta of the tool/result events 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 dsh profile 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 debate card: the motion, then round one, with the FOR and AGAINST speeches citing files and line numbers

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

The judge's round-by-round assessment, ending in the verdict: Against wins

Requirements

  • Node ^22.19 or >=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-harness or ../../deepseek-harness, which the scripts probe in that order. An npm-installed dsh is not enough — the build reads harness sources (packages/client/web/src/platform.ts and the tsconfig.base*.json path maps).
  • That checkout must have had pnpm install run in it. pnpm run typecheck additionally needs it built (pnpm run build there), because the leaf configs resolve harness packages through their project references.
  • To install: either a dsh on PATH, or that same checkout, whose source launch the scripts use when dsh is 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

CommandEffect
pnpm run install:profile -- --profile headlessInstall into another profile. The card is Web-only; the tools work anywhere.
pnpm run install:profile -- --no-buildInstall the current lib/ without rebuilding.
pnpm run typecheckTypecheck both faces against the harness sources.
pnpm run shoot:docsRe-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 cleanRemove 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
FieldDefaultMeaning
provider(required)Subagent backend to run debaters on. Must support persona and toolFilter; spawn, mounted by dsh-base, does.
maxRounds8Ceiling on the round count a debate may open with. Each round costs two children.
maxSpeechLength2000Characters per speech. Every later speech reads every earlier one, so an unbounded speech grows the next prompt quadratically.
debaterToolsread-only set aboveAllow list over the tools the judging agent itself can see.
searchTools[web_search]Which of those stay withheld until the credential resolves.
searchCredentialDEEPSEEK_API_KEYCredential reference name — never a literal secret.
debaterPersonacompetitive-debater personaShadows 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-base alone carries every default but web_fetch (it configures tool-web with fetch: false); the standard agent preset, which a web profile 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:

LayerWhereTakes effect
Inherited process environmentDEEPSEEK_API_KEY=… dsh --profile webthat 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 .envDEEPSEEK_API_KEY=…after restarting the profile
Harness home's .env~/.dsh/.envafter 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

PathWhat 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.ymlThe profile patch layer, named by dsh.bundle.patch. It inserts the two host rows.
src/debate/cordis.patch.ymlThe same pair as .ts rows, for running from source without installing.
tsdown.config.tsHost 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_code program projects no metadata. The tools plane skips presentationMeta for 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.