8000 Add DeepSeek Harness (dsh) as a CLI Base Agent (acting agent and dsh Evolver) by iamziyuzhao · Pull Request #90 · Infini-AI-Lab/hermit · GitHub
[go: up one dir, main page]

Skip to content

Add DeepSeek Harness (dsh) as a CLI Base Agent (acting agent and dsh Evolver) - #90

Merged
haizhongzheng merged 22 commits into
mainfrom
feat/dsh
Nov 11, 2026
Merged

haizhongzheng merged 22 commits into
mainfrom
feat/dsh

Conversation

@iamziyuzhao
@iamziyuzhao iamziyuzhao commented Nov 9, 2026 •
Copy link
Copy Markdown
Collaborator

Change

Adds DeepSeek Harness (dsh; npm @deepseek-ai/dsh 0.2.2-rc.1,
deepseek-ai/deepseek-harness) as a sixth CLI Base Agent beside
codex, claude, pi, mini-swe-agent and opencode. It is one CliAgent subclass that reports its runs through the runner's
outcome() (#77). It can act (agent.base: dsh) and learn (the new dsh Evolver). It follows #89 (Pi).

On main at 1b0d897: main is merged into the branch (0ddba64, main at 6e421a4, with conflicts in 11
files where #49, mini-swe-agent, and this PR both add a CLI; 77f3d75, at f469f59, without a conflict; 1f25839, at
1b0d897, with conflicts in 36 files where #94, OpenCode, and this PR both add a CLI; every conflict keeps both sides).
The first version of this PR was stacked on #89's earlier head; the branch was rebuilt on main (d5f0a64), so its
diff is only dsh: 75 files, +2675 −201 against 1b0d897, the documentation included. Where main and this PR both list
the CLIs (bases, hosts, readers, settings sections), dsh comes last, after mini-swe-agent and OpenCode. The head,
947f146, brings #97's handling of stale skills to hermit deploy --host dsh.

  • Layer 1: a <backend>/<model> reference becomes a route of dsh's pi-ai adapter (Binding.dsh_provider): the
    backend's URL in the first dialect it serves (as for Pi: chat completions, then Messages, then Responses), the key
    by its variable's name (apiKeyEnv, never the value) and the context window. dsh reports no cost, so the backend's
    price is applied to the usage it reports.
  • Layer 2: Dsh(CliAgent) runs dsh --profile headless --json in a private home (DSH_HOME, HOME,
    DSH_AGENTS_HOME), with telemetry off and the caller's own DSH_*, DEEPSEEK_BASE_URL and
    DEEPSEEK_SEARCH_BASE_URL dropped (without the sandbox they moved dsh's own provider and its web search), whatever
    a sandbox lets through: hermit's own DEEPSEEK_BASE_URL is its deepseek backend's chat completions root, which
    dsh reads as its own provider's Messages root. The session's env gives dsh one of its own.
    • Settings: the session's settings are a patch layer of dsh's own rows (--patch, written in the home):
      • the bound model as the default model (effort as its reasoningEffort; image_input: true declares a model
        that takes images);
      • the call's tools as an MCP server, offered to the model as mcp__<server>__<tool>;
      • web search and fetch off unless web_search: true. dsh's search is DeepSeek's own service (its Messages API
        on DEEPSEEK_API_KEY), so unless the model is reached there on that key the session gets web fetch only, as
        Codex's hosted search is off for a backend other than OpenAI's;
      • the session's transcripts in plain JSON lines (dsh compresses them with zstd), so that hermit.redact scrubs a
        key a session wrote from its home, as from every other record;
      • off in every session: the title request, telemetry (also DSH_TELEMETRY_DISABLED=1), the session log and
        plugin list that dsh adds to requests to DeepSeek's own API, and sub-agents and workflows (their tokens are in
        no usage figure dsh reports).
    • The workspace is the session's project, wherever it lies. Two rows of the layer make it so: skills come only
      from the workspace's .dsh/skills (skill-filesystem), and instructions only from the working directory, the
      workspace (agent-instructions with no root marker). Without them, a workspace inside a git repository also got
      that repository's skills and AGENTS.md, which outranked the adapter's. The dsh Evolver's sessions take no
      instruction file at all (instruction_files: false): dsh adds a folder's AGENTS.md when its file tools touch
      the folder, and an adapter's AGENTS.md is the acting agent's policy, which Codex's learner is never given.
    • Task data: a task's data inside the workspace is bound there by bubblewrap, a real directory, as for Claude
      Code (binds_mounts): dsh's glob and grep run ripgrep without --follow, so a link was never searched.
    • Keys: a backend that needs no key gets the placeholder key local through a variable of the session
      (HERMIT_DSH_NO_KEY), because dsh refuses every request of a route that names no key.
    • Sandbox: commands run in dsh's own sandbox (agent.dsh.permission_mode, default workspace-write).
      writable paths are granted by their real paths, as dsh requires. Approvals are never asked
      (--approval-policy never). A permission mode or an effort level dsh does not have is refused before any
      session: the acting agent's as the run config loads, the dsh Evolver's by hermit check and before train's
      first step, from its params as the Trainer has them (the method's own and the run's model in). dsh's own provider
      takes fewer levels (off, low, high, max) than a bound model's route. YAML's bare off, a boolean, is
      off.
    • Resume by --session-id when the home keeps the session (dsh resumes only in the directory the session ran
      in, which an AgentEvolver's calls share).
    • Instructions: dsh has no system prompt for one run, and its persona text fails a step on any {{...}} other
      than {{provider}} and {{model}}, so system instructions go at the top of the prompt as a marked block.
    • Refused: roles, a login, and container isolation.
    • outcome():
      • A run without a completed final event has failed, whatever dsh's exit code (dsh exits 0 when SIGTERM stops
        it).
      • So has a run whose tools did not start: dsh only warns on stderr and runs on without them.
      • A run that wrote no session event (a usage error, or a working directory's .env that sets a name dsh
        keeps, such as PATH) started no session (started: false), so each task's error record says that dsh exited
        before starting a session, not that its session failed (the run goes on).
      • A provider that could not serve the run (unavailable: the acting agent runs the task again) is told only by
        the final event's error: one of dsh's codes for it (RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT,
        EMPTY_RESPONSE, STREAM_CLOSED: those dsh retries itself, and a stream the provider closed), or the code
        dsh's pi-ai adapter gives an error it could not classify (PI_AI_ERROR) with an outage's words (UNAVAILABLE,
        which Pi's notices are matched with: an overload a provider sends inside a 200 stream, such as the Messages API's
        overloaded_error, has no status). Not by the stream's other lines: dsh starts a deployed tool server on its own
        stderr, and a tool's words of an outage are the session's work (a JSON line of type final that a tool writes is
        read as that event: see Known issues).
    • Usage is read from status step_end events (records.parse_dsh), each the sum of the step's attempts. A
      step that produced something but whose tokens dsh did not report (the provider reported no usage, or an attempt
      reported none and dsh left the step's usage out) is an unreported call, and the session's cost is then unknown
      rather than the price of the tokens reported. A step dsh retried after a refused request loses its cache counts
      (dsh keeps a count only when every attempt reported one), which its totalTokens still holds: those tokens are
      counted as input and priced as hermit prices a cache write, never less than the step's price with the counts
      (for a price whose cached input is no dearer than its input), so a USD budget still counts the session. On the
      Responses API the total is the server's own total_tokens, taken as given.
    • Context window: a run that ends CONTEXT_WINDOW_EXCEEDED with a backend that declares no context_window
      says to declare it. dsh sizes a model it does not know as 1,000,000 tokens with 128,000 output tokens a request,
      which a vLLM server refuses; with the window declared, dsh sizes each request by it.
    • Trajectory from the stream (trajectory.dsh_steps). A command's exit code is read from dsh's own end
      markers, anchored at the end as dsh reads them. It is unknown for a command that reached its timeout (in a
      session dsh moves it to a background job, or stops it when it cannot; the timeout printed is the one dsh applied:
      the model's, 1000.5 too, else 60 s, at most 600 s), was stopped, was killed by a signal or runs in the
      background, and for a result dsh's --json cut at 8 KiB (the marker is in the cut part; the step says it was
      cut). Every unfinished end (error, aborted, max-tokens, blocked, ...) is an error step.
  • Layer 3:
    • hermit/hosts/dsh.py (hermit deploy --host dsh) writes AGENTS.md, .dsh/skills, and a patch layer,
      .dsh/hermit.patch.yml, because dsh has no project file for MCP servers or skill folders. The layer holds the
      adapter's tool server (an insert row, 120 s per call) and names .dsh/skills. A server dsh cannot start is not
      retried in the background (failOnStartupError), but dsh only reports it on stderr
      (dsh: warning: 1 entry did not activate) and runs the session on without the adapter's tools, exiting 0: the
      dsh acting agent reads that warning and fails the session, but a session started by hand does not stop, unlike
      Codex's required. A context past what dsh gives the model of a project's instructions (65,536 bytes, its
      304-byte frame around AGENTS.md included) is refused. The acting agent passes the layer with --patch. As
      every host does since hermit deploy retires skills the adapter no longer exports #97 (947f146), a redeployment refuses, before writing anything, to replace a skill folder no
      deployment wrote or to write through a linked .dsh/skills, and moves a skill the adapter no longer exports to
      .retired-skills/.
    • AgentEvolver, the loader and the config accept dsh. methods/dsh/ is the new Evolver. It turns dsh's own
      sandbox off inside the evolver sandbox (permission_mode: danger-full-access), as Codex's is, and copies no
      login (home: []).
    • The AHE template runs its hooks by hand on dsh, as on Pi (hook_hosts.py gains a dsh wire).
  • Login: a model reference. Without one, dsh uses its own provider (deepseek-official), and the user's
    ~/.dsh/.env (or $DSH_HOME/.env) is copied into the session's home, as Codex's auth.json is. This path was not
    run (see Validation).
  • Core changes (shared code):
    • The runner (CliAgent.run): outcome() may now say started. When it does not, started is as before (the CLI
      wrote a JSON line).
    • records.parse_log reads a dsh stream with dsh's reader (it read one as Claude Code's).
    • Usage gains unreported_calls, the calls whose tokens a CLI did not report, and records.price_usage leaves
      such a usage's cost unknown (the budget counts it as such). Only dsh's reader sets it: Codex, Claude Code, Pi,
      mini-swe-agent and OpenCode are priced as on main.
    • The AHE Evolver's texts name a dsh acting agent's stream (agent-dsh.jsonl) instead of agent-codex.jsonl
      (as Name a Pi or mini-swe-agent acting agent's event stream in the AHE Evolver's texts #91 and Add OpenCode as a CLI Base Agent (acting agent and opencode Evolver) #94 name Pi's, mini-swe-agent's and OpenCode's).
    • The mwm and RRSI Evolvers refuse dsh at setup: mwm would run it on dsh's own provider instead of its bound
      model (its session settings carry no provider), and RRSI's read-only roles would run with all of dsh's tools
      (their settings are Codex's or Claude Code's). pi has the same problem on main: Run every method on Pi (evolver.base: pi) #92, an open
      draft, sets mwm and RRSI up for Pi and refuses every backend but codex, claude and pi, which covers dsh too.
    • Name a Pi or mini-swe-agent acting agent's event stream in the AHE Evolver's texts #91 and Run every method on Pi (evolver.base: pi) #92, split from this PR, change lines it changes. Name a Pi or mini-swe-agent acting agent's event stream in the AHE Evolver's texts #91 (the AHE texts) merged after f469f59; its
      lines came in with 1f25839. Run every method on Pi (evolver.base: pi) #92 (an open draft, now Pi on every method: at 03ed729, 54 files, 17 of
      them changed here too): whichever of it and this PR merges second resolves the conflicts by keeping both sides;
      Run every method on Pi (evolver.base: pi) #92's check replaces this PR's dsh one, but its message names Pi (codex, claude or pi, not 'dsh'), so this PR's
      two tests of that message (in test_mwm_evolver.py and test_rrsi_adapter.py) change with it.
    • login.evolver_problems, which train runs before its first step, also checks a dsh Evolver's settings
      (cli.dsh_problems), and hermit check now reports it for an Evolver without a login too (it did only with one).
    • hermit/isolate.py: only its config check (validate). dsh refuses a login, as Pi does, and the acting agent's
      dsh settings are checked there, as Claude Code's effort is on a login. No sandbox mechanics change (.dsh is a
      one-level folder). Pending: the core team's agreement, which the sandbox checklist asks for any isolate.py
      change.
  • Docs (f0d2091, a7883d8, d66105a; no code): dsh joins the pages Add Pi as a CLI Base Agent (acting agent and pi Evolver) #89 and mini-swe-agent: a Base Agent, an acting agent and a deployment host #49 changed for Pi and mini-swe-agent
    (agent-layers.md, model-backends.md, config-fields.md, task.md, evolver.md, methods/agent.md,
    custom-evolver.md, compatibility.md, installation.md, CHANGELOG, commands.md, adapter.md), methods/ahe_adapter.md
    for the AHE hooks, guides/sandbox.md and developer/sandbox-design.md, and the pages that list the hosts, the CLIs or the
    CLIs integration tests run: faq.md, what-is-a-harness-adapter.md, configuration.md, service-design.md,
    docs/web/index.html, tests/README.md, CONTRIBUTING.md, and the AHE template's README.md and .hermit/EVOLVER.md
    (which the AHE learner reads). What the pages say of dsh's own: its inner sandbox (permission_mode, and what it does to
    environment.write and /tmp), its web search and fetch, its patch layer and how a session loads it (with a warning
    that a session started by hand can edit it), a tool server that does not start, the limit on a project's instructions,
    what its usage leaves out, and a provider of one's own. README-pypi.md is unchanged, as mini-swe-agent: a Base Agent, an acting agent and a deployment host #49 and Add Pi as a CLI Base Agent (acting agent and pi Evolver) #89 left it, and so are
    convert's lists: dsh has no plugin format.
  • Not included: hermit convert --target dsh (an adapter as a dsh plugin package).

