Declarative command harness (bring any CLI agent, no code)
The process path used to be code-bound: a harness id mapped to a TypeScript adapter (ClaudeCodeHarness,
ScriptedHarness) baked into the job-runner image. So onboarding a new CLI agent (aider, etc.) meant a PR + image
rebuild. The command harness removes that: a CLI agent is declared as data (a HarnessSpec) and a
single generic CommandHarness interprets it. A SaaS user registers a spec → no code from us.
The spec (kind: "command")
{
"kind": "command",
"id": "aider", "version": "0.74.0",
"image": "…", // optional: dispatch image (default = job-runner image). use setup to install.
"setup": ["pip install --quiet aider-chat==0.74.0"], // run once in the sandbox before the task
"command": "aider --yes --no-git --message {{task}} --model {{model}} .",
"model": "sonnet",
"env": { }, // extra env (LLM keys come from per-tenant secrets, not here)
"trace": { "kind": "none" } // | { "kind":"otel"|"mlflow"|"langfuse"|"langsmith"|"phoenix", "endpoint":"…",
// "collect":"job"|"control-plane", "authSecret":"…"?,
// mlflow: "correlate":"id"|"tag"?, "experiment":"…"? · phoenix: "project" }
}
Template tokens in command: {{task}} (shell-quoted automatically — don't wrap it in quotes),
{{model}}, {{run_id}}.
How it runs
The control plane resolves the spec from the registry and embeds it in the CaseJob (harnessSpec). The
dispatched agent's makeHarness sees kind:"command" and builds the generic CommandHarness
(@everdict/harnesses), which: runs setup → runs the templated command in the sandbox (ComputeHandle.exec,
cwd work, with EVERDICT_RUN_ID + spec.env injected) → extracts the trace. The repo Environment + Graders
are unchanged, so evaluation (git-diff snapshot, tests-pass, …) works as for any harness. Same process-dispatch
path → runs on Local / Nomad / K8s backends with the existing isolation.
Trace extraction
Everdict's own record is unconditional. Whatever trace says, every run yields an env_action
command.exit (the command we invoked, its exit code, its wall duration) plus the process output we saw
(stdout/stderr tails as log events). That half is not the agent's testimony about itself, so it is never
delegated to a platform: a harness that pulls its trace from MLflow still leaves us evidence, and a dead
endpoint or a mistyped correlation key can no longer empty our ledger for a run we actually performed. A
configured trace source adds the agent's own account on top; it never substitutes for ours. What must not
double is the ANSWER — stdout is promoted to an assistant message only where nothing else carries the answer
(none, or file with no self-reported assistant message); elsewhere it stays a log, so answer-match and
judges keep reading exactly one answer.
none— no trajectory/cost. The command's stdout (tail 32k) becomes the final assistantmessagetrace event, so prompt-QA benchmarks (answer-match/judge — e.g. OfficeQA-style) grade a black-box CLI's printed answer; outcome grading (repo diff +tests-pass) is unchanged. A non-zero exit yields anerrortrace event (never silently swallowed). Numeric metrics the agent prints itself (e.g. browser-use'ssteps: 12result block) are recoverable with thetext-metricgrader ({id:"text-metric", config:{pattern:"^steps: (\\d+)", metric:"agent_steps"}}— one capture group over the final assistant message), so a trace:none run still gets a per-case step/score axis; real trajectory/cost still requires instrumentation (otel/mlflow) or themeterUsageproxy.file— the self-reported plane:{"kind":"file","path":"everdict-trace.json"}and the agent writes its ownTraceEvents (a JSON array or JSONL, one line per step; a relative path resolves under the harness workDir). Malformed lines are dropped individually and a missing file just means the agent chose not to report — the stdout answer still stands, and it is still emitted when the file carries no assistantmessage. The events pass through verbatim, which makes their TIME the agent's responsibility:- Stamp
at(ISO-8601 absolute instant) on every event.talone is a scalar in a unit only the emitter knows, so an event withoutatsits OFF the trajectory viewer's wall-clock axis — reported as unanchored, never guessed onto it. An agent that numbered its stepst: 1, 2, 3…and stamped nothing is why: its fifteen steps were once drawn inside the first fifteen milliseconds of a twenty-three second run. - Stamp
spanIdon atool_callandparentIdon thetool_resultthat answers it (and on any step nested under another) to get a span TREE instead of a flat list. Optional, additive, ignored by graders. durationMsis optional — atool_callwithout one takes its length from its result's arrival.
- Stamp
otel/mlflow/langfuse/langsmith/phoenix— after the command, the trace is pulled via@everdict/tracebuildTraceSource(the same 5 kinds as pull-ingest; phoenix needsproject) byEVERDICT_RUN_ID. The agent must tag its spans with that id (OTEL_RESOURCE_ATTRIBUTES=everdict.run_id=…is injected) — i.e. an instrumented agent; uninstrumented CLIs usenone. Where the pull happens is thecollectknob (2-phase collection,docs/architecture/streaming-case-pipeline.mdD4):"job"(default) —runCasecalls the harness'scollectTrace(runId)after compute release (sandbox not held during flush lag; works for cluster-internal endpoints);"control-plane"— the job ends at execution, the result carriestraceRef {kind, endpoint, runId}, andexecuteCasepulls + grades the deferred observation graders on the control plane (only if the endpoint is reachable from there). Auth:authSecret(SecretStore name → verbatimAuthorization; resolved into transienttrace.authfor in-job pulls, re-resolved by name for control-plane pulls). Correlation (mlflow + otel):correlate:"id"(default — runId IS the platform trace id, pull-ingest convention) or"tag"— the agent tags its trace witheverdict.run_id=$EVERDICT_RUN_ID(mlflow: trace tag, resolved viatraces/search, requiresexperimentscope; otel: resource attribute —OTEL_RESOURCE_ATTRIBUTESis already injected — resolved via the Jaeger search API, requiresservicescope). Tag mode needs zero id coordination: the agent keeps its own trace ids. Empty pulls retry (3×, flush lag).
Security
setup/command are arbitrary user code → they run only inside a trust zone (gVisor/Kata + per-tenant
namespace + warm-pool keying), the isolation the runtime already enforces for untrusted tenant code. Pin
image/install versions.
Verified
- Deterministic (
packages/harnesses/src/command.test.ts): setup ordering;{{task}}shell-quoting + env injection (EVERDICT_RUN_ID); every run recordscommand.exit(a silent command still leaves that one event);trace:none→ stdout = final assistant message; exit≠0 →errorevent;trace:otel→ pulls by run id and keeps stdout/stderr aslogs rather than as the answer; setup failure → error. - 2-phase collection live, real MLflow 3.14 (
scripts/live/trace-collect-mlflow.mjs):collect:"job"round trip (injectedEVERDICT_RUN_ID→ post-release pull of real spans → steps/cost derived) +collect:"control-plane"(job returnstraceRef;executeCasepulls + grades deferred observation graders) + dead-endpoint soft-degrade. All PASS. - Live, no key (
scripts/live/command-harness.mjs): a user-declaredcommandspec (echo … > result.txt) dispatched throughLocalBackend→ ran in the realLocalDriversandbox →CaseResultwithresult.txtin the git-diff snapshot. Zero code, zero LLM key. - aider live, real model (
scripts/live/aider-litellm-live.mjs): the real OSS coding agent aider (setup: pip install aider-chatinto a venv) fixed a seeded bug (addreturneda-b) using gpt-5.4-mini served by workclaw's LiteLLM (--model openai/chatgpt/gpt-5.4-mini+OPENAI_API_BASE/OPENAI_API_KEY), and Everdict graded it tests-pass = PASS — end-to-end, zero adapter code. Gotchas for a LiteLLM responses-bridged model: aider needs--no-stream(streaming garbles the bridged output → no edit applied) and--edit-format whole(robust for weaker models). Seeexamples/harness-templates/aider-litellm.template.json. - aider live on Nomad (
scripts/live/aider-nomad.mjs): the same aider+gpt-5.4-mini eval run inside a real Nomad alloc (docker driver) →tests-pass = PASSin ~10s.NomadBackendinjects the LiteLLM key viasecretEnv(→ alloc env → inherited by aider); the baseeverdict-job-runnerimage bakes inpython3+aider(packages/job-runner/Dockerfile) so runs are fast andsetupis empty. Gotcha (container→host networking): use the docker bridge gateway172.17.0.1forOPENAI_API_BASE, not the host LAN IP — from inside the alloc the LAN IP TCP-connects but the model-completion response doesn't return cleanly, hanging aider until timeout; the gateway path works in ~10s. - aider on K8s (kind) (
scripts/live/aider-k8s.mjs,K8sBackend({hostNetwork})): PASS — real aider fixes the seeded bug using gpt-5.4-mini (workclaw LiteLLM) inside a real K8s Job →tests-passin ~13 s. Nomad↔K8s real-agent parity complete (Local + Nomad + K8s all green). Two things were needed:- Networking: a kind pod can't reach the host's host-network LiteLLM normally, so the eval pod uses
hostNetwork: true(K8sBackend.hostNetwork) and the node is joined to the docker default bridge (docker network connect bridge everdict-control-plane) → reaches172.17.0.1:4000. (Normal-pod paths — direct, or Service+manual-Endpoints — can't reach the host at all in kind.) - Model name (the real root cause of the earlier "hang"): this litellm version routes any model whose name
contains
chatgpt/to its native ChatGPT-OAuth device-code provider, which blocks forever waiting for an interactive login in a non-interactive pod. (SLICE-25's "httpx hangs" was a misdiagnosis — rawhttpxPOSTs fine; only litellm's OAuth path stalls.) Fix: give aider a clean alias (gpt-5.4-mini, nochatgpt/prefix) registered on the LiteLLM proxy → litellm uses the plain OpenAI-compatible path. Use--model openai/gpt-5.4-mini. Production note: in a real cluster, run LiteLLM as an in-cluster Service (normal pod network) and expose models under non-chatgpt/names.
- Networking: a kind pod can't reach the host's host-network LiteLLM normally, so the eval pod uses