Design: AgentDescent as a plugin for DeepSeek Harness, Claude Code, Codex and other agents¶
Status: design record; steps 1 to 9 of the delivery plan are implemented (
evolvespec.py,runstore.py,cli.py,mcp.py,integrations/,plugin_runner,stop_when). The user-facing guide is Use it from your agent; this page keeps the reasoning. Written againstmainafter the one-call wrappers were removed (evolve()is the only entry point) and the policy install hooks landed. Section 14 lists what changed since the first draft and why.
0. The one-paragraph version¶
Today AgentDescent is a Python library: you write a dozen lines that hand
public building blocks to evolve(), wait, then result.write_to(...). The goal
is that a person sitting inside DeepSeek Harness (DSH), Claude Code, Codex,
Gemini CLI, OpenCode, Cursor, or any future agent can say "evolve this skill
against these examples" and the host agent does it for them, with no script and
no Python knowledge.
Every one of those hosts speaks two things: MCP (tools) and the
Agent Skills standard (SKILL.md). So the design is one shared core and thin
shells:
┌──────────────────────────────────────────────┐
│ agentdescent (library, unchanged engine) │
│ evolve() + the public building blocks │
└────────────────────┬─────────────────────────┘
│ EvolveSpec (JSON) + Run store (~/.agentdescent/runs)
┌────────────────────┴─────────────────────────┐
│ agentdescent.cli agentdescent.mcp │ <- NEW, in this package
│ `agentdescent evolve …` `agentdescent mcp` │
└───┬──────────────┬──────────────┬────────────┘
│ │ │
DSH: ~/.dsh/skills + Claude Code Codex skill + Gemini / OpenCode /
cordis.patch.yml plugin config.toml Cursor (+MCP)
(mcp-client, hooks) (.claude-plugin) mcp_servers all reuse the same SKILL.md
The EvolveSpec is the new central abstraction: a declarative JSON description
of what to evolve, against what data, scored how, by which agent, under which
policies. It is an evolve() call as data, in the same spirit as
RolloutSpec in workspec.py is a rollout as data. The host agent's job shrinks
to "write a spec from the user's words, run it, report the outcome, offer to
apply". That is what makes the module able to "evolve anything": every field
of the spec is an ordinary evolve() argument or a public building block, and
the spec is the only place the two are joined.
1. Constraints that shape the design¶
These come from the code and from how the hosts behave; each one rules out an obvious alternative.
| Constraint | Consequence |
|---|---|
Upstream removed the one-call wrappers. evolve_skill, evolve_skill_dir, evolve_agent_dir, evolve_agent_code are gone; evolve() is the only entry point and the quickstarts show the full call. |
The plugin must not reintroduce them as Python. The spec is a serialisation boundary for non-Python callers, and evolvespec.py is the one place that composes the public blocks into an evolve() call. Python users never see it; the quickstarts stay the template and the spec's composition table (section 2) must match them line for line. |
| A run takes minutes to hours and costs real money. MCP tool calls time out in seconds to a few minutes. | Runs are background jobs. The MCP tool that starts a run returns a run_id immediately; separate tools poll and fetch. Never block a tool call on evolve(). |
The host agent (e.g. Claude Code, DSH) is usually also the worker the run spawns (claude -p, dsh --profile headless). |
Nested agents must work. runners._child_env already strips the environment for candidate code; the worker launcher must also drop host session markers (CLAUDECODE, CLAUDE_CODE_*, CODEX_*, DSH_*) so the child does not think it is inside a session, and must never inherit the host's MCP config, or the worker will call agentdescent recursively. |
The engine's callables are closures and cannot be pickled (see workspec.py). |
The spec uses Ref semantics: named scorers (rewards.SCORERS), named agents (claude_code, codex, dsh, openai_compatible), named policies (Beam, AdvantageAcceptance, ...), dotted paths for user code. RolloutSpec/Ref exist for exactly this; the spec reuses them rather than inventing a second scheme. |
Merge-side policies now take the verifier and thresholds through bind/configure when installed, and wrappers default their inner. |
Policies are JSON-configurable with no glue: {"ref": "AdvantageAcceptance", "strength": 1.0} resolves to an object the aggregator installs itself. Without the hooks the spec would have had to construct verifiers it cannot reach. The composition rules (Policies.require_supported, the one-pair rule, factory exclusivity) are enforced by the engine, so the spec validates by building the Policies bundle and letting the engine refuse. |
write_to overwrites the user's real skill directory. |
The plugin never auto-applies. A run leaves its result in the run store; apply is a separate, explicit tool that backs up first (write_to(backup=True)), and write_to(dry_run=True) gives the plan to show before asking. |
| The core depends on numpy and nothing else. | agentdescent.audit.ppi needs it for the calibration estimator; the rest of the package, and the CLI, use only the standard library. agentdescent.mcp imports the MCP SDK lazily and is installed via an extra: pip install "agentdescent[mcp]". |
| Every host has its own plugin manifest format, and they change often (DSH says so explicitly). | Put all logic in the package; keep per-host directories to manifests plus a shared SKILL.md. A new host is a new manifest, not new code. |
2. The EvolveSpec¶
A spec is what a host agent writes from the user's request. It must be small enough for an LLM to author correctly and complete enough to reproduce a run.
{
"version": 1,
"kind": "skill_dir", // text | skill_dir | agent_dir | agent_code | plugin
// "host": "dsh", // plugin kind only: dsh | claude_code | codex
"target": "~/.claude/skills/pdf-audit",
"name": "pdf-audit", // artifact_id; defaults to basename
"data": { // one of:
"path": "./eval/cases.jsonl", // local json / jsonl / csv
// "hf": {"dataset": "hotpotqa/hotpot_qa", "split": "validation", "config": "distractor", "limit": 40},
// "inline": [{"prompt": "...", "gold": "..."}],
"prompt": "question", "gold": "answer" // -> tasks_from(rows, prompt=, gold=)
},
"score": "contains", // SCORERS name | {"cmd": "./grade.sh"} | {"ref": "pkg.mod:fn"}
"agent": {"ref": "claude_code", "extra_args": ["--permission-mode", "acceptEdits"]},
"reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash"}, // optional; defaults to agent
"layout": "claude_skill", // runners.LAYOUTS key or literal prefix
"editable": ["**"], "frozen": ["references/policy.md"],
"max_files_per_diff": 2,
"policies": { // optional; each slot is a Ref, installed by the engine
"selection": {"ref": "Beam", "k": 4},
"task_sampler": {"ref": "DifficultyWeighted"},
"acceptance": {"ref": "AdvantageAcceptance", "strength": 1.0},
"conflict": {"ref": "AdvantageConflict", "margin": 0.5},
"staleness": "guarded"
},
"agg_config": {"base_delta": 0.5}, // thresholds are config, not policies (policy-guide §7)
"evolve": { // passes straight through to evolve()
"rounds": 6, "n_workers": 4, "asynchronous": true,
"target_reward": 0.98, "patience": 3, "held_out_frac": 0.3,
"max_seconds": 3600, "max_calls": 400
}
}
2.1 What kind composes¶
kind is the only field that is not a plain evolve() argument. It selects one
row of this table, and each row is exactly the block the corresponding
quickstart shows, so tests/test_dataset_to_skill.py and
tests/test_dir_evolution.py are the reference the composition is tested against.
kind |
strategy | run= |
propose= |
reward |
blast_radius |
extra defaults |
|---|---|---|---|---|---|---|
text |
SingleSlot(initial_value=<target file or string>) |
lambda skill, task: model(template.format(skill, prompt)) |
reflector(reflect) |
scorer(score) |
SKILL_BLAST_RADIUS |
none |
skill_dir |
FileTree(load_tree(target), editable, frozen, max_files_per_diff) |
tree_runner(agent, layout, name, overlay=frozen_files) |
tree_reflector(reflect, strategy) |
scorer(score) |
SKILL_BLAST_RADIUS |
self_verify=False, cheap_eval_tasks=4 |
agent_dir |
same | tree_runner(..., layout="claude_agent") |
same | same | HARNESS_BLAST_RADIUS |
same |
agent_code |
FileTree(..., frozen=["tests/**","conftest.py"] + frozen) |
code_runner(entrypoint, test_cmd, setup_cmd, overlay=frozen_files) |
tree_reflector(..., context_files=("**/*.py",)) |
gated_reward(scorer(score)) |
HARNESS_BLAST_RADIUS |
same |
plugin |
FileTree(..., frozen=PLUGIN_FROZEN[host] + frozen) |
plugin_runner(host, agent_args, overlay=frozen_files) (section 8) |
tree_reflector(..., context_files=PLUGIN_CONTEXT[host]) |
gated_reward(scorer(score)) |
HARNESS_BLAST_RADIUS |
same, plus a container sandbox by default |
The two defaults in the last column are the ones the removed wrappers used to
set and the quickstarts now pass explicitly: a rollout is a real agent call, so
re-running each proposal's trajectory (self_verify) doubles the cost and
ranking on the whole held-out set is the dominant expense. The spec sets them
for the three directory kinds because the person writing the spec is a model
that has not read the cost model; they remain overridable in evolve.
Governance follows from kind exactly as it does in the quickstarts, so a user
cannot accidentally evolve a harness under skill rules. agent_code additionally
requires entrypoint and defaults test_cmd to python -m pytest -q; plugin
requires host and is the subject of section 8.
2.2 Field semantics¶
scorehas three forms because users have three kinds of graders: a name fromrewards.SCORERS, an arbitrary shell command ({"cmd": ...}gets the task as JSON on stdin and the answer in$ANSWER, prints a float in[0, 1]), and a Python reference. The shell form is what lets someone evolve anything without touching Python: a linter, a compiler, a diff against a golden file, a web check are all one command.cmdgraders run through the same_child_envtrimming as candidate code.agent/reflect/policies.*areRefs resolved through the allowlist inworkspec.py. Addingagentdescent.agents,agentdescent.selection,agentdescent.sampling,agentdescent.advantage,agentdescent.fusionandagentdescent.stalenessto the default allowed prefixes is the only change the resolver needs."staleness": "guarded"is sugar forget_policy("guarded").reflective_mergeis exposed as a single ref that fills bothconflictandfusion, so the half-installed pair the policy guide warns about cannot be written.agg_configholds numbers,policiesholds rules. This is the policy guide's own line, and the spec keeps it: a threshold is anAggregatorConfigfield, a decision is a policy object.- Secrets never enter the spec. Providers read keys from the environment on
the worker side, as
SandboxSpecdoes today. The spec is written to the run store and shown to the user, so this matters. - A spec is also a file (
.agentdescent/<name>.evolve.json), so a run is reproducible from the CLI and can be checked into the repo alongside the skill it evolves. "Evolve this again with more data" is then a one-line edit.
Implementation: agentdescent/evolvespec.py beside workspec.py, with
EvolveSpec.from_dict / to_dict / validate() and
run_spec(spec, *, run_dir, on_round) -> EvolutionResult. validate() builds
the Policies bundle and resolves every Ref without running anything, so the
engine's own composition checks fire at plan time rather than round one. This
module is the only place the mapping from JSON to evolve() lives; CLI and
MCP both call it.
3. The run store and background execution¶
~/.agentdescent/runs/<run_id>/
spec.json what was asked
status.json {"state": "running|done|failed|cancelled", "round": 3, "rounds": 6,
"best_reward": 0.81, "calls": 212, "usd": 1.72, "pid": 41022, ...}
rounds.jsonl one RoundInfo per line, appended by the on_round hook
result.json EvolutionResult.save() on completion
tree/ the evolved directory, materialised (directory kinds)
ledger/ the git ledger (repo_path=), which is also what makes resume work
log.txt stderr of the run
- The run is a detached subprocess:
python -m agentdescent.cli run --run-dir <dir>. Detaching (not a thread) is what survives the MCP server being restarted by the host, which Claude Code does on/mcpreconnects and DSH does on profile reboot.status.jsonis written atomically (os.replace) after every round from theon_roundcallback. - Resume for free:
evolve(repo_path=<run_dir>/ledger)already resumes a ledger, soagentdescent resume <run_id>re-launches the same spec on the same ledger after a crash or a cancel. - Cancel sends SIGTERM to the process group;
runners._shalready usesstart_new_session, so the worker agents die with it and noclaude -pordshorphans are left behind. - Budget.
evolve()already hasmax_seconds,max_rolloutsandmax_calls, and they pass through. A dollar budget needs one small engine addition, flagged here as the only core change in the plan: astop_when(info: RoundInfo) -> boolhook next toon_round, so the runner can end the run withstop_reason="budget"whenUsage.estimated_costcrosses the line. Until it lands,planconvertsbudget_usdintomax_callsfrom its per-call estimate.
4. The CLI (agentdescent)¶
A console script ([project.scripts] agentdescent = "agentdescent.cli:main")
that exposes exactly the verbs the MCP server exposes, so a user can do by hand
anything the agent can do, and the SKILL.md can fall back to shell when a host
has no MCP.
agentdescent init <path> [--kind skill_dir] [--data cases.jsonl] # write a starter spec
agentdescent plan <spec.json> # validate + cost estimate, no run
agentdescent evolve <spec.json> [--detach] [--budget 5] # start a run
agentdescent status [<run_id>] # one run or all
agentdescent watch <run_id> # tail rounds.jsonl
agentdescent show <run_id> [--diff] # evolved tree, diff vs original
agentdescent apply <run_id> [--to <path>] [--dry-run] [--no-backup]
agentdescent cancel <run_id>
agentdescent resume <run_id>
agentdescent doctor # which agents/keys are available
agentdescent install <dsh|claude-code|codex|gemini|opencode|cursor>
agentdescent mcp # run the MCP server (stdio)
doctor matters more than it looks: the most common failure will be "the
worker agent is not on PATH" or "no API key for the reflector", and the host
agent should run this first and tell the user what is missing rather than start
a run that fails on round one.
5. The MCP server (agentdescent.mcp)¶
Stdio server, launched as agentdescent mcp. Tools mirror the CLI one-to-one;
descriptions are written for the calling model, since that is who reads them.
| Tool | Returns | Notes |
|---|---|---|
doctor() |
available agents, providers, missing keys | call first |
plan(spec) |
validated spec + cost estimate | does not run; validate() resolves refs and builds Policies, so composition errors surface here |
start(spec, budget_usd?) |
run_id |
detaches; returns in < 1 s |
status(run_id?) |
status.json (+ last 3 rounds) |
cheap; safe to poll |
show(run_id, diff=true) |
evolved tree, write_to(dry_run=True) plan, unified diff, outcomes() |
what the user reads before deciding |
apply(run_id, to?) |
files written, backup path | destructive; host must confirm with the user |
cancel(run_id) / resume(run_id) |
new status |
Tool names carry no prefix because every host namespaces them itself
(the host decides the prefix: mcp__agentdescent__plan under a dsh mcp-client
entry or a plain .mcp.json, mcp__plugin_agentdescent_agentdescent__plan when
Claude Code loads it as a plugin -- measured, see plugins.md). Two resources, for
hosts that support them: agentdescent://runs (list) and
agentdescent://runs/{id}/rounds (progress), so a host can render progress
without the model spending tokens on polling.
The cost estimate in plan is rounds × n_workers × train_tasks agent calls
for rollouts plus rounds × cheap_eval_tasks for ranking plus one held-out
sweep per committed candidate, priced from the provider's Usage rates when
known. It says which of those numbers it does not know (per-call tokens for a
tool-using agent) rather than hiding the gap.
Why plan is separate from start: it forces the "show the spec and the
price, then run" beat into the protocol itself. A skill can ask the model to
do that; a two-step tool surface makes it do that.
6. The shared skill (SKILL.md)¶
The same file ships in every host package. It is what teaches the host model when and how to use the tools. Its shape:
---
name: agentdescent
description: Evolve a skill, agent definition, prompt or small codebase against
examples using AgentDescent's parallel merge-based optimiser. Use when the user
wants to improve, tune, optimise or "train" a SKILL.md, an agent folder, a
system prompt, or code that has a test/score, and has (or can write) examples
with expected answers.
---
1. Run doctor. Report anything missing; stop if no worker agent.
2. Establish the four things a spec needs: target, data, score, agent.
- No data? Offer to draft 8-20 cases into eval/cases.jsonl and have the user
check them. Never evolve against data the user has not seen.
- No obvious score? Prefer `contains`/`exact`; offer a `cmd:` grader when
the answer is a file, code, or a format check.
- Leave `policies` empty unless the user asks for a mechanism by name; the
empty bundle is the shipped run.
3. Call plan; show the spec and the cost estimate; get a yes.
4. start; then poll status about once per round, not more. Summarise round
deltas, not raw JSON.
5. When done: show with diff=true. Explain *what changed and why* using
outcomes(). Do not paste the whole tree.
6. Ask before apply. Mention the backup path afterwards.
Guardrails: never edit the target directory yourself during a run; never raise
budget without asking; if the host is itself the worker, warn about cost
scaling (rounds × n_workers × tasks agent calls).
The skill is host-agnostic on purpose. If a host has no MCP, the same steps run
through agentdescent <verb> shell commands; the SKILL.md carries both forms.
7. Per-host packaging¶
All per-host material lives under integrations/ in this repo and is generated
from one source of truth (the SKILL.md, the tool list, the server command) by a
small script so they cannot drift.
7.1 Claude Code plugin (integrations/claude-code/)¶
.claude-plugin/plugin.json name, version, description
.claude-plugin/marketplace.json in the repo root so `/plugin marketplace add Birfy/agentdescent` works
skills/agentdescent/SKILL.md the shared skill
commands/evolve.md /agentdescent:evolve <path> — a slash-command shortcut that
invokes the skill with the argument prefilled
.mcp.json {"mcpServers": {"agentdescent": {"command": "agentdescent", "args": ["mcp"]}}}
hooks/hooks.json SessionStart: `agentdescent status --brief` so an in-progress run
is surfaced when the user comes back
agents/evolution-reviewer.md optional subagent that reads `show --diff` output and writes
the plain-language "what changed" summary
The worker inside the run is claude -p with --permission-mode acceptEdits
and --strict-mcp-config plus an empty MCP config, so the worker does not
see the agentdescent server (no recursion) or the user's other servers (no
surprise side effects).
7.2 DeepSeek Harness (integrations/dsh/)¶
DSH is the host that fits this design most naturally, because it is a plugin system: models, tools, skills, hooks, sessions and the UI are all Cordis plugins composed by a profile. Verified against the current developer preview:
| DSH mechanism | What it is | How AgentDescent uses it |
|---|---|---|
Skills (skill-filesystem) |
SKILL.md bundles, kebab-case names, discovered from <project>/.dsh/skills, <project>/.agents/skills, ~/.dsh/skills, ~/.agents/skills, plus customSkillDirs |
the shared SKILL.md is copied to ~/.dsh/skills/agentdescent/SKILL.md (or .agents/skills, which Codex also reads) |
MCP (@deepseek-ai/dsh-mcp-client) |
one plugin instance per server in cordis.patch.yml; tools appear as mcp__<serverName>__<tool> |
one entry pointing at agentdescent mcp |
Hooks (hooks-claude-code) |
consumes Claude Code's hooks.json format (SessionStart, PreToolUse, Stop, ...) |
the same hooks/hooks.json the Claude Code plugin ships, unchanged |
| Headless profile | dsh --profile headless "task" runs one session and prints the last assistant text to stdout |
the worker: cli_agent(["dsh", "--profile", "headless"]) |
Profiles + $DSH_HOME/cordis.patch.yml |
layered YAML config; dsh plugin --profile <p> add github:owner/repo#main installs a plugin via pnpm |
tier A edits the patch file; tier B is an installable plugin |
Tier A, config only (ships first). agentdescent install dsh writes:
- id: mcp-agentdescent
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: agentdescent
transport: stdio
command: agentdescent
args: ['mcp']
toolCallTimeoutMs: 120000
env:
# dsh scrubs any ambient variable matching KEY|PASSWORD|SECRET|TOKEN
# before starting an MCP server, so provider keys must be forwarded here.
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: hooks-agentdescent
name: '@deepseek-ai/dsh-hooks-claude-code'
config:
configPath: ~/.dsh/skills/agentdescent/hooks.json
The env block is not optional and is the one DSH-specific trap: without it the
MCP server starts with no provider credentials, doctor reports every reflector
as unavailable, and nothing else explains why. install dsh therefore prints the
variables it forwarded and doctor checks for the scrubbed set explicitly.
Tier B, native Cordis plugin dsh-agentdescent (later, optional). A small
TypeScript package installable with
dsh plugin --profile web add github:Birfy/dsh-agentdescent#main that does what
config alone cannot:
- registers the skill through
ctx.skills.registerProvider()and the MCP server through the samemcp-clientplugin, so oneaddinstalls everything; - a Web UI panel listing runs from
~/.agentdescent/runswith live round progress and the diff, reading theagentdescent://runsMCP resources. DSH is web-first, and a run that takes an hour deserves a progress view rather than a polling model; - an optional loop plugin: DSH's loop/scheduling plugins already run auto-research style cycles, and "re-evolve this skill nightly against the cases that failed this week" is exactly a scheduled spec. The spec file makes this a one-line job definition.
Tier B contains no optimisation logic; it calls the same CLI/MCP surface as everything else, which keeps the promise that adding a host never touches the core.
DSH as the worker. Add agents.dsh() beside claude_code() and codex():
def dsh(*, workspace=None, extra_args=(), **kwargs) -> Completion:
"""DeepSeek Harness headless profile as a Completion."""
return cli_agent(["dsh", "--profile", "headless", *extra_args],
workspace=workspace, **kwargs)
and two layouts in runners.LAYOUTS so an evolved skill is materialised where
the worker actually looks for it:
"dsh_skill": ".dsh/skills/{name}", # DeepSeek Harness project skill
"agents_skill": ".agents/skills/{name}", # Agent Skills standard; DSH and Codex both read it
A DSH-hosted run whose spec says agent: {"ref": "dsh"} therefore evolves a
skill with DSH grading DSH, no other vendor in the loop; the reflector can still
be openai_compatible(model="deepseek-v4-...") for cost. Session hygiene for the
worker drops DSH_* from the environment and points DSH_HOME at an empty
directory inside the workspace, so the worker does not load the user's profile,
plugins, or this very MCP server.
7.3 Codex (integrations/codex/)¶
Codex reads skills from ~/.codex/skills/<name>/SKILL.md (and project
.agents/skills/) and MCP servers from ~/.codex/config.toml:
Install is agentdescent install codex, which copies the skill and appends
the server block if absent. The worker is codex exec --full-auto in the
workspace. Codex has no hooks; the "surface in-progress runs" behaviour comes
from a line in AGENTS.md telling the model to run agentdescent status --brief
on start.
7.4 Other hosts¶
| Host | Skill location | MCP config | Worker command |
|---|---|---|---|
| Gemini CLI | gemini-extension.json + GEMINI.md + skills/ |
in the extension manifest | gemini -p |
| ~~OpenCode~~ | implemented and verified -- ~/.config/opencode/skill/, mcp in opencode.jsonc, opencode run; see plugins.md |
||
| Cursor | .cursor/rules/agentdescent.mdc (rule wraps the skill text) |
.cursor/mcp.json |
not a worker; use dsh/claude_code/codex/API |
agentdescent install <host> is the single entry point for all of them; each
host is ~30 lines in agentdescent/integrations/<host>.py that knows the paths
and the manifest shape. Adding a host does not touch the core.
8. Evolving the plugins themselves¶
Everything above evolves what a host runs: a skill, an agent folder, a prompt,
a codebase. A host plugin is one level up: the DSH Cordis package that adds
tools, skills and hooks to dsh; the Claude Code plugin directory with its
skills, commands, agents, hooks and MCP servers; a Codex skills-plus-config
bundle. Those are the things a team actually maintains, and the
dsh-agentdescent and Claude Code plugins from section 7 are themselves
examples. So plugin is a fifth kind, and the question it has to answer is
the same as for every other kind: what is the state, how does one rollout run,
what guards it.
8.1 What a plugin is, per host¶
| Host | The tree (FileTree keys) |
How a rollout loads the candidate plugin | Validate gate | Worker command |
|---|---|---|---|---|
| DSH | package.json, src/**/*.ts, cordis.patch.yml, skills/**/SKILL.md, hooks.json, tests/** |
HOME=<ws> already makes $DSH_HOME resolve to <ws>/.dsh; setup writes <ws>/.dsh/cordis.patch.yml referencing the plugin by path (or runs dsh plugin --profile headless add link:<ws>/plugin) |
pnpm install --offline, pnpm build, pnpm test; then dsh --profile headless --dump-config must list the plugin, or the gate fails |
dsh --profile headless "<task>" |
| Claude Code | .claude-plugin/plugin.json, skills/**, commands/**, agents/**, hooks/hooks.json, .mcp.json |
claude -p --plugin-dir <ws>/plugin; CLAUDE_CONFIG_DIR=<ws>/.claude so the user's real plugins and settings are not loaded |
claude plugin validate <ws>/plugin (structure, JSON, referenced files exist) |
claude -p --plugin-dir <ws>/plugin --strict-mcp-config --mcp-config <ws>/.claude/mcp.json "<task>" |
| Codex | .agents/skills/**, a config.toml fragment (MCP servers) |
HOME=<ws> puts the fragment at <ws>/.codex/config.toml |
TOML parses; every mcp_servers.*.command resolves on PATH |
codex exec "<task>" |
Two facts make this cheap to build. First, code_runner already does
materialise, setup, gate, run entrypoint with the task prompt, and its
_child_env sets HOME to the workspace, so the host's home directory is
the workspace for free: ~/.dsh, ~/.claude, ~/.codex all land inside the
sandbox and the user's real profile is never touched. Second, the Claude Code
--plugin-dir flag and DSH's link: install exist precisely for loading an
uninstalled plugin from a path. So plugin_runner(host, ...) is a thin
per-host table over code_runner: which setup_cmd, which test_cmd, which
entrypoint, which environment variables to forward. Nothing new in the
engine.
def plugin_runner(host: str, *, name: str, agent_args=(), env_passthrough=(),
overlay=None, fixtures=None, timeout=900.0, sandbox_pool=None):
spec = PLUGIN_HOSTS[host] # setup / validate / entrypoint / env per host
return code_runner(
[*spec.entrypoint, *agent_args], layout=f"plugin/{name}", name=name,
setup_cmd=spec.setup, test_cmd=spec.validate, overlay=overlay,
fixtures=fixtures, timeout=timeout, sandbox_pool=sandbox_pool,
env={**spec.env, **{k: os.environ[k] for k in env_passthrough if k in os.environ}})
env_passthrough is necessary because _child_env deliberately strips every
variable but a short allowlist, and a host agent needs its provider key. The
spec names the variables, never the values, exactly as SandboxSpec does; the
value is read on the worker side at launch.
8.2 What "better plugin" means: tasks and reward¶
A plugin's job is to make the host better at something, so its tasks are
end-to-end host tasks run with the plugin loaded, scored like any other
run (scorer, or a cmd grader). Three kinds of task are worth having in
every plugin's dataset:
- Capability tasks exercise what the plugin adds: "total the
amountcolumn ofdata.csv" for a CSV skill; "list open runs" for the agentdescent plugin. Gold is the answer,contains/exactscores it. - Regression probes check the plugin is present and wired: a task whose
prompt is "which tools do you have?" and whose
cmdgrader greps the answer for the host's own spelling of the tool name (see plugins.md -- a plugin adds aplugin_<name>_segment). A refactor that breaks registration fails these before it fails anything subtle. On DSH,--dump-configin the gate covers most of this for free. - Cost probes read structured output,
claude -p --output-format jsongivestotal_cost_usdand turn count, and acmdgrader turns "under N turns and under $X" into a[0, 1]score. This is how "the skill works but makes the agent take twelve turns" becomes a gradient instead of an anecdote. Reward stays scalar; a multi-objective plugin run is a task mix, andBeamorParetoFrontierselection can keep more than one head alive while it settles.
A failed gate (TEST_FAILURE_MARKER) scores 0 through gated_reward, and
the failure text is what the reflector reads, so "you broke the build" and
"the plugin no longer registers its tool" are learning signals rather than
crashed rounds.
8.3 Governance and isolation¶
A plugin is a harness: it decides what tools the host has and, through hooks,
what the host may do. So the layer is HARNESS_BLAST_RADIUS (L1, every merge
through the oracle) and four things are frozen by default, enforced twice
as in agent_code (the strategy refuses proposals that touch them and the
runner overlays pristine copies after materialisation):
PLUGIN_FROZEN = {
"dsh": ["tests/**", "pnpm-lock.yaml", "hooks.json", "**/permission*"],
"claude_code": ["hooks/**", ".claude-plugin/marketplace.json", "**/permission*"],
"codex": ["**/permission*"],
}
hooks are frozen because a hook that blocks a tool call is the plugin's own
L0: an optimizer that could loosen it to score better would be optimising the
guard away. Lockfiles are frozen because they are regenerated by tooling in
setup_cmd, not authored by a model. Both lists are extendable and, with a
deliberate frozen=[], overridable.
Isolation is stricter than for the other kinds. Candidate plugin code runs
inside the host process, with the host's tool access, so the plugin kind
defaults to the container provider (sandbox_container.ContainerProvider,
network on because the host needs its API) and doctor warns, rather than
silently downgrading, when no container engine is available. The trimmed
environment is still applied inside the container.
Cost has one new term: a per-rollout pnpm install for DSH plugins. Two
mitigations, both in the setup step and neither in the engine: a shared pnpm
store (npm_config_store_dir under workspace_root) so installs are
hardlinks after the first, and a build cache keyed by the hash of
package.json plus the lockfile, so a candidate that only touched SKILL.md
reuses the previous node_modules and dist/. SharedSandboxPool is the
place the cache lives when rollouts span processes.
8.4 The plugin evolving itself¶
The dsh-agentdescent Cordis plugin and the Claude Code plugin from section 7
are plugin-kind targets like any other, and the interesting dataset for them
is the user requests the skill is supposed to handle: "evolve
~/.claude/skills/pdf-audit against eval/cases.jsonl", with the skill
directory and cases staged as fixtures. The grader reads the run store and the
transcript, and all of its checks are mechanical:
planwas called beforestart(the tool sequence in the transcript);- the spec
planreceived validates (agentdescent plan <spec>exits 0); - the host stopped before
applyand asked (noapplycall; the last message is a question); - cost stayed under the probe's ceiling.
That is a reward for "the skill teaches the host to use the tool correctly",
which is what a plugin's SKILL.md is for, and the same loop then evolves the
tool descriptions, the commands/evolve.md shortcut and the hooks-adjacent
prose.
Two guards keep this from folding in on itself:
- A recursion guard. The worker environment carries
AGENTDESCENT_NESTED=1. The MCP server'sstartrefuses under it and returns a stub run id, so the transcript still shows that the host calledstart(which the grader wants) without a nested evolution actually running;doctorreports nested mode explicitly. - The controller never hot-swaps itself. A self-evolved plugin reaches the
user's real installation only through
apply, which is a human decision on a diff, after an L1 oracle gate, with a backup. The loop can improve its own skill text and tool descriptions; it cannot change what is running while it runs.
9. Worker adapters: what the run spawns¶
agents.claude_code() and agents.codex() exist; agents.dsh() is added in
section 7.2. Two further additions:
- Structured output.
claude -p --output-format jsonyields the answer and the exact token/cost figures; parsing it feedsUsagewith real numbers instead of wall-clock estimates. Same forcodex exec --json. Addclaude_code(structured=True)and readtotal_cost_usd; this is what makes a dollar budget honest. DSH's headless profile prints text only; itssdkprofile (JSON-RPC over stdio) is the route to token counts there, and is deferred until the preview's protocol settles. - Session hygiene. A
_worker_env()inagents.pythat dropsCLAUDECODE,CLAUDE_CODE_*,CODEX_*,OPENAI_AGENT_*,DSH_*, and pointsCLAUDE_CONFIG_DIR/CODEX_HOME/DSH_HOMEat an empty directory inside the workspace, so the worker starts clean and cannot read the user's real config, memory or MCP servers.
10. Security and cost posture¶
- Two trust boundaries, kept apart. The host session (trusted, has the
user's keys and config) starts a run. Each worker (semi-trusted, model-driven,
edits files) runs in a leased workspace with a stripped environment and its own
config dir. Candidate code (
agent_codekind) andcmdgraders run withrunners._child_envas today. Nothing here weakens what the library already does. - Applying is explicit and backed up. Same as today; the plugin just makes
it a separate verb with a
dry_runplan and a confirmation step in the skill. - Cost is visible before, during, after.
planestimates,statusreports calls and spend so far,max_calls/max_seconds(and laterstop_when) stop the run. - The spec is the audit log. Everything a run did is derivable from
spec.json+ the ledger; nothing hides in a closure. This is the same reasonworkspec.pyrefusescloudpickle.
11. Delivery plan¶
Ordered so that each step is usable on its own and testable offline (the
echo() agent, cli_agent(["python", "-c", ...]) as in
examples/skill_dir_evolution.py, and the router domain make every step
runnable without a key).
| Step | Lands in | Tests |
|---|---|---|
1. evolvespec.py: schema, validate() (refs + Policies bundle), run_spec composing the four kind rows |
agentdescent/evolvespec.py; allowlist prefixes in workspec.py |
every kind produces the same evolve() kwargs as the matching quickstart block; policies round-trips through install_policy; bad pairs are refused at validate() |
2. Run store + detached runner + status.json from on_round |
agentdescent/runstore.py, cli.py |
start with echo(), poll, cancel, resume on the same ledger |
| 3. CLI verbs | cli.py, [project.scripts] |
subprocess tests against the offline domain |
4. MCP server ([mcp] extra) mirroring the CLI |
agentdescent/mcp.py |
tool schema snapshot; plan → start → status → show with echo() |
5. Shared SKILL.md + install dsh (tier A) + integrations/claude-code plugin + marketplace.json |
integrations/, agentdescent/integrations/dsh.py |
patch YAML round-trips; skill lands in a discovery root; plugin manifest validates |
6. install codex (+ others); agents.dsh() + dsh_skill/agents_skill layouts; structured-output worker adapters |
agentdescent/integrations/, agents.py, runners.py |
parse fixtures of claude -p --output-format json / codex exec --json; tree_runner(layout="dsh_skill") materialises under .dsh/skills/ |
7. plugin kind: plugin_runner host table, PLUGIN_FROZEN, container default, AGENTDESCENT_NESTED guard, pnpm store/build cache |
runners.py, evolvespec.py, mcp.py |
offline: a stub host CLI that echoes its --plugin-dir / $DSH_HOME; frozen hooks survive a proposal that edits them; nested start returns a stub |
8. stop_when hook in evolve() for a dollar budget |
evolution.py (the one core change) |
a run ends with stop_reason="budget"; on_round semantics unchanged |
9. Docs page docs/plugins.md; README section "Use it from your agent" |
docs | test_docs_links, test_docs_examples |
10. (optional) dsh-agentdescent Cordis plugin: one-command install, Web UI runs panel, scheduled re-evolution |
separate repo | smoke test against dsh --dump-config |
Steps 1 to 4 are the product; 5 to 10 are packaging, the plugin kind, and one small engine hook.
Nothing in the aggregator, ledger, async runtime or governance changes.
12. Alternatives considered¶
- Python-only, "just import it". That is the status quo, and it is why the question is being asked: the audience inside an agent session does not want to write Python.
- Bring the one-call wrappers back as the plugin's API. Upstream removed them
deliberately: each was a second signature to keep in step with
evolve(). The spec is not a signature Python callers see; it is a wire format, and it has to exist anyway for MCP. One composer inevolvespec.py, tested against the quickstarts, is the smallest thing that satisfies both. - MCP only, no CLI. Loses reproducibility and debuggability. The detached run process needs an entry point anyway; making it the CLI costs nothing.
- A per-host plugin with its own logic. Fastest to demo, slowest to keep
alive: every host revs its manifest format yearly. Logic in the package,
manifests in
integrations/. - Blocking
evolvetool call. Fails on the first real run: hosts time tool calls out and restart servers between sessions. Background jobs are not optional. - Auto-apply on success. Tempting for a one-shot feel, but the artifact is the user's own skill directory and the reward is a proxy. Show the diff, ask.
13. DSH facts this design relies on¶
Checked on 2026-09-05 against the developer preview; DSH says breaking changes
are expected, so install dsh should verify with dsh --dump-config rather
than assume.
- Headless one-off run:
dsh --profile headless "task", final assistant text on stdout (the olddsh runsubcommand was removed).$DSH_HOMEdefaults to~/.dsh; profiles live under$DSH_HOME/profiles/<name>. - Skill discovery roots and ranks:
.dsh/skills(100),.agents/skills(200),customSkillDirs(300),~/.dsh/skills(400),~/.agents/skills(500), bundled (600). Names must match^[a-z0-9]+(?:-[a-z0-9]+)*$. - MCP: one
@deepseek-ai/dsh-mcp-cliententry per server; stdio or streamable-http; tools namedmcp__<serverName>__<tool>; ambient env matchingKEY|PASSWORD|SECRET|TOKENandDSH_*is scrubbed before the server starts. - Hooks:
hooks-claude-codeplugin reads Claude Code'shooks.json(SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,Stop; exit code 2 denies). - Plugins:
dsh plugin --profile <p> add "github:owner/repo#main"(orlink:/path), forwarded to pnpm; a plugin is a Cordis package with acordis.patch.yml; the community topic isdsh-plugin.
Sources: deepseek-ai/deepseek-harness,
its docs/config-catalog.md, docs/subsystems/skills.md, apps/cli/README.md
and packages/mcp/mcp-client/README.md;
DSH CLI user guide;
awesome-deepseek-harness.
14. What changed since the first draft¶
Two upstream commits landed between the first draft and this one, and both moved the design.
evolve() is the only entry point (the wrappers evolve_skill,
evolve_skill_dir, evolve_agent_dir, evolve_agent_code and the modules
skill.py / skilldir.py were removed; scorer / SCORERS moved to
rewards, gated_reward was added to runners, the blast-radius constants
moved to governance). The first draft said "kind selects the wrapper".
Now kind selects a row of the composition table in section 2.1, the spec
composer is the single place that wiring lives, and it is tested against the
quickstarts so it cannot drift from what a Python user would write. The two
cost defaults the wrappers used to set (self_verify=False,
cheap_eval_tasks=4) are set by the spec for directory kinds, because the spec
author is a model that has not read the cost model.
Policies install through bind / configure (install_policy, wrappers
default their inner, PolicyUnboundError, and docs/policy-guide.md). This
made a policies block in the spec possible: every shipped policy is now
constructible from JSON scalars alone and the aggregator hands it the verifier
and thresholds itself. The spec follows the guide's rule that thresholds are
agg_config and decisions are policies, exposes reflective_merge only as
the pair, and validates by building the bundle so the engine's composition
rules fire at plan time.
Smaller: the abstraction is renamed from "Recipe" to EvolveSpec to sit
beside RolloutSpec and to avoid colliding with the policy guide's own
"Recipes" table; plan is now also a CLI verb; show uses
write_to(dry_run=True); the dollar budget is stated honestly as needing one
small engine hook rather than pretending evolve() has it.
A fifth kind, plugin (section 8), added on request: the host plugins
themselves, the dsh-agentdescent and Claude Code plugins included, are
evolvable. It reuses code_runner end to end, because _child_env already
makes the workspace the host's home and Claude Code's --plugin-dir / DSH's
link: install load an uninstalled plugin from a path. What is new is a
per-host table, a frozen-by-default list with hooks in it, a container
default, and a recursion guard for the plugin that evolves itself.