Validation

Run in an Ubuntu 24.04 container (arm64), as root with --privileged because the VM's AppArmor setting refuses
unprivileged user namespaces. Python 3.12, bubblewrap, Node 24.22.2, and dsh 0.2.2-rc.1 from npm (its pi-ai dependency
resolved to 1.5.0), with the rrsi group and the mini-swe-agent extra installed as CI installs them (not the gepa,
skillopt and harness2 groups). The toy runs ran on 207ea88, and the scripted one again on 1e75c6f, on a7883d8 and on
the head, 947f146.
After
207ea88, 1bbd57c adds one test (and a docstring's line breaks); f4792e0 a recorded stream (promoted.jsonl) and,
in an existing test, its case and two [stopped: …] results written by hand as dsh's bash tool renders them; d25958a
widens the two timeout markers' patterns (round 8) and records promoted.jsonl and bash.jsonl again with the calls
its tests read; a43698d corrects wording only (round 9: comments, a docstring, the fixture README); cacb4ae fixes
the post-PR audit's findings; 0ddba64 merges main (6e421a4); ace8fab fixes round 10's medium finding in the code
(an overload dsh leaves unclassified); 1e75c6f adds a test and a case of an existing one (round 11's two guards) and
corrects wording (docstrings, test comments, the deploy test's name); 77f3d75 merges main (f469f59); f0d2091, a7883d8 and
d66105a add the documentation and change no code (d66105a changes two of its sentences); 1f25839 merges main
(1b0d897), and the head, 947f146, has hermit deploy --host dsh retire stale skills (#97). The full suite ran on
6e421a4, on 1e75c6f, on 1e75c6f merged with f469f59 (aabd6e8, a merge made for that check, with the tree
77f3d75 has), on a7883d8 and on the head, each split over six containers at once (one pytest, serially, in each); each run's
folder (the author's own records, not published) names the commit it copied.

Check Result
python -m pytest --integration, main (6e421a4) 3 failed, 1261 passed, 79 skipped
The same, this branch (1e75c6f) 3 failed (the same three), 1310 passed, 79 skipped
The same, a7883d8 (main at f469f59 merged, the documentation added) 3 failed (the same three), 1343 passed, 80 skipped (the one more than above is #93's test that needs an NVIDIA GPU); all 24 undo checks held (each fix undone, its test fails); the developer's environment as below but for that test's skip (356 passed, 9 skipped); the scripted toy run and the sandboxed tool server as below
The same, the head (947f146: main at 1b0d897 merged, dsh retiring stale skills) 3 failed (the same three), 1369 passed, 85 skipped (#94's tests that need the opencode CLI, not in this image, among them); all 24 undo checks held; the developer's environment as below (364 passed, 10 skipped: one more, OpenCode's case of the installed-CLI test); the scripted toy run and the sandboxed tool server as below
The documentation site (bash docs/build.sh: Sphinx, warnings are errors), on a7883d8 and on the head Builds clean, as it does without this PR's documentation (main at 1b0d897). In the site's theme (looked at on a7883d8) adapter.md's seven-column table (eight columns on the head, with OpenCode's) scrolls inside its wrapper, as the six-column one does, and the warning box in commands.md renders
The documentation followed as a new user, in this container, scripted model, on f0d2091 (round 13; a7883d8 and d66105a change none of the texts this row names) The installation page's list; a run config for a dsh acting agent and for the dsh Evolver, which hermit check accepts, every setting in task.md's 8000 row read; hermit deploy --host dsh matching adapter.md's table and tree, and the documented dsh --profile headless --patch ... started from the workspace offering and answering the adapter's tool
tests/core/test_dsh_agent.py (43 tests) and test_deploy.py::test_dsh* All pass, including the 11 that run the installed dsh
This PR's test files (the RRSI one with its group) in a developer's own environment: a user backends file and DEEPSEEK_BASE_URL, DS_BASE_URL that move deepseek, DeepSeek keys, DSH_HOME with a key, DSH_* All pass but the root-only test_isolate one (8 skip, as in the plain run: 7 for a CLI not installed here, 1 for no system site-packages directory); a43698d's web-search test fails there
The acting agent in the task sandbox (sandbox.enforce: required) with an adapter tool (docs/_examples/file_adapter), scripted model, on 1e75c6f dsh started the deployed tool server inside the task sandbox; the adapter's code ran in hermit's nested bubblewrap (HA_ADAPTER_SANDBOX); the tool and the session answered
hermit train + hermit eval on the docs' toy task, sandbox enforced, dsh acting and learning, scripted model, on 207ea88 and 1e75c6f hermit check ok; train 0.0 → 1.0; eval: final adapter 1.0, none 0.0; no call of unknown cost
The same with DeepSeek V4.1 Flash on Fireworks, reached through the sandbox's allowlist proxy train 0.0 → 1.0 in 2 steps; eval: final adapter 1.0, none 0.0; $0.058 in all as hermit prices it (training, both evals and the check's probe task), no call of unknown cost; the learner's sandbox allowed only api.fireworks.ai, and its probe found the network and the run's data closed; the key in no file the run wrote
  • Failures: the same three on all five trees (par-main-6e421a4-6sh, par-1e75c6f, par-merge-f469f59,
    par-docs-a7883d8, par-merge-947f146). Two fail
    when run as root: tests/core/test_isolate.py::test_a_rollout_sees_what_is_declared_and_nothing_else and
    tests/methods/ahe/test_ahe_hook_runner.py::test_logs_that_cannot_be_written_lose_the_line_not_the_call. The third,
    tests/methods/ahe/test_ahe_evolver.py::test_the_evolve_prompt_is_start_md_and_the_query, depends on the order: it
    makes a temporary directory in ~/.cache/hermit-tests without creating that folder, which other tests create, and in
    all four runs it ran before them (in pytest's own order, and in some other splits, it runs after them and passes).
  • Fixtures (tests/fixtures/dsh/) are dsh's output unchanged, recorded as their README says.
  • dsh's behaviour was checked against its source at the tag dsh-v0.2.2-rc.1 and with the installed dsh.
  • Not verified:
    • macOS. dsh's sandbox there is Seatbelt, which cannot nest inside another Seatbelt sandbox.
    • dsh's own bubblewrap sandbox nested in hermit's as an unprivileged user. dsh then tries Landlock; if neither
      starts, every command fails with SANDBOX_UNAVAILABLE, visibly.
    • dsh's own provider with ~/.dsh/.env.
    • A backend that serves only the Messages or the Responses API (only overloaded.jsonl was recorded with a
      Messages one: a run that ended at its first request).
    • hermit train with a real model since 207ea88 (the Fireworks run): the merge of main was checked with the
      scripted model.
Known dsh behaviours (not changed here)
  • Usage dsh does not report:
    • a compaction's summary request (dsh keeps its usage in the session's transcript, not in its stream), and web
      searches (with web_search: true on DeepSeek's API, each search is a request of its own on the same key): the
      session's cost still reads as known without them, though a search can cost more than the steps around it.
      Counting each search as a call of unknown tokens instead would leave the session's whole cost out of a USD budget;
    • the tokens of a step one of whose attempts reported none (dsh then leaves the step's usage out; it is counted
      as a call of unknown tokens);
    • what a provider billed for an attempt cut off mid-stream that reported zeros;
    • the last step of a session a stop (a timeout, a cancelled call) cut short before its end: dsh writes a step's
      usage at its end, after its tool calls ran, and a stopped session writes nothing more. Its cost is then that of
      its earlier steps; a session stopped in its first step reports no usage, so the acting agent's record has an
      unknown cost.
  • A task run again after an outage (unavailable) keeps only its last attempt's usage, for every CLI: the acting
    agent's retry loop on main reports the last result's usage, and none when every attempt ends in an outage (the
    task's error record has no cost and no tokens: a USD budget adds nothing for it and notes one call of unknown cost, a
    tokens budget none of the attempts' tokens). dsh's outage ends reach that loop, and a dsh session can end with one
    after billed steps. A separate change (it is shared code).
  • dsh tells an error's kind by its words: dsh classifies pi-ai's errors by words and numbers anywhere in their text,
    so a provider's message that names a network, a connection, a timeout or a rate limit (a refusal's explanation, say)
    is retried by dsh and then taken for an outage, run again and left at an unknown cost; no real refusal text with such
    a word was found. A chat-completions reply whose only output is the refusal field and that ends stop has no
    content for dsh, because pi-ai does not read that field: it ends EMPTY_RESPONSE whatever the refusal says, so dsh
    retries it and hermit takes it for an outage. No provider was shown to send such a reply to dsh's requests, which ask
    for no structured output. For an error dsh leaves unclassified (PI_AI_ERROR) the outage's words are matched as Pi's
    notices are: a server error inside a 200 stream that names no overload ends the task as failed, as Pi's does, and a
    malformed event whose quoted text holds an outage's words is taken for one.
  • A run started on an earlier commit of this branch wrote compressed transcripts, and dsh refuses a home that
    holds both kinds: such a run cannot be resumed on this head.
  • An Evolver with no model reference (dsh's, as Pi's on main) fails each step: dsh's own provider is not
    reached from the evolver sandbox.
  • .env files: dsh also reads a .env in the working directory. One that sets a reserved name (PATH, HOME,
    a proxy, DSH_*) stops dsh before it writes any event; the acting agent reports that the session did not start.
  • The deployed patch layer (hermit deploy --host dsh) is a file of the workspace, .dsh/hermit.patch.yml, and
    dsh's sandbox keeps no folder of the workspace read-only (Codex keeps .codex read-only under workspace-write): a
    session started by hand can edit it, and a stdio row it adds there runs outside dsh's sandbox in the next session
    started with --patch (reproduced with the installed dsh). Start such a session from a fresh deployment (a new
    folder), or read the layer first. hermit's own sessions are not affected: only the acting agent loads this layer, and
    it deploys the adapter anew into an emptied workspace before each attempt.
  • Context window: see Change. With a backend that declares none, a vLLM server refuses every request; Fireworks
    caps the output tokens instead, and compaction starts late on a small-context model.
  • Nested instructions: dsh adds a folder's AGENTS.md when its file tools touch the folder. The acting agent's
    adapter copy (hermit/<id>/) holds one, identical to the context in the shipped templates.
  • dsh's own sandbox: with neither bubblewrap nor Landlock, dsh refuses each command (SANDBOX_UNAVAILABLE) and
    the session still answers. With a task's data bound, SIGTERM ends the binding's bubblewrap, and dsh with it.
  • Resume works only in the directory the session ran in.
  • /tmp under workspace-write: dsh's mode lets a session write /tmp as well as the workspace, as dsh documents
    it. Under bubblewrap its commands get an empty /tmp of their own; under Landlock they write the real one, and so do
    its file tools (write, edit, ...) under either. A run whose directory lies under /tmp (agent.params.logs, by
    default .logs beside the config, as in this PR's toy runs) therefore lets the session write its home, its log
    directory and the call's directory, as a Claude Code or Pi session can. Under bubblewrap its commands also do not see
    a declared path under /tmp outside the workspace (a mount without at, an environment.read path) or the session's
    home, which dsh's read, glob and grep do see. Keep a dsh run's directory and its data outside /tmp, or mount the
    data inside the workspace (at:).
Known issues from review rounds 7 and 9-14 and the post-PR audit on a43698d (low severity, not changed)
  • Refused at each session rather than as the config loads (a misconfiguration; each task then fails with dsh's
    own message before any request): Claude Code's claude_permission_mode in a dsh agent's params becomes dsh's
    permission mode; a provider route given with no model. A provider whose models are plain strings (round 13) fails
    at the session's start too, but with a Python error (AttributeError, or TypeError with image_input: true and no effort) instead of dsh's message; the documentation gives
    the form, a list of {id: ...}.
  • DSH_HOME with a leading ~ (as export DSH_HOME="~/x" leaves it): dsh expands it, hermit does not, so the
    user's .env is not copied for dsh's own provider, whose sessions then lack the key.
  • A kept home's sessions folder replaced by a link (by the session itself) is still resumed through it, from
    where the session could read anyway.
  • Correct today, untested: agent.base: dsh selecting dsh by its name; the dispatch of hermit deploy --host dsh;
    agent: {name: cli, base: dsh} binding its model as dsh's; the dialect hermit check and train require of a dsh
    model's backend (needs()); a one-key override of a dsh section, and agent.name: dsh with another agent.base
    refused; a session's own tool server name dsh refuses (tools_name; the deployed server's name is tested); a
    --patch=<path> in args; the clamp of a Responses total below the counts; a cut message, thought or tool result
    marked as cut; a tool other than bash, write, edit or an MCP tool kept as a tool-call step; the key half of the
    web-search rule; effort: off turning thinking off on dsh's own provider; (round 9) the exponent form of a timeout in
    both timeout markers (1e-7ms: JavaScript prints a timeout under 0.000001 ms so), which round 8's patterns match and
    which read as exit 0 before them; only the decimal form (1000.5ms) is tested; (round 12) a dangling link at the
    layer's path of a kept home (its test plants only a link to an existing file). Not a behaviour but a gap in that test
    (round 12): the call whose session leaves the pipe has no time limit, so a change that opens the layer after the
    session hangs the test, and CI's job until its 45-minute limit, instead of failing it; and the test does not check
    that a pipe was left.
  • No automated test runs dsh through the Trainer (post-PR audit): the dsh Evolver's step is tested without the
    evolver sandbox, and hermit train runs it in the sandbox only in the toy runs, which leave evolver.resume off (Pi
    on main has no such test either; Codex has one).
  • A line separator in a run's last text (U+2028, U+2029 or U+0085, which dsh's JSON leaves unescaped, as JSON
    allows; round 10): main's shared stream readers (cli._objects, trajectory.events) split lines there too, so
    such a run's final event is not read: a completed run reads as failed, its answer dropped, and an outage as none.
    Claude Code's result event is read the same way: a change of its own (split on \n only).
  • A tool's stderr in a session's text (rounds 10-12; for a stdio tool server on dsh's stderr: the adapter's outside
    the task sandbox, or a row of a --patch layer in args): whether the run completed, and whether it was an outage,
    are read from the last JSON line of type final in the stream, dsh's own unless a tool writes one. A tool's whole
    text line changes neither, but a JSON line of that type written after dsh's end (dsh stops its stdio servers after
    writing it), or at any time in a run dsh ended without one (SIGTERM), is taken for the run's end, so a failed run can
    read as a success with the tool's text as its answer, and a completed one as failed or as an outage; no known tool
    writes one. Any other JSON object line a tool writes is read as dsh's event too: of type status and phase
    step_end, its usage is added to the session's tokens and cost (with no usage, after a line of type text, the cost
    becomes unknown); of type session, its sessionId replaces the session's; of types tool_call and tool_result,
    it adds a step to the trajectory. Output with no line end just before a line of dsh's (a tool's progress dots) hides
    that line's event: if it is dsh's final, a completed run then reads as failed and an outage as none; if it is a
    step's status line, that step's tokens are not counted. Whether its tools started is read from every stderr line, so
    a tool's line in dsh's form for a row that did not start fails a completed run, and in a failed run's text takes the
    place of dsh's end; so does, with a backend that declares no context_window, a tool line holding
    CONTEXT_WINDOW_EXCEEDED, which brings the context-window hint to a run that ended otherwise. A tool's other text
    lines come before dsh's end in a failed session's text, whose last 600 characters keep that end; a JSON line of type
    error that a tool writes is read as dsh's, in the stream's order, so one written after dsh's end comes after it, and
    a long one (about 600 characters) pushes that end out of the text.
  • The tests' own backend names (round 10): the tests bind their scripted models as fake, mock and local, so
    a developer whose environment sets FAKE_BASE_URL, MOCK_BASE_URL or LOCAL_BASE_URL sees up to 10 of them fail,
    as main's Pi tests do for fake; two runs on dsh's own provider copy a developer's $DSH_HOME/.env into the
    test's home for the call (the fake dsh never reads it).
  • Wording, in code that round 12 left as reviewed: one test docstring compares the acting agent's check at load
    with Claude Code's effort, which is checked at load only on a login; one says only dsh's own provider can turn
    thinking off (dsh's pi-ai routes can too; hermit declares none for a bound model); one says dsh's project is the
    nearest folder above with a .git (it is the nearest with one, the working directory included, else the working
    directory: hermit/hosts/dsh.py says so); Dsh.outage's says only the final event tells an outage (see the item
    on a tool's stderr).
  • Skills on a redeployment (round 14): no test covers dsh's check_skills call (removing it fails no test, as on
    main for every host but Codex, the only one whose refusals hermit deploy retires skills the adapter no longer exports #97 tests). The merge commit 1f25839 alone, without
    947f146, lets a dsh redeployment replace a user's own .dsh/skills/<name>, since hermit deploy retires skills the adapter no longer exports #97's copy_skills replaces a
    skill's folder. The refusal looks only for a folder named as the exported skill: a user's flat
    .dsh/skills/<name>.md, or a folder whose SKILL.md gives the same name, is not refused, and dsh then offers one
    of the two by its own order (nothing on disk is replaced).
  • The patch layer's skills row (round 14; as on d66105a) is written only when the deployment copies a skill.
    After a redeployment that copies none, a person's dsh --patch .dsh/hermit.patch.yml session in a workspace below a
    repository's root no longer loads the skills the user left in .dsh/skills. The acting agent is unaffected: its own
    layer always names that folder.
  • A repeated test (round 14): main's test that the AHE Evolver's texts name every acting agent's stream now also
    covers dsh's, so this PR's dsh-only test repeats it, but for dsh's command (dsh --profile headless --json), which
    only that test checks.

Review

  • Tests and documentation cover the affected behavior, where applicable. Tests: see Validation. Documentation: see
    Docs under Change.

  • Commands, defaults, and public interfaces are consistent. New: agent.base: dsh (agent.name: dsh),
    evolver.base: dsh, the dsh Evolver, a dsh: settings section (permission_mode, image_input, ...),
    hermit deploy --host dsh, and the provider setting for dsh. hermit check passes on a dsh config.

  • Shared logs and examples contain no credentials or private data. The patch layer holds the key's variable
    name, never its value. A grep found the Fireworks key in no record of the real run. The fixtures hold no key.

  • Documentation-only: N/A.

  • Core changes: see Change.

  • Failures reported accurately: tests cover a failed request (an outage sets unavailable, so the task is run
    again), a run stopped by SIGTERM (dsh exits 0), a run cut at the token limit, tools that did not start, a run that
    started no session, a tool's stderr line with an outage's words (no outage: the task runs once), an overload a
    provider sent inside a 200 stream (an outage), a command moved to a background job at its timeout or killed at it
    (also at a timeout of 1000.5 ms), stopped, run in the background or cut (exit code unknown), and refused roles, login
    and container. Two corner cases are not reported accurately, both under Known issues: a JSON line of type final
    written by a tool can make a failed run read as a success (no known tool writes one), and a line separator in a run's
    last text makes a completed run read as failed.

  • Real entry points: in tests, make("dsh").run, the acting agent's run (with the deployed adapter's own
    stdio tool server) and the dsh Evolver's step (without the sandbox); hermit train and hermit eval in the toy
    runs (see Validation), which leave evolver.resume off: no test runs dsh through the Trainer (see Known issues). The
    stand-ins are only at the edges (the CLI, the model).

  • Test checklist: the dsh tests are in tests/core/test_dsh_agent.py (and test_deploy.py, the AHE, mwm and
    RRSI groups for their parts), named for the behaviour each holds; tests/fixtures/dsh/ is listed in
    tests/selection.py and tests/README.md, and its README says how each stream was recorded (dsh's output unchanged).
    Each review round's fixes were undone one at a time to see their test fail (round 8's: the decimal form of a timeout,
    not its exponent form; see Known issues). The stand-ins are a fake dsh that replays a recorded stream and a scripted
    model on the loopback. Some inputs are made in test_dsh_agent.py itself: usage removed from a recorded stream (as
    dsh writes a step whose usage it left out), a stream's events trimmed or spliced, an end's code swapped (for codes dsh
    writes; AUTH's keeps the recorded 503 words on purpose, so that a refusal's words are shown not to be read) or its
    message replaced (pi-ai's No API key), a tool's stderr line inserted, and three bash results: the two [stopped: …]
    ones, as dsh's bash tool renders them, and one marked error, whose text is a placeholder. Tests that run the
    installed dsh are marked integration and skip without it or without bubblewrap. Tests write to tmp_path, and the
    check under bubblewrap to a directory of its own under ~/.cache/hermit-tests (outside /tmp: see Known dsh
    behaviours), removed after it. Runtime (--durations, in the test container, par-1e75c6f): the 43 tests of
    test_dsh_agent.py and the 2 dsh tests of test_deploy.py, about 29 s with other containers running beside them: the
    11 that run the installed dsh 15.8 s (the slowest 2.8 s), the 32 others, which CI runs, 12.2 s (none over 1.1 s), and
    the 2 of test_deploy.py 1.1 s; the [dsh] cases of test_ahe_hosts.py and test_sandbox.py 0.1-0.2 s each, the
    mwm and RRSI parts under 0.1 s.

  • Sandbox checklist: every dsh session runs through hermit.sandbox: the acting agent in the task sandbox
    (isolate.py's worker), the dsh Evolver in the Trainer's evolver sandbox (the Fireworks run's records, below).
    dsh's own sandbox runs nested inside the run's for the acting agent (workspace-write), and is off inside the
    evolver sandbox, as Codex's is. dsh starts the deployed adapter's tool server (the stdio row of
    .dsh/hermit.patch.yml) outside its own sandbox but inside the task sandbox, with dsh's environment; the row carries
    HA_ADAPTER_SANDBOX, so the adapter's code runs in the Session's nested sandbox, as with Codex's server (see
    Validation). The task data's binding bubblewrap also runs inside the task sandbox and only adds mounts. The sandbox
    itself is unchanged; isolate.py changes only in its config check (see Core changes), which the checklist counts as
    a core change: pending the core team's agreement, so this stays unticked until it is linked here.

    The Fireworks run's sandbox records on 207ea88 (excerpt)
    • rollout-1.json: role rollout, enforced, network isolated, allow api.fireworks.ai, probe passed (the network
      outside and the run's data closed); environment names DSH_HOME, FIREWORKS_API_KEY,
      HA_ADAPTER_SANDBOX_ENFORCE, HA_RESULT_FD, HOME, PATH, PYTHONPATH and the proxy variables.
    • learner-1.json: role learner, enforced, network isolated, allow api.fireworks.ai, probe passed; program
      dsh's lib/bin.js; environment names DSH_AGENTS_HOME, DSH_HOME, DSH_TELEMETRY_DISABLED,
      FIREWORKS_API_KEY, HERMIT_DSH_NO_KEY, HOME, PATH, PYTHONPATH and the proxy variables; binds
      /w/adapter, /w/material, /w/task-info, /w/history, /w/state, /w/scratch, /w/output, /w/root,
      /w/method, /w/agent-logs, /w/agent_home.
  • Review rounds before this update (fresh agents, separate from the author's session; in rounds 1-6 each
    was given the general, test and sandbox checklists):

    • Round 1, on 03fafff: two reviewers, 23 findings. Fixed in 7a076fb, each with a test that fails without
      its fix: bash results cut at 8 KiB read as exit 0; markers anchored with Python's $; background commands read
      as exit 0; tools that did not start went unnoticed; started true for a run that wrote only an error;
      --add-dir broke every command for a path through a link; agent.dsh.permission_mode was dropped; a git
      repository's skills and AGENTS.md reached an unsandboxed session; a workspace below a repository's root lost
      its deployed skills; no check of the 64 KiB instruction cap; usage of a step that answered without usage was not
      counted; an effort without a model was dropped; user_home() ignored DSH_HOME; AHE had no dsh wire; mwm and
      RRSI accepted backends they cannot run; parse_log read Pi's and dsh's streams as Claude Code's; tests for
      dsh's sandbox inside the run's, the context-tool portability loop and the deployed server's sandbox variables;
      docstrings. Explained, not changed: what dsh does not report (compaction), the agent-writable patch layer, resume
      in the same directory, and the .env stop (reported as no session). Docs: pending.
    • Round 2, on 7a076fb: two reviewers, 20 findings and questions (three found by both). Fixed in 9d87553, each
      with a test that fails without its fix: task data was linked, which dsh's search does not follow; web search with
      another backend's model went to DeepSeek on DEEPSEEK_API_KEY; calls without reported tokens were priced at $0.00;
      the deployment marker could make a folder above the workspace the instructions' root; the deployed tool server did
      not fail the session when it could not start; the size check left out dsh's 304-byte frame; the AHE texts named
      Codex's stream for Pi and dsh; an effort level or permission mode dsh lacks failed every session instead of the
      config; docstrings (the deploy command named no profile); tests for an AgentEvolver step, the deploy's limit and a
      server name dsh refuses, and a recorded SIGTERM stream (stopped.jsonl). Explained, not changed: dsh injects a
      nested AGENTS.md when its file tools touch a folder (the templates' copies equal their context); the deployed
      skills row replaces a skill-filesystem config of the user's own (documented); the context window. The round-1
      message overstated one change: a dsh usage error was already an error record per task, not a wrong answer; that
      record now says dsh exited before starting a session (the run goes on).
    • Round 3, on 9d87553: two reviewers, 16 findings and questions (two found by both). Fixed in 2021944,
      each with a test that fails without its fix. One was a regression of round 2's own fix: its shared pricing rule
      made Claude Code's results of refused requests (one turn, no tokens) cost unknown, and the AHE and RRSI merges
      then dropped a step's known spend from the budget; it is replaced by the dsh-only unreported_calls. The others:
      dsh's zstd transcripts escaped key scrubbing; the dsh learner was given an adapter's AGENTS.md as instructions;
      the dsh Evolver's settings were not checked at load, and YAML's bare off was not off; the search rule went by
      the backend's name; a missing context_window failed every request on vLLM without saying why; the task-data
      binding had no test CI runs; wording (the deployed server row, a run dsh never started, the size check's scope,
      the linked-data warning). Explained, not changed: dsh's refusal of every command without bubblewrap or Landlock
      (not reproduced: a container without privileges fell back to Landlock); a session with bound data stopped by
      SIGTERM ends without an event.
    • Round 4, on 2021944: two reviewers, 14 findings, questions and checklist items (two found by both). Fixed in
      f4b7b09, each with a test that fails without its fix: a step dsh retried after a refused request lost its cache
      tokens and was priced as known (dsh drops the step's cache counts; recorded: retried.jsonl), which also corrects
      round 3's account of retries; the dsh Evolver's checks missed the method named with its params and refused an effort
      with the run's own model (they now run on the Trainer's params, in hermit check and before train's first step);
      efforts dsh's own provider refuses passed; evolver.dsh.instruction_files was dropped, not refused; the caller's
      DEEPSEEK_SEARCH_BASE_URL moved dsh's search without the sandbox; the context-window hint cut dsh's error.
      Explained, not changed: a home of compressed transcripts from earlier commits; an Evolver with no model reference
      (as Pi's on main); agent.dsh.instruction_files, like other free-form settings, does not reach the acting agent;
      a successful result keeps an unread failure text, as Pi's does.
    • Round 5, on f4b7b09: two reviewers, 14 findings, questions and test gaps (one found by both). Fixed in
      f1a92ea, each with a test that fails without its fix: round 4's unknown cost for a retried step made a whole
      session's spend invisible to a USD budget, while its tokens are known and hermit prices a cache write as input
      (now priced); the caller's DEEPSEEK_BASE_URL moved dsh's own provider without the sandbox; the Evolver's check
      refused its own values, and the acting agent's instruction_files: false, which would cut the adapter's context,
      was dropped; tests now pin the session's tool row (failOnStartupError, its 30-minute call timeout), every row
      DSH_OFF turns off and all six outage codes; wording: effort: off asks a bound model for no level (its
      server's default), and the context-window hint keeps dsh's error line as far as it fits. Explained, not changed:
      a Responses server's own total_tokens is taken as pi-ai gives it; a session with an unreported call has an
      unknown cost, which a USD budget does not count (as any unknown cost); a deployed tool server's stderr reaches
      dsh's (not reproduced as a problem; the post-PR audit reproduced one outside the task sandbox, fixed in
      cacb4ae).
    • Round 6, on f1a92ea: two reviewers, 11 findings (none found by both); the fixes change claims and tests, no
      behaviour. Fixed in 37aee8c: tests, each failing without what it checks: a session's own env reaching dsh had no
      test; the integration test of dsh's own sandbox now shows a command's write outside the workspace refused (it passed
      with the sandbox off); a bash call that failed to run has no exit code, and a session whose folder, or the folder it
      lies in, is a link is not resumed (neither was tested); and wording, in the reader's account: a stopped session's
      last step is not counted (its docstring said it was a call of unknown tokens), dsh's own adapter, not pi-ai, drops a
      cache count of 0, and a retried step's price is never less only for a price whose cached input is no dearer than its
      input, and on the Responses API only as far as the server's total_tokens holds the tokens; a stale test docstring.
      Explained, not changed: a sandbox's grant of DEEPSEEK_BASE_URL does not reach dsh (see Layer 2); a task run again
      after an outage keeps only the last attempt's usage, for every CLI (see Known dsh behaviours).
    • Round 7, on 37aee8c: a workflow of five reviewers, one lens each (usage and cost; settings and entry points;
      the session at run time; parity with Pi and shared code; tests and claims), each finding then reproduced by a
      separate agent told to refute it. 34 findings (27 distinct), all confirmed, none a wrong result in a correct
      configuration. One was rated medium: no test guarded which steps of dsh's stream count as calls, the rules a USD
      budget rests on; tested in 1bbd57c. Three more were rated medium by their reviewers and low by the agents that
      reproduced them (the code was right). One was changed at the author's request: no test held a command dsh moves to a
      background job at its timeout, what a timeout gives in a session unless dsh's job limit is reached, and only one
      check keeps it from reading as exit 0. f4792e0 records one (promoted.jsonl) and tests it, and also a command
      stopped from outside the call ([stopped: <reason>]), whose two results are written by hand as dsh's bash tool
      renders them: no headless session writes one. The other two were listed under Known issues as correct but untested:
      agent.base: dsh selecting dsh by its name, and the session's patch layer written past a link a session left in a
      kept home, which round 11 rated medium and tested (1e75c6f). The Pi fixes this branch carried moved to Name a Pi or mini-swe-agent acting agent's event stream in the AHE Evolver's texts #91 and Run every method on Pi (evolver.base: pi) #92
      (207ea88), so that this PR changes only dsh, and the check under bubblewrap keeps its home under
      ~/.cache/hermit-tests. Corrected in this description: its claims that mwm and RRSI both ran pi without its model
      (RRSI ran it on its model, with role settings Pi does not read), that round 6's fixes were all tests, that a stopped
      session's cost is always known, that the acting agent's check at load is as Claude Code's effort check (that one
      runs at load only on a login), and its list of usage dsh does not report (web searches were missing). The other low
      ones are not changed (the author's call): see Known issues.
    • Round 8, on f4792e0: two reviewers, one on the test and its recorded stream, one on the claims, each
      finding then reproduced by a separate agent told to refute it. 6 findings (5 distinct), all confirmed, one in
      part. Two were medium, both fixed in d25958a with recorded results: dsh prints the timeout it applied to a
      command, the model's when it gave one (1000.5), and both timeout markers were matched only at a whole number
      of milliseconds, so a command still running at such a timeout read as exit 0, and one killed at it that trapped
      the signal as its trapped exit; and the marker alone, what a command that printed nothing yet gets, was
      untested. The low ones are corrected: two in this round's own text (a docstring's order of dsh's markers, and
      that the [stopped: …] results are written by hand), and this description's diff size, stale since 1bbd57c
      (f4792e0 added a file).
    • Round 9, on d25958a: two reviewers, one on the code and its tests, one on the claims, each finding then
      reproduced by a separate agent told to refute it. 6 findings (4 distinct), all low, all confirmed. Three were
      wording this change's own text got wrong, corrected in a43698d and here: dsh prints the timeout it applied
      (60 s when the model gives none, at most 600 s), not always the model's; DSH_NO_OUTPUT's comment against a
      background marker that stands alone; round 8's account of its low findings. The fourth, the exponent form of a
      timeout untested, is not changed: see Known issues.
    • Post-PR audit, on a43698d: four auditors, one lens each (correctness; tests; the sandbox; this description and
      the docs plan), with the general, test and sandbox checklists; 11 of the 13 findings then went to a separate agent
      told to refute it (not the docs plan's missing pages, nor the tests' runtime, which another finding's refuter
      measured). 13 findings: 1 high, 2 medium, 10 low (the refuters held all 11, 6 in part). Fixed in cacb4ae, each
      with a test that fails without it: (high) the web-search test bound its DeepSeek models through the developer's own
      backends (HA_BACKENDS or ~/.config/hermit/backends.yaml, DEEPSEEK_BASE_URL, DS_BASE_URL), so it failed
      wherever those move deepseek: it now binds them on the built-ins alone (in such an environment a43698d's test
      fails and the head's tests pass); (medium) a deployed tool server writes on dsh's stderr, which the Base Agent read
      as dsh's own: outside the task sandbox (environment.runner: host, sandbox.enabled: false) a tool's line with an
      outage's words made a session that failed for another reason (max-tokens) an outage, run retries times and then
      an error of unknown cost, which a USD budget did not count; unavailable is now read from the final event's error
      code, and UNAVAILABLE is main's again. Corrected in this description: (medium) Layer 3's claim that the deployed
      tool server stops a session at its start as Codex's required does; the two accounts of started; Name a Pi or mini-swe-agent acting agent's event stream in the AHE Evolver's texts #91 and Run every method on Pi (evolver.base: pi) #92 as
      open drafts, and the files they share with this PR; the sandbox checklist, ticked while its isolate.py agreement
      is pending (it now also gives its records and the deployed tool server); the test checklist's runtime; the docs
      plan's missing pages; the real entry points; in the fixture README, tools_down.jsonl's port and where the Base
      Agent keeps its stderr lines. Disclosed, not changed: /tmp under dsh's workspace-write, and no test through the
      Trainer. The drop of the caller's DSH_* and DEEPSEEK_* variables under a sandbox that passes them needed no
      change: the shared drop's test (Claude Code's) holds it.
    • Round 10, on 0ddba64: a workflow of three reviewers, one lens each (the audit's fixes; the merge of main;
      this description), each lens's findings then reproduced by a separate agent told to refute them. 14 findings, all
      held (2 in part). Two were medium. One was a regression of cacb4ae's fix, fixed in ace8fab with a recorded
      stream (overloaded.jsonl): a provider overloaded inside a 200 stream (the Messages API's overloaded_error) ends
      the run with dsh's unclassified PI_AI_ERROR, which dsh does not retry, so the task was no longer run again; such
      an end is now told by its words, matched as Pi's notices are. The other: this description had ticked the independent
      review, which neither round 10's fix nor the head then pushed had had (unticked until round 12 had reviewed the
      head). The low ones are corrected here (dsh as the fifth CLI; mini-swe-agent among those priced as on main and
      missing from the AHE texts; a stale untested item; the audit entry's count; the runtime; the developer-environment
      row, now run with the rrsi group; records that name their commit) and in a test docstring (parse_log before
      mini-swe-agent: a Base Agent, an acting agent and a deployment host #49), or disclosed under Known issues (a line separator in a run's last text, a tool's stderr in a failed session's
      text, the tests' own backend names). One is outside this PR: the docs plan's proposed wording quotes main before
      mini-swe-agent: a Base Agent, an acting agent and a deployment host #49.
    • Round 11, on ace8fab (the post-PR audit of the published head): four reviewers, one lens each, aimed at where
      earlier medium findings came from: ace8fab's rule, as a table of every way a dsh run ends; the low findings and
      the "not reproduced" or "explained" items of rounds 1-10, checked again in conditions they had not been judged in
      (in two halves: the session at run time; configuration, the sandbox and deployment); and claims corrected in one
      place but left in another, and every ticked box. Each finding then went to a separate agent told to refute it (one
      agent per finding for the claims lens). 15 findings, all held (4 in part); 4 rated medium by their reviewers. Two
      held as medium, fixed in 1e75c6f, each with a test that fails without it: the kept home's patch layer is written
      as a file of its own, never through a link or into a pipe the last session left (round 7's untested guard); an end
      dsh leaves unclassified that is no outage, with a tool's outage words on dsh's stderr, runs once (ace8fab's path
      had no such test). The other two were rated low by their refuters, and their items are corrected: a task whose every
      attempt ends in an outage keeps no usage at all (main's loop), and web searches and a compaction's request leave
      a cost that reads as known; a code change that counted each search as a call of unknown tokens was withdrawn, since
      it would leave such a session's whole cost out of a USD budget. A refuter checking an "explained" item found one
      more medium: the deployed patch layer's parity with Codex's .codex/config.toml is false (Codex keeps .codex
      read-only; see Known dsh behaviours). The low ones are corrected here and in 1e75c6f (PI_AI_ERROR as dsh's code,
      not pi-ai's; docstrings and test comments that ace8fab made stale or that misstated dsh; the tool's stderr item; a
      list item nested under the wrong one; the run results' split order; the round-7 account; the deploy test's name;
      the inputs written by hand), or added to Known dsh behaviours (dsh tells an error's kind by its words).
    • Round 12, on 1e75c6f (the post-PR audit of what changed since ace8fab): two reviewers, one on the commit and
      its tests, one on this description's edits, each finding then reproduced by a separate agent told to refute it. 10
      findings, none above low: 9 held (3 in part), 1 refuted (the Core changes box, which asks what changes and why, as
      Change answers). The code is left as reviewed. The low ones are corrected here (a refusal that needs no word to be
      an outage; what a tool's stderr can change; why hermit's own sessions are unaffected by an edited layer; the inputs
      made by hand; what 1e75c6f adds) or listed under Known issues (the kept home's test: a dangling link, no time
      limit on the call that leaves the pipe, and no check that a pipe was left; the project root in a test docstring).
    • Round 13, on f0d2091 (the documentation): four reviewers, one lens each (the pages on the agents and the
      learners; settings, limits and the sandbox; deployment, models, installation and the changelog; the documentation
      followed as a new user, its commands run in the test image), each lens's findings then reproduced by a separate agent
      told to refute them. 16 findings (10 distinct), none above low: 12 held, 4 in part. Corrected in a7883d8, all ten:
      a dsh command's /tmp is its own, the default sandbox refuses a declared writable path, and how to mount a path
      inside the workspace (guides/sandbox.md); how to write a provider, and what a web fetch reaches; which price the
      cost follows; dsh named as DeepSeek Harness on the custom Evolver page; Landlock, the patch layer, llm-pi-ai and
      the request left out of the usage are explained; a sentence's word order. One of them also showed a code behaviour,
      listed under Known issues (a provider with plain strings for models). Then a7883d8 and this description's
      update were read back by two separate readers, each finding reproduced by a separate agent: 9 findings, all held (2
      in part). Two were on the documentation (low), corrected in d66105a: the sandbox guide's two ways to reach a path
      under /tmp are for reading it, and the requests that summarize a session's history are uncounted each time. Seven
      were on this description (two medium: Run every method on Pi (evolver.base: pi) #92's account, stale since Run every method on Pi (evolver.base: pi) #92 was rewritten, and a merge of main called
      conflict-free that had conflicts), corrected here. These corrections were not read again.
    • Round 14, on 1f25839 and 947f146 (the merge of 1b0d897, and hermit deploy --host dsh retiring stale
      skills):
      four reviewers, one lens each (the conflict resolutions in the code, read with git show --remerge-diff;
      OpenCode and dsh side by side in the code they share; dsh's adoption of hermit deploy retires skills the adapter no longer exports #97; the tests and, lightly, the
      documentation), each lens's findings then reproduced by a separate agent told to refute them. The resolutions held:
      every definition one side changed keeps that side's version, the 26 both sides changed are unions, every registry
      and table gives each CLI its parent's behaviour (main's for all but dsh, d66105a's for all but OpenCode, compared
      by probes run on both parents), and every test of either parent is on the merged tree with the same outcome (one of
      main's as the Pi case of this PR's parametrized version of it). 8
      findings (7 distinct), none above low: 5 held, 3 in part. Five are on this PR and listed under Known issues (dsh's
      check_skills call untested, found by two reviewers; what the skill refusal does not see; the patch layer's skills
      row; a repeated test). Three are on main's side, the same on 1b0d897, and are not changed here: an OpenCode
      workspace's AHE hooks run by hand do nothing and report nothing (as mini-swe-agent's since before this PR; the AHE
      page says OpenCode is not one of the template's hosts); no test checks hermit deploy --host opencode refusing an
      adapter that is not plugin-portable; a change to tests/fixtures/pi/ alone does not select the RRSI test that
      reads it.
  • This description: read end to end against the code at 207ea88 and the run records it cites; the parts
    1bbd57c, f4792e0, d25958a, a43698d, cacb4ae, 0ddba64, ace8fab, 1e75c6f change, against those commits.
    Rounds 11 and 12 read the corrections of the rounds before them, and two separate readers those made from round 12;
    what those two found is corrected here and was not read again. This update (the documentation, the merge of
    f469f59, round 13) was read back as round 13's entry says. The next one (the merge of 1b0d897, 947f146, round
    14) was not read back.

  • Independent review (post-PR audit): on a43698d (above), on ace8fab (round 11), on 1e75c6f (round 12) and, for
    the documentation, on f0d2091 (round 13) and a7883d8 (its read-back), and for the merge of 1b0d897, on
    1f25839 and 947f146 (round 14): no high or medium finding remains. Their low findings are
    corrected here or listed under Known issues. The code has not changed since 1e75c6f except by the merges of f469f59
    (77f3d75, no conflict) and 1b0d897 (1f25839) and by 947f146, which round 14 reviewed; the checks above ran on
    the head. d66105a's two documentation sentences (the read-back's) were not reviewed again.

🤖 Generated with Claude Code

iamziyuzhao and others added 8 commits November 8, 2026 20:23
…Evolver)

DeepSeek Harness (`dsh`, npm @deepseek-ai/dsh 0.2.2-rc.1) beside codex, claude and pi, in the
same three layers as Pi (#89), on the runner's outcome() of #77.

- Layer 1: a <backend>/<model> reference becomes a route of dsh's pi-ai adapter
  (Binding.dsh_provider): the backend's URL in the first dialect it serves, the key by its
  variable's name (apiKeyEnv), the context window. dsh reports no cost: the backend's price
  applies.
- Layer 2: Dsh(CliAgent) runs `dsh --profile headless --json` in a private DSH_HOME, HOME
  and DSH_AGENTS_HOME, telemetry off, the caller's own DSH_* variables dropped. The
  session's settings are a patch layer of dsh's own rows (--patch): the bound model as the
  default model (effort as its reasoningEffort, image_input as Pi's), the call's tools as an
  MCP server offered directly (1800 s per call), web search and fetch off unless asked for,
  no title request, session log, plugin list, sub-agents or workflows. A server that needs
  no key gets the placeholder key through HERMIT_DSH_NO_KEY (a route that names none is
  refused by dsh). Approvals are never asked (--approval-policy never); permission_mode
  picks dsh's own sandbox. Resume by --session-id when the home keeps the session; the
  workspace's .dsh/skills are also the session's own (dsh reads project skills only at the
  nearest folder with a .git). outcome(): a run without a completed `final` has failed,
  whatever dsh's exit code (SIGTERM exits 0). Usage from step_end events; the trajectory
  from the stream, a command's exit code from dsh's end markers (a timeout or a stop:
  unknown; "(no output)": empty); every unfinished end is an error step. dsh's outage codes
  (RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, STREAM_CLOSED) join UNAVAILABLE.
  Roles, a login and container isolation are refused.
- Layer 3: hermit/hosts/dsh.py (`hermit deploy --host dsh`): AGENTS.md, .dsh/skills and
  the adapter's tool server (120 s per call) as an insert row of .dsh/hermit.patch.yml,
  which the acting agent passes with --patch. The AgentEvolver, the loader and the config
  accept dsh; methods/dsh/ is the new Evolver (dsh's own sandbox off in the evolver
  sandbox, as Codex's is).
- Tests: tests/core/test_dsh_agent.py over streams recorded from dsh 0.2.2-rc.1
  (tests/fixtures/dsh) and, marked integration, the installed dsh: a session unsandboxed
  and in bubblewrap, the acting agent inside a git repository (the deployed skill offered,
  the adapter's own tool server answering), a keyless server, and --dump-config holding
  every row the session patches; the deploy host; selection entries.

Checked against dsh's source at the tag dsh-v0.2.2-rc.1 and with the installed dsh. First
built on #89's earlier head; rebuilt on main after #89 merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fix was checked against dsh's source at dsh-v0.2.2-rc.1 and, where it shows, against
the installed dsh (new recorded streams in tests/fixtures/dsh); each has a test that fails
without it.

- Stream reading: a bash result dsh's --json cut at 8 KiB (`truncated`) lost its exit
  marker and read as exit 0; now unknown, and the text says it was cut. A command started
  in the background has no status yet (unknown). The end markers are anchored at the very
  end (\Z), as dsh's JavaScript reads them, so a command that printed a marker of its own
  and exited 0 is a clean exit.
- Usage: a step that answered counts as a call even when the provider reported no usage
  (dsh reports zeros), and so does a step dsh reports no usage for (an attempt without
  any); what dsh never reports (compaction, a step's partial usage) is documented.
  parse_log reads Pi's and dsh's streams with their own readers.
- outcome(): a session whose tools did not start (dsh writes the MCP row's error on stderr
  and runs on without them) has failed. A run that wrote only an `error` event (a usage
  error) or nothing (a working directory's .env that sets PATH, PYTHONPATH, ...) started
  no session, so the acting agent reports it instead of recording each task as a wrong
  answer: outcome() may now say `started` (shared runner; absent, as before).
- The workspace is the project wherever it lies: the session's layer sets the
  skill-filesystem row to the workspace's .dsh/skills alone and the agent-instructions row
  to the deployment's marker (.hermit-deploy.json) as the project root, so a workspace
  inside a git repository gets its own skills and none of the repository's skills or
  AGENTS.md (they outranked the adapter's). This replaces copying the skills to the home.
- `--add-dir` takes the real path of an existing directory, as dsh requires (a link failed
  every command and write); an effort without a model is refused; user_home() follows
  DSH_HOME.
- The acting agent passes agent.dsh.permission_mode on (it was dropped) and refuses a mode
  dsh has not.
- hermit deploy --host dsh: the patch layer also names the workspace's .dsh/skills (a
  workspace below a repository's root kept none), and a context past the 65,536 bytes dsh
  reads of a project's instructions is refused.
- The AHE template has a `dsh` wire (as Pi's: hooks run by hand hand the memory over); mwm
  and RRSI refuse a backend other than Codex or Claude Code at setup (pi and dsh were
  accepted and ran without their model).
- Docstrings say what dsh reads (.env files, the instruction chain) and sends (its own
  provider's ids).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fix was checked against dsh's source at dsh-v0.2.2-rc.1 and, where it shows, against the
installed dsh; each has a test that fails without it.

- A task's data is bound in the workspace, not linked (binds_mounts, as for Claude Code): dsh's
  glob and grep run ripgrep without --follow, so a linked folder was never listed or matched.
  dsh's own sandbox still runs nested in that mount namespace (integration test).
- Web search with a model of another backend: dsh's search is DeepSeek's own service (its
  Messages API on DEEPSEEK_API_KEY), so the session searched on a provider and key the run did
  not bind, outside its usage. Such a session gets web fetch only, as Codex's hosted search is
  off for a backend other than OpenAI's; a DeepSeek model or dsh's own provider keeps search.
- Usage: a step dsh reports no usage for counts as a call only if it produced something (a step
  whose requests all failed is none), and price_usage leaves calls of which no token was
  reported unpriced: their cost is unknown, not $0.00 (shared: a usage with calls and no tokens
  is no longer priced at 0 for any CLI).
- The instructions' project root: no root marker, so it is the working directory, the workspace.
  The deployment marker (.hermit-deploy.json, which every host's deployment writes) made a
  session without a deployment take a deployed folder above it for its root, and read its
  AGENTS.md.
- hermit deploy --host dsh: the tool server's row fails at a session's start when dsh cannot
  start it (failOnStartupError, as Codex's `required`), which the acting agent reads as a failed
  session; it was retried in the background while the session ran without the adapter's tools.
  The 65,536-byte check counts dsh's frame around AGENTS.md (304 bytes, a </system-reminder>
  one byte more): an AGENTS.md just under the cap was deployed and cut. Docstring: the command
  names its profile; the skills row replaces a skill-filesystem config of the user's own.
- dsh's effort levels and permission modes are checked as the run config loads (an unknown one
  failed every session, one task at a time), and an effort level dsh has not is refused in the
  session.
- The AHE learners' texts name a Pi or dsh acting agent's stream (agent-pi.jsonl,
  agent-dsh.jsonl) instead of agent-codex.jsonl (Pi's entry was missing on main too).
- Tests: a recorded stream of a session stopped by SIGTERM (stopped.jsonl: exit 0, no `final`,
  an `error` event) replaces a cut success stream, and is run through the acting agent and an
  AgentEvolver step; the deploy refuses at the exact limit and an MCP server name dsh refuses.

Correction to round 1's message: on 03fafff a dsh usage error was already an error record per
task (failed), not a wrong answer; `started` now makes the acting agent stop the run and say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fix was checked against dsh's source at dsh-v0.2.2-rc.1 and, where it shows, against the
installed dsh; each has a test that fails without it.

- Usage: round 2's shared rule (a usage with calls and no tokens is not priced) is gone. It
  made Claude Code's results of refused requests (a 529, a 401: one turn, no tokens) cost
  unknown instead of $0.00, and the AHE and RRSI evolvers' merged usage then dropped the
  step's known spend from the budget. Usage gains `unreported_calls`, the calls whose tokens a
  CLI did not report; only dsh's reader sets it (a step that produced something without
  usage: the provider reported none, or dsh retried the step and so left its usage out), and
  price_usage leaves such a usage's cost unknown rather than pricing the part reported, which
  under rate limits may be far less.
- dsh's transcripts are plain JSON lines (the session layer sets session-persistence-jsonl to
  compression none, its root as dsh's own `!!js dshHomePath('sessions')`): they were zstd,
  which hermit.redact cannot scrub a key from. A scrubbed transcript still resumes.
- The dsh Evolver's sessions take no instruction file (instruction_files false: the
  agent-instructions budget 0): dsh adds a folder's AGENTS.md when its file tools touch it, so
  the learner was given the adapter's AGENTS.md, the acting agent's policy, as instructions.
  Codex's learner is given none.
- dsh's settings are checked as the run loads for the dsh Evolver too (an effort it has not
  failed every learner step, after its rollouts); evolver.dsh.permission_mode is refused (the
  Evolver runs dsh's own sandbox off), and YAML's bare `off`, a boolean, is `off`.
- Web search is kept only for a model reached at api.deepseek.com on DEEPSEEK_API_KEY, where
  dsh's search goes (a `deepseek` backend moved by DEEPSEEK_BASE_URL searched elsewhere).
- A run that ends CONTEXT_WINDOW_EXCEEDED with a backend that declares no context_window says
  to declare it (recorded: context_window.jsonl, vLLM's refusal of dsh's 128,000 output
  tokens; with the window declared dsh sizes the request and completes).
- A unit test with the fake dsh for the task data's binding, which CI runs where bubblewrap can
  bind (the only test needed the dsh CLI).
- Wording: the deployed tool server's row makes dsh report a server it cannot start and the
  acting agent fail the session (dsh itself goes on); a run dsh never started is each task's
  error record, not a stopped run (round 2's message said so); the deploy's size check is exact
  for a session at the workspace's root; the linked-data warning names dsh's glob and grep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fix was checked against dsh's source at dsh-v0.2.2-rc.1 and against the installed dsh;
each has a test that fails without it.

- A step dsh retried after the provider refused a request lost its cache counts: the refused
  attempt reports zeros, pi-ai reports a count of 0 as none, and dsh sums a count into the
  step's only when every attempt reported it, while the step's totalTokens still holds them.
  The reader counts what the total holds beyond the counts reported as input, and the call as
  unreported (its cost unknown: those tokens were read from the cache or written to it, which
  are priced apart). Recorded: retried.jsonl (a 503, then 1000 prompt tokens, 900 cached:
  dsh reports inputTokens 100, totalTokens 1010). Round 3 said such a step's usage is left
  out; dsh does that only for an attempt that reports no usage at all (a stream that ended
  before its last event, an attempt a stop cut short).
- The dsh Evolver's settings are checked from its params as the Trainer has them (its method's
  own under the run's, the model bound), in login.evolver_problems, which train runs before its
  first step and hermit check now reports for an Evolver without a login. Round 3's check, as
  the run loaded, missed the dsh method named with its params (`backend: dsh` comes from the
  method's manifest) and refused an effort given with the run's own model. The acting agent's
  are still checked as the run loads; one function (cli.dsh_problems) holds both, and
  isolate.py's change is that call.
- dsh's own provider (a model of no binding, DeepSeek's API) takes the efforts off, low, high
  and max; another level failed every session (UNSUPPORTED_REASONING_EFFORT).
- evolver.dsh.instruction_files is refused, as evolver.dsh.permission_mode is: the Evolver
  dropped it.
- The caller's DEEPSEEK_SEARCH_BASE_URL is dropped with its DSH_* variables: without the
  sandbox it moved dsh's web search off DeepSeek's API.
- The context_window hint follows dsh's error whole (once), not its first 300 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each fix was checked against dsh's source at dsh-v0.2.2-rc.1 and against the installed dsh;
each has a test that fails without it.

- A step dsh retried after a refused request is priced again, its dropped cache tokens counted
  as input. Round 4 made such a session's cost unknown, and a USD budget then counted none of
  its spend (rollouts and learner steps add no dollars for an unknown cost), under the very load
  that causes retries. Its tokens are known, and hermit prices a cache write as input: the price
  is the step's with the counts reported, or more if those tokens were read from the cache,
  never less. A call stays unreported, and its session's cost unknown, only when dsh reported
  none of its tokens.
- The caller's DEEPSEEK_BASE_URL is dropped as DEEPSEEK_SEARCH_BASE_URL is: without the sandbox
  it sent the requests of dsh's own provider, with DEEPSEEK_API_KEY, elsewhere. A variable the
  session is given (`env`) still passes.
- dsh's settings check takes what each agent uses: the Evolver's danger-full-access and
  instruction_files false are accepted (round 4 refused them); the acting agent's
  instruction_files false is refused (it was dropped; it would cut the adapter's context, which
  reaches dsh as its AGENTS.md).
- Tests pin the session's tool row (failOnStartupError, the 30-minute call timeout), every row
  DSH_OFF turns off, all six outage codes, and dsh's own provider's levels for the Evolver; the
  outage test is named for what it checks.
- Wording: effort off asks a bound model for no reasoning level, which leaves its server's
  default (dsh's own provider turns thinking off); the context-window hint keeps dsh's error
  line as far as it fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each finding was checked against dsh's source at dsh-v0.2.2-rc.1 and the installed dsh; each
test added fails without what it checks. No behaviour changes: the fixes are tests and claims.

- Tests: a session's own `env` reaches dsh while the caller's DEEPSEEK_BASE_URL is dropped; the
  integration test of dsh's own sandbox shows a command's write outside the workspace refused
  (it passed with the sandbox off); a bash call that failed to run (a result dsh marks `error`)
  has no exit code; a linked session folder, or a session in a linked folder, is not resumed;
  a stopped session's usage is pinned (none).
- The reader's account: the last step of a session a stop cut short is not counted (dsh writes
  a step's usage at its end, after its tool calls ran, and a stopped session writes nothing
  more); the docstring said such a step was a call of unknown tokens. dsh's own adapter, not
  pi-ai, drops a cache count of 0. A retried step's price is never less than with its counts
  for a price whose cached input is no dearer than its input; on the Responses API the total
  is the server's own `total_tokens`, taken as given.
- dropped_env says why a sandbox's grant of DEEPSEEK_BASE_URL does not reach dsh (hermit's own
  is its deepseek backend's chat completions root, which dsh reads as its own provider's
  Messages root) and that `env` gives dsh its own; the Settings paragraph says so too.
- A stale test docstring: the Evolver's own permission mode and instruction_files are taken.

Not changed, disclosed: the acting agent's re-run after an outage keeps only its last
attempt's usage, for every CLI (shared code, a separate change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pi fixes this branch carried change Pi's behaviour on main, not dsh's: parse_log's Pi
reader, the AHE Evolver's texts for a Pi acting agent's stream, and mwm and RRSI refusing a Pi
learner. They go to their own PRs (fix/pi-stream-readers, fix/mwm-rrsi-backend-check). This
branch keeps dsh's entries only: parse_log reads a dsh stream as dsh's, the AHE texts name a
dsh acting agent's stream, and mwm and RRSI refuse dsh (the other PR's general check covers it
too, whichever merges first).

The integration test of dsh's own sandbox keeps the session's home under
~/.cache/hermit-tests, as the test checklist asks of a check under bubblewrap (it was under
/var/tmp).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 7 (five reviewers on 37aee8c, one lens each, every finding then reproduced by a
separate agent told to refute it) found 34 issues, all confirmed: one of medium severity, the
rest low. The medium one: no test guarded the reader's three rules for a step whose tokens dsh
did not report (a step that only called a tool is a call, so is one that only thought, and what
a step produced does not carry over to the next); each, undone, left every test passing while
it misstated a session's cost. The new test fails under each. The low ones are listed in the
PR description, not changed.

Also: a round-6 test docstring rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iamziyuzhao and others added 7 commits November 9, 2026 13:21
In a hermit dsh session a command still running at its timeout (dsh's default is 60 s) is
moved to a background job, unless dsh's job limit is reached: the session's layer leaves
tool-bash's promoteOnTimeout at its default, true. Its result ends `[still running after
<ms>ms; moved to background job <id>]` with no exit marker, and only the DSH_BACKGROUND check
keeps it from reading as exit 0. No test held that case: bash.jsonl was recorded with
promotion off. Review round 7 found this (rated medium by its reviewer, low by the agent that
reproduced it, as the code was right); the author asked for it fixed.

promoted.jsonl is recorded through make("dsh").run, as the other Base Agent streams are, and
the test now reads it, and also a command stopped from outside the call (`[stopped: <reason>]`,
which no headless session writes, so written as dsh's tool-bash renders it), with and without
a marker after it. Each check undone (the background check, the `stopped:` alternative, the
markers after a stop) fails the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 8 (two reviewers on f4792e0, one on the test and its fixture, one on the claims,
each finding then reproduced by a separate agent told to refute it) found:

- dsh prints a command's timeout as the model gave it, a JavaScript number (`1000.5`, `0.5`),
  and both timeout markers were matched only at a whole number of milliseconds: a command still
  running at such a timeout, moved to a background job, read as exit 0, and one killed at it that
  trapped the signal read as its trapped exit (rated medium). Both markers now match any number
  dsh prints. Recorded: promoted.jsonl's third call (1000.5 ms, moved to the background) and
  bash.jsonl's ninth (1000.5 ms, killed, exit 0 trapped).
- The test of a promoted command held only one with output before its marker; the marker alone,
  what a command that printed nothing yet gets, rests on DSH_BACKGROUND matching without a newline
  before it, which nothing held (rated medium). promoted.jsonl's second call is `sleep 5`.
- Wording (low, in f4792e0's own docstring): the stop marker comes before the signal or exit
  marker, not before every other marker (a sandbox denial's come first).

Each fix undone fails its test: the marker with a newline required before it, and either marker
at whole milliseconds only. bash.jsonl is recorded again by the same script, its first eight
results unchanged; its steps' count in the usage test follows (10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 9 (two reviewers on d25958a, one on the code and its tests, one on the claims, each
finding then reproduced by a separate agent told to refute it) found 6 issues (4 distinct), all
low. Three are wording this change's own text got wrong, corrected here (comments, a docstring
and the fixture README; no code or test changes):

- dsh prints the timeout it applied, not always the model's: its default (60 s) when the model
  gives none, and at most its cap (600 s).
- DSH_NO_OUTPUT's comment said `(no output)` comes before the marker of a command that printed
  nothing, which a command moved to the background does not get (its marker stands alone).

The fourth, the exponent form both timeout patterns match (`1e-7`, a timeout under 0.000001 ms)
held by no test, is listed in the PR description, not changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…built-ins alone (post-PR audit)

- Dsh.outcome read `unavailable` from every line of the stream that is no event, and dsh starts a deployed stdio
  tool server on its own stderr, which the stream keeps: outside the task sandbox, a tool's line with an outage's
  words (`Service Unavailable`) made a session that failed for another reason (`max-tokens`) an outage, run
  `retries` times and then an error of unknown cost. It is now read from the `final` event's error code
  (DSH_OUTAGES, Dsh.outage), and UNAVAILABLE is main's again.
- test_dsh_refuses_roles_and_a_container_and_searches_only_on_deepseeks_own_provider bound its DeepSeek models
  through the developer's own backends (HA_BACKENDS or ~/.config/hermit/backends.yaml, DEEPSEEK_BASE_URL,
  DS_BASE_URL), so it failed wherever those move `deepseek`: it binds them on the built-ins alone.
- The fixture README: tools_down.jsonl's port is one Node's fetch refuses, and the Base Agent's stream has that
  run's stderr lines first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: main's new Base Agent (mini-swe-agent) and this branch's (dsh) are both kept, mini-swe-agent's first as
main has it, in every list of CLIs, bases, hosts, stream readers, bindings and settings sections (cli.py's CLIS,
records.parse_log, trajectory's STREAM_HOSTS and READERS, backends' NEEDS, CLI_AGENTS and CLI_KINDS, config's FREE,
BASES and CLI_BASES, defaults.yaml, the acting agent's names and checks, isolate.validate's login check, `hermit
deploy --host`, the context-tool test's hosts). parse_log keeps main's Pi reader. The acting package's table of agents,
which now lists mini-swe-agent, lists dsh too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eview round 10)

- cacb4ae read an outage from the final event's code only, which narrowed a43698d's rule: a provider overloaded
  inside a 200 stream (the Messages API's `overloaded_error` event, a chat server's error chunk) has no status, and
  dsh ends the run with pi-ai's unclassified PI_AI_ERROR, which it does not retry; the task was then not run again.
  Such an end is now read by its words (UNAVAILABLE, as Pi's are), and only dsh's own end is read so, never a tool's
  stderr. Recorded through the Base Agent: overloaded.jsonl.
- A test docstring described parse_log as main had it before #49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A kept home's patch layer is written as a file of its own, never through a link
  or into a pipe the last session left (the dsh Evolver's agent_home): the guard
  had no test.
- An end dsh leaves unclassified (PI_AI_ERROR) that is no outage, with a tool's
  outage words on dsh's stderr, runs once: ace8fab's new path had no test that
  its words come from the end alone.
- PI_AI_ERROR is the code of dsh's pi-ai adapter, not pi-ai's; outcome() and
  errors() no longer say the end's code alone tells an outage.
- test_deploy.py: the dsh test is named for what it holds, and its comments say
  what dsh does (a server that does not start is reported; the dsh acting agent
  fails the session; a project's root may be the working directory itself), as
  hermit/hosts/dsh.py now does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@iamziyuzhao
iamziyuzhao marked this pull request as ready for review November 10, 2026 05:47
iamziyuzhao and others added 6 commits November 10, 2026 02:18
…h branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DeepSeek Harness joins the pages that name the CLI Base Agents, their settings, deployment, models and installation, in
the wording of Pi's and mini-swe-agent's entries; what is its own: its inner sandbox (permission_mode, and what it does
to environment.write and /tmp), its web search, its patch layer and how a session loads it, a tool server that does not
start, and the usage it leaves out. No code changed. convert's lists are untouched: dsh has no plugin format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 13 read f0d2091: 16 findings, all low. dsh's commands each have a /tmp of their own and its default sandbox refuses
a declared writable path (the sandbox guide); how to write a provider of one's own (model-backends); what a web fetch
reaches; which price the cost follows; what Landlock, a patch layer and the request left out of the usage are. No code
changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sandbox guide's two ways to reach a path under /tmp are for reading it (a mount is read-only); the requests that
summarize a session's history are uncounted each time, not once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained #91 (the AHE Evolver's texts name a Pi or mini-swe-agent
acting agent's stream), #94 (OpenCode as a CLI Base Agent), #97
(hermit deploy retires skills the adapter no longer exports) and #99
(agent skills for this repository). #94 adds OpenCode at the places
this branch adds dsh, so 36 files conflicted. Each conflict keeps both
sides, OpenCode before dsh:

- CLIS, the readers (records.parse_log, trajectory's STREAM_HOSTS,
  READERS and MARKS, the AHE debugger's STREAM_TEXTS) and every list
  of CLIs name both, dsh last; cli.py's Dsh part follows OpenCode's;
- main's NO_LOGIN replaces this branch's tuples in login.py,
  isolate.py and acting/workspace.py, and gains "dsh";
- a comment that no longer fits 120 columns with both names is on
  its own line;
- docs: both names in each list. docs/methods/agent.md's dsh learner
  now "signs in as the pi learner does", since OpenCode's sentence
  stands between them; in docs/methods/ahe_adapter.md, #91's general
  wording ("the one its CLI wrote") replaces this branch's list of
  streams.

dsh does not call #97's check_skills and retire_skills yet; the next
commit does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hermit deploy --host dsh now calls check_skills before it writes
anything (a redeployment refuses to replace a skill folder no
deployment wrote, or to write through a linked skills folder) and
retire_skills after it copies the skills (a skill the last deployment
exported and this one does not moves to .retired-skills/). dsh copies
its skills before it writes its patch layer, whose skill-filesystem
row names them, so retire_skills follows copy_skills there. #97's
redeployment test gains the dsh case, which fails without the
retire_skills call; no test covers the check_skills call, as on main
for every host but Codex. #97's CHANGELOG entry names .dsh/skills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@haizhongzheng haizhongzheng left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and for the careful description. We reviewed 947f146 with two independent reviewers, verified each finding against the code, and found nothing that blocks the merge. We made no changes.

What we checked

  • No change for the existing CLIs. The shared-code changes leave Codex, Claude Code, Pi, mini-swe-agent and OpenCode as they were: started in CliAgent.run, unreported_calls in price_usage, parse_log, the stream marks, needs and the CLI lists.
  • Config checks, run on a real config. A dsh agent with a bound model loads with effort: medium, and YAML's bare off is read as off. A wrong effort or permission_mode is refused at load. hermit check reports a dsh Evolver's wrong effort and its permission_mode.
  • Copied files. The user's ~/.dsh/.env is copied only when there is neither a binding nor a login.
  • dsh is listed everywhere. Every list of CLIs in hermit/ and docs/ names dsh.
  • Tests. python -m pytest --changed --base origin/main selected every group: 1287 passed, 110 skipped, 0 failed. Of the skips, 10 need the dsh CLI, which this machine lacks; the rest are optional groups that CI installs. CI passed on this head.
  • Merging. The PR merges cleanly into main (886c89d).

isolate.py
The only change is the config check: dsh refuses login, and the acting agent's dsh settings are checked. The sandbox mechanics are unchanged. As maintainer, I agree to this change, which the sandbox checklist asks for.

Noted, not changed (both are on main already)

  • A dsh .env escapes redaction. Without a binding, ~/.dsh/.env is copied into the session's home. hermit.redact knows only the values in os.environ, so a key that exists only in that file is not scrubbed if a session prints it. main does the same with mini-swe-agent's .env and with Codex's and Claude Code's credential files. A shared fix would cover every CLI.
  • extra_body is dropped. Binding.dsh_provider does not pass a backend's extra_body. pi_provider, which uses the same pi-ai, does not either, and neither do Codex and Claude Code; only OpenCode and mini-swe-agent apply it. The docs' "added to every chat request" already overstates this for Pi.

🤖 Generated with Claude Code

@haizhongzheng
haizhongzheng merged commit bb19ad4 into main Nov 11, 2026
1 check passed
@haizhongzheng
haizhongzheng deleted the feat/dsh branch November 11, 2026 05:18
haizhongzheng added a commit that referenced this pull request Nov 11, 2026
Conflicts: mwm's and RRSI's backend checks keep this PR's list
(codex, claude, pi), which refuses dsh as #90 did; the tests add dsh
to the refused backends. Docs list both Pi and dsh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0