Use it from your agent — DeepSeek Harness, Claude Code, Codex, OpenCode¶
Everything else in these docs is Python: you write a dozen lines and hand them
to evolve(). This page is for the other audience — a person sitting inside
an agent (DeepSeek Harness, Claude Code, Codex, OpenCode) who wants to say "evolve this
skill against these examples" and have the agent do it. It is also for the
agent: the same surface is what the agent calls.
The design record — why it is shaped this way and what was considered — is Plugin for DSH, Claude Code, Codex and other agents.
Install¶
Install 0.7.0 from PyPI:
pip install "agentdescent[mcp]>=0.7.0" # the MCP server needs this extra (and Python >= 3.10)
agentdescent demo # a complete evolution, offline, no key
agentdescent doctor # which agent CLIs, keys and optional pieces are here
agentdescent install dsh # or: claude-code, codex, opencode
Or clone the repository as described in install and first run and install the MCP extra from that checkout:
Research demos beyond agentdescent demo use the
source examples.
The MCP SDK requires Python >= 3.10; AgentDescent supports 3.9. On 3.9 the
extra installs nothing and the tool surface is unavailable — the CLI verbs are
the whole API, which is what the shipped SKILL.md falls back to. doctor and
install both say so rather than letting it surface as a host reporting
CONNECTION_CLOSED.
If you have never run one, do demo before install: it builds a skill that is
wrong on purpose, twelve cases with known answers and a local program as the
agent, then runs the real loop — same staging, ledger, workers, merge and
held-out gate, only the model replaced. Start here — the plugin in three
commands walks the whole path.
install writes the shared skill and the host's manifest, and nothing else:
| host | what it writes | how to load it |
|---|---|---|
dsh |
~/.dsh/skills/agentdescent/SKILL.md + hooks.json; an @deepseek-ai/dsh-mcp-client entry and the hooks-claude-code bridge appended to ~/.dsh/cordis.patch.yml |
restart dsh; check with dsh --profile web --dump-config \| grep agentdescent |
claude-code |
a plugin directory at ~/.agentdescent/plugins/claude-code/ (plugin.json, skill, /agentdescent:evolve, .mcp.json, a SessionStart hook) |
claude --plugin-dir ~/.agentdescent/plugins/claude-code, or /plugin marketplace add Birfy/agentdescent then /plugin install agentdescent@agentdescent |
codex |
~/.codex/skills/agentdescent/SKILL.md; an [mcp_servers.agentdescent] block appended to ~/.codex/config.toml |
restart Codex; check with codex mcp list. Or skip install entirely and use the plugin — see below |
opencode |
~/.config/opencode/skill/agentdescent/SKILL.md; an mcp.agentdescent entry merged into ~/.config/opencode/opencode.jsonc (into opencode.json beside it when your opencode.jsonc has comments; OpenCode merges the two) |
restart OpenCode; check with opencode mcp list |
agentdescent doctor also reads what install left behind, without writing
anything: one host <name>: ok | stale | partial | missing line per host (the
same data is hosts in the JSON report and the MCP doctor tool). stale
means the SKILL.md a host holds differs from the one this package would write
now -- what a pip install -U leaves behind -- and partial means the skill or
the MCP entry is absent; both are problems (exit code 1) and the fix is to re-run
agentdescent install <host>. A host CLI on PATH with nothing installed is a
hint, not a problem, because pip install wires nothing and not every host on a
machine should be touched. An OpenCode config with comments (opencode.jsonc, or an
opencode.json you keep by hand) that cannot be parsed but names agentdescent
reports its MCP entry as unknown rather than guessing; one that never names it
holds no entry of ours, so with the skill installed the host is partial.
unknown alone never makes a host partial, so a user who never installed
anything is missing. An install made through a
host's own marketplace (codex plugin add, Claude Code's /plugin install)
leaves no files here, so it reads missing and the hint names that route too.
Only presence is checked: a launcher pinned to an interpreter that has since
been deleted still reads ok.
Uninstall¶
agentdescent uninstall <host> (--dry-run to preview, --home as for
install) takes back what the table above says install wrote: the skill
files, the Claude Code plugin directory, the cordis.patch.yml block, the
[mcp_servers.agentdescent] table in config.toml, the mcp.agentdescent
entry in opencode.jsonc or opencode.json. Everything around them is left as it was, and so is
the run store in ~/.agentdescent.
What counts as "ours" differs by file:
- dsh: the block between the two marker lines is owned by the markers and
removed whole, including any edit made inside it. A block from before the end
marker existed ends at its hooks row (
configPath: .../agentdescent/hooks.json); rows you added after it stay. If that row is not found, the block is kept and reported. - Codex: the table is removed wherever it now sits, so tables Codex added
after it (
[projects."..."]) are fine, but only if it is still byte for byte whatinstallwrote (the current block with itsenv_varsline, or the older one without it). A key you added under it, or a table you wrote yourself, is kept. - OpenCode: only the pinned launcher
installwrites. The bare["agentdescent", "mcp"]entry (whatopencode mcp addwrites) is treated as yours.installadds a$schemakey only to a file it creates, anduninstalldeletes such a file once our entry was all it held; an emptymcpobject you had is left behind. A file with comments is never rewritten:installwrites the entry intoopencode.jsonbeside it instead, and if that file cannot be parsed either it prints aFAILED:line with the entry to add by hand and exits 1 (--jsonreports"ok": false).
Anything kept is printed as kept ... (...) and makes the command exit 1, so a
script can tell "fully removed" from "one step is yours"; nothing to remove
exits 0 and prints nothing to remove. An install made through the host's own
marketplace is removed with that host's plugin command, not with this one.
Codex: the same plugin as Claude Code¶
Codex reads the Claude Code plugin format, marketplace and all, so the package this repository already publishes installs there in one command — and brings the MCP server with it, so none of the file edits above are needed:
codex plugin marketplace add Birfy/agentdescent # or a local checkout path
codex plugin add agentdescent@agentdescent
codex plugin list # installed, enabled
codex mcp list # agentdescent ... enabled
Verified against codex-cli 0.153.4: codex plugin list reads
.claude-plugin/marketplace.json and resolves the plugin at
integrations/claude-code, and the server it declares shows up in
codex mcp list with nothing written into mcp_servers by hand.
Why OpenCode is configuration and not a plugin¶
OpenCode has a plugin system too, but it is a different thing: a plugin is a JS
module (@opencode-ai/plugin) whose Hooks are interception points —
tool.execute.before / .after, chat.message, chat.params,
permission.ask, event, auth, and a config hook that can mutate the
loaded config.
It could therefore inject the MCP server programmatically. It is not worth it
for what AgentDescent contributes: a skill and an MCP server are both plain
configuration in OpenCode, so the plugin would add an npm package, a publish
step and a version to keep in step, to arrive at the same two entries
install opencode writes directly. A plugin would earn its place only for
behaviour configuration cannot express — intercepting tool calls, or pushing
run status into the chat — which is not what this integration does.
DeepSeek Harness: a native plugin, or the files¶
install dsh writes files and needs no npm. The alternative is the Cordis
plugin in integrations/dsh-agentdescent:
dsh plugin --profile web add link:/path/to/dsh-agentdescent
dsh --profile web --dump-config | grep -n agentdescent # both rows compose
It registers the skill through ctx.skills.register() at runtime -- no file to
install, and it cannot drift from the package -- and its own cordis.patch.yml
adds the MCP row. Use one route or the other, not both.
The plugin also contributes a runs panel to the dsh web UI: an evolve
action in the session header showing how many runs are live, and the list
behind a click. It reads agentdescent serve, a read-only loopback view of the
run store:
The panel is a plain browser page as well, so it is useful without dsh. The
server answers a cross-origin read only for a loopback Origin, so the dsh
page on :3080 can read it and a website you happen to visit cannot; without
the server running the panel just says how to start it.
Two things a dsh plugin needs that are easy to miss, both found by installing it
for real: package.json must carry "dsh": {"bundle": {"patch":
"./cordis.patch.yml"}} or dsh plugin add warns "declares no dsh.bundle ...
not a profile layer" and the plugin is inert; and rows go under - insert:,
because a patch file otherwise overrides rows by id.
It is idempotent (--dry-run shows what it would do) and honours DSH_HOME,
CODEX_HOME and XDG_CONFIG_HOME, unless you pass --home <dir>: an explicit
--home is the host's whole world, so those variables are ignored and nothing
outside <dir> is read or written (also for uninstall). Every manifest here was verified by writing
it and having the host read it back (dsh 0.1.2-rc.1, Claude Code 2.1.261,
codex-cli 0.153.4, opencode 1.18.29).
DeepSeek Harness scrubs provider keys
Before starting an MCP server, dsh removes every ambient variable matching
KEY|PASSWORD|SECRET|TOKEN. The patch entry install dsh writes (and the
native plugin's cordis.patch.yml) forwards the same list as Codex,
explicitly (!!js process.env.X): DEEPSEEK_API_KEY, OPENAI_API_KEY,
OPENAI_BASE_URL, ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, ARK_API_KEY
and AGENTDESCENT_HOME. Without that block the server starts with no
credentials and doctor reports every reflector as unavailable, with
nothing else to explain why. A spec whose api_key_env names another
variable needs that name added to the env expression by hand (doctor
still reads the block as ok; re-running install dsh rewrites the whole
block, so add the name again afterwards). An older four-name block reads as
stale, and install dsh repairs it.
Codex scrubs the environment of MCP servers
Codex starts an MCP server with HOME LOGNAME PATH SHELL USER LANG LC_ALL TERM
TMPDIR TZ and whatever the server's env_vars names. install codex writes
an env_vars line into its [mcp_servers.agentdescent] table, and the plugin's
.mcp.json carries the same key, forwarding DEEPSEEK_API_KEY,
OPENAI_API_KEY, OPENAI_BASE_URL, ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL,
ARK_API_KEY and AGENTDESCENT_HOME. Without it the server sees no keys
(doctor reports every reflector as unavailable) and, with AGENTDESCENT_HOME
set, writes its runs somewhere agentdescent status in your shell does not
look. Re-running install codex repairs an older block that lacks the line
(doctor reads it as stale); a spec that sets api_key_env to another
variable needs that name added to env_vars by hand.
What the agent does¶
The skill file teaches the host model one procedure, and the tool surface enforces its shape:
planis separate fromstart.planresolves every reference, loads the data and builds the policy bundle without running anything, then returns the composedevolve()arguments and an upper bound on agent calls. A wrong field fails here, by name, not in round one of a background process.startreturns in under a second. The run is a detached process; the host pollsstatus. MCP tool calls time out and hosts restart their servers between sessions, so nothing blocks onevolve().applyis its own tool and says it is destructive. The evolved artifact reaches your real directory only through it, aftershowhas displayed the diff and the plan (write_to(dry_run=True)), and it backs up first.- Questions about an archive are read-only tools.
safety(safe to keep seeding from?),portfolio(layer ranking and a suggested budget split) andaudit_trace(one adoption's evidence rings) return the same JSON as the CLI'ssafety/portfolio/audit <run_id>verbs and write nothing. A missing archive is an error that names the Python meta API (continuous_meta_run; see consolidation) —evolve/ plugin runs never write one, and there is no CLI/MCP verb that does. Tell the user that; do not invent a "consolidation" step.examruns an oracle module, so it stays CLI-only.
The tool names differ by how the server was added, which matters when you allowlist them or write a probe that greps for one (verified against Claude Code 2.1 and the dsh mcp-client docs):
| added as | name |
|---|---|
a Claude Code plugin (what install claude-code writes) |
mcp__plugin_agentdescent_agentdescent__doctor — mcp__plugin_<plugin>_<server>__<tool> |
a plain MCP server entry in your own .mcp.json |
mcp__agentdescent__doctor |
a dsh mcp-client entry (what install dsh writes) |
mcp__agentdescent__doctor — mcp__<serverName>__<tool> |
| a Codex or OpenCode server entry | agentdescent's tools under that server's name |
In an interactive session you approve the tools when they are first called. In
headless use (claude -p) nothing prompts, so pass the names explicitly:
claude -p --plugin-dir <dir> --permission-mode acceptEdits \
--allowedTools mcp__plugin_agentdescent_agentdescent__doctor \
mcp__plugin_agentdescent_agentdescent__status
--allowedTools is variadic, so give the prompt on stdin (or put it before the
flag) or it will be swallowed as another tool name.
The spec¶
What the agent writes from your words, and what you can write yourself. It is
an evolve() call as data: every field is an ordinary argument or a public
building block, and agentdescent.evolvespec.compose is the only place the two
are joined.
{
"kind": "skill_dir",
"target": "~/.claude/skills/pdf-audit",
"data": {"path": "eval/cases.jsonl", "prompt": "question", "gold": "answer"},
"score": "contains",
"agent": {"ref": "claude_code", "extra_args": ["--permission-mode", "acceptEdits"]},
"reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash"},
"evolve": {"rounds": 6, "n_workers": 4, "asynchronous": true, "max_seconds": 3600}
}
kind picks a row of the composition table, which mirrors the quickstarts
line for line:
kind |
strategy | run |
propose |
reward | layer | defaults added |
|---|---|---|---|---|---|---|
text |
SingleSlot(target) |
model(template) |
reflector |
scorer |
L2 | rounds 8, patience 3, target 0.98 |
skill_dir |
FileTree(load_tree(target)) |
tree_runner |
tree_reflector |
scorer |
L2 | rounds 6, self_verify=False, cheap_eval_tasks=4 |
agent_dir |
same | tree_runner(layout="claude_agent") |
same | same | L1 | same |
agent_code |
same, tests frozen | code_runner(entrypoint, test_cmd) |
same | gated_reward(scorer) |
L1 | same |
plugin |
same, hooks frozen | plugin_runner(host) |
same | gated_reward(scorer) |
L1 | same, container advised |
The other fields:
algorithm(optional): a named method fromagentdescent algorithms list(rrsiandarchive_selectiontoday; the catalog is named algorithms). A name or{"name": ..., "params": {...}}replaces the kind's default recipe with that method's policy/knob defaults; explicitpolicies/evolvefields in the same spec still win.planechoes the resolved card, its parameters and its extra cost, and rejects an unknown name, an unknown parameter, a wrong type or a kind the method does not support before any run starts. A host can discover the same cards with thelist_algorithms/describe_algorithmMCP tools, or write the field withagentdescent init --algorithm NAME --param KEY=VALUE. Third-party packages can add a name by exposing anAlgorithmunder theagentdescent.algorithmsentry-point group; a plugin that fails to load is skipped, printed as a warning byagentdescent algorithms list, and reported byagentdescent.algorithms.external_errors()rather than taking the shipped registry down. The value loads to anAlgorithm(or a zero-argument factory returning one), and itsnamemust equal the entry-point name:
data: one of path (.json / .jsonl / .csv), hf
({"dataset", "split", "config", "limit"}, through
the data layer) or inline (a list of rows); prompt and
gold name the columns. A fixtures column is staged into the workspace.
* score: a name from SCORERS (contains, exact, last_number,
numeric_close); {"cmd": "./grade.sh"} to grade with any program (the
task as JSON on stdin, the answer in $ANSWER, a number in [0, 1] on
stdout — a linter, a compiler, a golden-file diff); or {"ref": "pkg.mod:fn"}.
* agent / reflect: a short name (claude_code, codex, dsh,
opencode, openai_compatible, claude, host_model, echo), or module:attribute inside the
import allowlist, with keyword arguments beside it. "call": false names a
callable rather than a factory. A cheap reflect behind an expensive agent
is the usual trade.
openai_compatible and anthropic_compatible have three connection
parameters, and no others: api_key_env (the name of the variable holding
the key), base_url_env (the name of a variable holding the endpoint) and
default_base_url (the endpoint itself, used when that variable is unset).
Defaults are OPENAI_API_KEY / OPENAI_BASE_URL / https://api.openai.com/v1
(ANTHROPIC_* / https://api.anthropic.com for the Anthropic twin), so a key
that is not OpenAI's needs an endpoint too, or it is sent to api.openai.com:
"reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash",
"api_key_env": "ARK_API_KEY",
"default_base_url": "https://ark.cn-beijing.volces.com/api/coding/v3"}
Every other keyword (temperature, thinking, ...) goes into the request body.
A reasoning model (Ark doubao-seed-code thinks by default) sends nothing
while it thinks, so a non-streaming call can hit the 120 s "timeout": set
"stream": true on the block, raise "timeout", or pass the provider's own
switch ("thinking": {"type": "disabled"} on Ark). A timeout is reported with
the endpoint, model and seconds, not as a bare TimeoutError.
A spec that writes base_url, api_key or a similar guess is refused by
plan, evolve and MCP start with the spelling to use; plan also warns when
a non-OpenAI api_key_env has no endpoint configured. A key is never written in
a spec.
Which model a run uses¶
A worker agent is the host CLI as a subprocess (claude -p …, codex exec …,
dsh --profile headless …, opencode run …), so the question "does it use the
model my agent is configured with" has a precise answer: not by default, and
on purpose.
agents.worker_env() inherits the environment — so provider keys in
ANTHROPIC_API_KEY / OPENAI_API_KEY / DEEPSEEK_API_KEY reach the worker —
but points each host's config directory inside the rollout workspace
(CLAUDE_CONFIG_DIR, CODEX_HOME, DSH_HOME, OPENCODE_CONFIG_DIR). The
worker therefore starts clean: it cannot read your plugins, your memory, your
MCP servers — or your model choice and your subscription login, which live in
those directories. That isolation is what makes a rollout reproducible, and it
is what keeps a plugin evolving itself from reading the copy being rewritten.
The worker's working directory and its PWD are the rollout workspace
(isolate: false included): a CLI that resolves its project from $PWD rather
than the real cwd, as OpenCode does, would otherwise run in the directory you
launched agentdescent from.
Two switches, both ordinary spec fields:
"agent": {"ref": "claude_code", "extra_args": ["--model", "haiku"]},
"reflect": {"ref": "claude_code", "extra_args": ["--model", "opus"]}
extra_args pins a model — the flag is the host's own: claude --model,
codex exec -m/--model, opencode run -m/--model (as provider/model). For
Claude Code and Codex that is all it takes, isolation intact. dsh has no
--model: its model comes from the profile, so select it with --patch or a
prepared DSH_HOME.
OpenCode: -m only reaches providers built into OpenCode
An isolated OpenCode worker cannot read ~/.config/opencode, and a custom
provider — an OpenAI-compatible endpoint such as Volcengine Ark, a local
vLLM, a company gateway — is defined in your opencode.json. -m ark/model
then fails every call with OpenCode's opaque
UnknownError: Unexpected server error (the same text as -m nosuch/model),
while plan still says ok. Two fixes:
"isolate": false— the worker uses your own OpenCode setup (see below).- Keep isolation and hand the worker a provider-only config file through
the agent's
env:
"agent": {"ref": "opencode", "extra_args": ["-m", "ark/deepseek-v4-flash"],
"env": {"OPENCODE_CONFIG": "/abs/path/providers.json"}}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"ark": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ark",
"options": {"baseURL": "https://ark.cn-beijing.volces.com/api/coding/v3",
"apiKey": "{env:ARK_API_KEY}"},
"models": {"deepseek-v4-flash": {"name": "deepseek-v4-flash"}}
}
}
}
Put only the provider block in that file, and take keys from the
environment with {env:VAR} — never inline, the file is read by a model-driven
process. It must go in env: an OPENCODE_CONFIG exported in the shell that
launches agentdescent is dropped for an isolated worker (a config file would
otherwise bring its MCP servers with it). AgentDescent does not copy providers
out of your opencode.json for you, because that file also holds inline keys,
MCP servers and plugins that isolation exists to keep out. A failed isolated
call now says this in its error, and plan warns when extra_args pins a
provider/model.
isolate: false hands the worker your real setup — your configured model,
your subscription login, your plugins. This is the literal answer to "use the
model my main agent is configured with", and the reason it is not the default:
the worker now sees state you can change between rounds, and a kind: plugin
run would load the very plugin it is rewriting.
Either switch works for reflect as well as agent, and pointing both at a
host CLI is the way to run with no provider key at all — every call goes
through the CLI's own authentication:
{
"kind": "skill_dir",
"target": "~/.claude/skills/pdf-audit",
"data": {"path": "eval/cases.jsonl", "prompt": "question", "gold": "answer"},
"score": "contains",
"agent": {"ref": "claude_code", "extra_args": ["--permission-mode", "acceptEdits"], "isolate": false},
"reflect": {"ref": "claude_code", "isolate": false}
}
Borrowing the host's model: host_model¶
The third route needs no model name and no key at all:
host_model means the model of the agent that started this run, and it takes
whichever of two routes the host allows.
Route 1 — MCP sampling. sampling/createMessage lets a server ask its
client for a completion, so the model is the one the session is running, with
the session's authentication and policy. This is the better route and it is
currently the rarer one. Measured, by logging what each host sends at
initialize:
| host | clientInfo.name |
capabilities it declares | sampling? |
|---|---|---|---|
| Claude Code 2.1.261 | claude-code |
roots, elicitation |
no |
| OpenCode 1.18.29 | opencode |
roots |
no |
DeepSeek Harness (dsh-mcp-client 0.0.1) |
dsh-mcp-client |
none | no |
| Codex | — | — | not measured: codex mcp list never opens a session |
Route 2 — the host's own CLI. Since no host measured supports sampling,
host_model falls back to running that host's CLI (claude, codex, dsh,
opencode) with the user's real configuration (isolate: false), so it uses
the model and the login they have set up. The server picks the CLI from the
name the client sent, and only if it is on PATH.
start says which route a run got:
The two are not the same thing, and the difference is worth knowing:
| sampling | the host's CLI | |
|---|---|---|
| model | the live session's | the user's configured one |
| outlives the session | no — the bridge dies with it | yes |
| cost | the host's own call | a fresh CLI process per reflection |
Sampling also works across a gap worth understanding. start returns in
milliseconds and the run proceeds in a detached process that has no MCP
session and cannot get one, so the server stands up a loopback bridge holding
the live session and hands its address to the run it launches. If that bridge
dies mid-run — the usual reason being that you closed the agent — the run falls
back to the CLI route rather than failing.
Sampling is deprecated at the protocol level
MCP revision 2026-07-28 (SEP-2577) deprecates sampling, along with
roots and logging. The shape is unchanged and the Python SDK still ships
it, so route 1 works today and warns. Route 2 does not depend on it, and
neither do extra_args and isolate.
algorithm: a named recipe from the registry,"rrsi"or{"name": "rrsi", "params": {"b_max": 3}}. It supplies defaults forpoliciesslots,agg_configandevolveknobs, so anything you set explicitly wins andplanlists what you overrode.agentdescent algorithms list [--kind K]andagentdescent algorithms show <name>(both take--json) print what is available: the kinds each applies to, its parameters and defaults, its extra cost, and a fidelity label (benchmark_faithful,mechanism_microport, orpresetfor an engine component under an algorithm's name). The registry lives in the package, so it works from the wheel; today it shipsrrsi(text; screen, proposal and acceptance policies, see RRSI) andarchive_selection(any kind; DGM-style archive parent sampling). Unknown names, unknown or mistyped parameters and a kind the algorithm does not apply to fail inplan, naming the field and the valid choices.policies: one reference per slot (selection,task_sampler,acceptance,conflict,fusion,promotion,proposal,staleness), built from JSON scalars alone because the aggregator installs them throughbind/configure(the policy guide).reflective_mergefillsconflictandfusiontogether. Thresholds go inagg_config; decisions go here.evolve: the JSON-valued knobsevolve()takes (rounds,n_workers,seed,held_out_frac,asynchronous, …), overriding the kind's defaults. A parameter that takes an object (strategy,aggregator_factory,run,propose, …) cannot be written as a string, soplanrefuses it, and an unknown key, naming the field; usepoliciesfor rules andagent/reflectfor models.allow: extra import prefixes for your own code. The package's own modules are always allowed and nothing else is; a spec is saved beside its run and shown to the user, so it never carries code or secrets.
agentdescent init <path> writes a starter spec with kind guessed from what
the path is.
Relative paths (target, data.path, a cmd grader) are resolved when the
spec is read, not when it runs: the detached run has the run directory as its
working directory and an MCP server has whatever the host launched it in, so a
relative path would otherwise mean three different files. The spec stored beside
the run holds the resolved paths and is re-runnable from anywhere.
Where a run lives¶
~/.agentdescent/runs/<run_id>/ ($AGENTDESCENT_HOME/runs to move it)
spec.json what was asked
status.json state, round, best reward, calls, dollars if priced — replaced atomically each round
rounds.jsonl one RoundInfo per line
result.json EvolutionResult.save() at the end
tree/ the evolved directory, materialised
ledger/ the git ledger; resume re-launches on it
ledger/trajectories/ one record per rollout (task, answer, reward); off with "record_rollouts": false
log.txt the detached process's output
A run outlives the tool call that started it, but it inherits the environment
of the process that launched it — including provider keys and, in sandboxed
or proxied setups, HTTPS_PROXY. That is what you want from an interactive
session, which stays open. It bites in one specific case, measured: a run
started from a headless claude -p kept working until that process exited,
then failed every call with [Errno 111] Connection refused, because the proxy
it had been handed belonged to the process that had gone. The run recorded
state: failed with that error, and agentdescent resume <run_id> from a live
shell picked it up on the same ledger and finished it. If you start runs from a
short-lived host process, expect to resume them from somewhere longer-lived.
agentdescent status, watch, show, apply, cancel and resume all read
this directory, so a run started from an agent can be inspected from a shell
and a run started from a shell can be picked up by an agent. cancel signals
the process group, so the worker CLIs die with the run; resume re-launches the
spec on the same ledger and the engine continues.
A run that learned nothing: propose_errors¶
rounds.jsonl rows (and status / show, as propose_errors=N) count the
reflect calls that raised in a round, with the first exception as
propose_error. Compare it with considered: considered: 0 and
propose_errors: 0 is a reflector that had nothing to say; considered: 0 and
propose_errors > 0 is one that was never heard — typically openai_compatible
without OPENAI_API_KEY, or a model name the endpoint does not serve. When
every proposal raises in two consecutive rounds the run stops as
state: failed with error: reflector failed on every call in rounds 1-2: <first
exception> and agentdescent evolve exits non-zero, so the remaining paid
rollouts are not spent. A reflector that fails only some of the time does not
fail the run; the counts are recorded and a RuntimeWarning names the first
error. Fix the key or model (agentdescent doctor, plan warns about the
missing key) and resume the run.
A run that learned nothing: samples¶
show (CLI show / show --json, and the MCP show tool) carries samples:
up to five of the lowest-reward rollouts of the final artifact, one per task,
each with task_id, reward, the agent's output and the task's gold (both
cut to 300 characters; the whole block stays under 4 KB). That is the answer to
"did the agent answer wrongly, or did scoring reject a right answer": an output
of 21 against a gold of 321 is the first case. The records live under
ledger/trajectories/ in the run directory, contain only what your own agent
was shown and answered, and never leave your machine. Set "record_rollouts":
false in the spec to record nothing; show then has no samples key.
A dollar budget (--budget with --usd-per-call, or budget_usd on start)
uses evolve(stop_when=): the run stops between rounds once
calls × price crosses the line and reports the spend it actually incurred.
Evolving the plugins themselves¶
A host plugin — the DSH Cordis package that adds tools and skills to dsh,
a Claude Code plugin directory, a Codex skills-plus-config bundle — is one level
up from a skill, and kind: plugin evolves it. The AgentDescent plugins this
page installs are themselves valid targets.
{"kind": "plugin", "host": "claude_code", "target": "./my-plugin",
"data": {"path": "eval/plugin-cases.jsonl"}, "score": "contains",
"agent": "echo", "reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash"},
"env_passthrough": ["ANTHROPIC_API_KEY"]}
Each rollout materialises the candidate plugin, loads it into an isolated copy
of the host that lives inside the workspace (HOME is the workspace, so
~/.dsh, ~/.claude and ~/.codex are sandboxed for free), runs the host's
validate gate, then runs the host CLI on the task:
| host | loads the candidate with | gate | worker |
|---|---|---|---|
dsh |
dsh plugin --profile headless add link:<plugin> after pnpm install and pnpm run --if-present build |
pnpm run --if-present test; dsh --profile headless --dump-config composes |
dsh --profile headless "<task>" |
dsh: build and test are optional, and the entrypoint needs a provider
A dsh plugin is often plain ESM with nothing to compile — the one this page
installs is — so both scripts run with --if-present. A package that
declares them still runs them and still fails the gate when they fail; for
one that declares neither, --dump-config is the gate, and it is a real
check that the plugin loads.
The worker's dsh is a fresh profile inside the workspace with none of your
credentials, so the entrypoint stops at MISSING_CREDENTIAL unless you give
it one. Two spec fields do that: entrypoint is appended to the host's
command, and env_passthrough names the variables to carry through.
{"kind": "plugin", "host": "dsh", "target": "./my-dsh-plugin",
"entrypoint": ["--patch", "/abs/path/to/provider.patch.yml"],
"env_passthrough": ["DEEPSEEK_API_KEY"]}
A patch overriding llm-deepseek by id (a bare row, not - insert:) points
it at any OpenAI-compatible endpoint:
| claude_code | claude -p --plugin-dir <plugin> --strict-mcp-config | claude plugin validate <plugin> | the same command |
| codex | skills copied to .agents/skills/, config.toml to .codex/ | the TOML parses | codex exec --sandbox workspace-write --skip-git-repo-check "<task>" |
| opencode | skills copied to .opencode/skills/, opencode.jsonc to .config/opencode/ | the JSON parses | opencode run "<task>" |
A failing gate scores 0 in-band and its text is what the reflector reads, so
"the plugin no longer registers its tool" is a learning signal, not a crashed
round. agent is ignored for this kind — the host is the agent — but must be
present; env_passthrough names the variables the host needs (its provider
key) and never carries values.
Three things are stricter than for the other kinds:
- Governance. A plugin is a harness (
HARNESS_BLAST_RADIUS, L1: every merge through the oracle), and hooks, permission config, lockfiles and tests are frozen by default (PLUGIN_FROZEN), enforced twice — the strategy refuses proposals that touch them and the runner overlays pristine copies after materialisation. A hook that blocks a tool call is the plugin's own L0; an optimiser that could loosen it would be optimising the guard away. - Isolation. Candidate plugin code runs inside the host process with the
host's tool access. Use a container sandbox
(sandboxes) for anything you would not run by hand;
plansays so in its notes. - Recursion. Every worker runs with
AGENTDESCENT_NESTED=1. When the plugin being evolved is the one that hosts this MCP server, the worker'sstartreturns a stub — the transcript still shows the host called it, which is what a grader looks for, and no nested run starts. A self-evolved plugin reaches your real installation only throughapply, a human decision on a diff after the L1 oracle, with a backup: the loop can improve its own skill text and tool descriptions, and it cannot change what is running while it runs.
What "a better plugin" means is up to the dataset. Three task shapes are worth
having: capability tasks (what the plugin adds — gold is the answer),
regression probes ("which tools do you have?" with a cmd grader that
greps for the tool name) and cost probes (claude -p --output-format json
gives turn count and total_cost_usd; a cmd grader folds "under N turns and
under $X" into a score). Reward stays scalar; a multi-objective plugin run is a
task mix.
From Python¶
Nothing here is a second entry point. EvolveSpec is a wire format for callers
that are not Python; compose() produces the same evolve() call the
quickstarts show and is tested against them, so if you are writing Python,
write the call:
from agentdescent import EvolveSpec, compose, load_spec
comp = compose(load_spec(".agentdescent/pdf-audit.evolve.json"))
comp.kwargs["rounds"] # what the spec composed to
result = comp.run() # == evolve(comp.tasks, comp.reward, **comp.kwargs)
Next¶
- Testing the plugins — what to run for each host, and what no test can tell you
- Quickstart — evolve a directory — the Python call
the
skill_dirkind composes - Using the policy slots — what the
policiesblock can name - Sandboxes — the container provider the
pluginkind wants - Design record — why it is built this way
Fixing input-file errors¶
agentdescent plan <spec.json> reports input errors and exits with status 2.
Spec-file read errors appear on stderr; dataset validation errors appear in the
plan response (ok: false). For malformed spec or dataset JSON, the diagnostic names the file,
line and column to edit. JSONL line numbers count physical lines, including
blank lines. Files must be readable UTF-8; a spec must be a JSON object. Fix
the named input, then rerun plan before starting an evolution.