Passer au contenu principal
B

dsh-eval-harness

biboyang/dsh-eval-harness

Harness d'évaluation pour les plugins DSH : des cas au format YAML pilotent de vraies exécutions d'agent en mode headless, avec des assertions sur les appels d'outils, les arguments, les résultats et l'utilisation des tokens, ainsi qu'une barrière de référence pour la non-régression en CI.

Installer

dsh plugin --profile web add github:biboyang/dsh-eval-harness

README

dsh-eval-harness

DSH 插件/skill 作者的回归评测门禁:写 yaml 用例 → headless 驱动真实 agent 跑 → 解析 session trace 断言 → 对比 baseline 出 PASS/WARN/FAIL 报告与 CI 退出码。

简介

给 DSH 插件/skill 的回归评测流程提供一个可进 CI 的门禁工具:

  1. 用 yaml 写评测用例(prompt + 期望行为断言);
  2. eval_run 逐条 fork dsh --profile headless --patch <overlay> <prompt> 子进程跑真实 agent(overlay 把会话落盘切到隔离目录,每条用例独立 workspace),解析落盘的 session.jsonl / session.jsonl.zstd trace(多帧 zstd 直读),执行断言,写 report.json + report.md;
  3. eval_gate 把本次报告与 baseline 报告对比,输出 OVERALL=PASS|WARN|FAIL|N/A 与退出码,供 CI 拦截回归。

安装

npm 渠道已废弃:registry 上的 dsh-eval-harness 停留在 0.3.1,不再更新,请勿从 npm 安装。分发只走 GitHub。

从 GitHub 源码安装:

dsh plugin --profile headless add github:BiBoyang/dsh-eval-harness

# 验证挂载
dsh --profile headless --dump-config | grep dsh-eval-harness

能力面

Tools

工具说明
eval_run跑 cases_dir 下全部用例:headless 驱动真实 agent → 采集 session trace → 断言 → 写 report.json/report.md
eval_gate对比 baseline 与本次报告,输出门禁判定(OVERALL/EXIT_CODE),strict 模式收紧 WARN 退出码
eval_judge_validate在人工标注集上校准 LLM judge:报混淆矩阵与 TPR/TNR(分开看,agreement 会骗人),双指标达标才算 calibrated

Skills

Skill作用
eval教模型帮用户编写评测用例(用例格式、断言编写要点、解析子集约束)

用例格式(cases/*.yml)

一个文件一条用例:

name: 用例名                    # 唯一,gate 按 name 对比 baseline
prompt: "发给 agent 的内容"      # 多行可用块标量 `|`
require_plugins: [some-plugin]  # 可选,元信息
tags: [fast]                    # 可选,标签;eval_run 的 tags 筛选按任一命中匹配
retries: 1                      # 可选,失败重跑次数(非负整数,缺省用 eval_run 的全局 retries)
trials: 3                       # 可选,可靠性测量的独立 trial 次数(正整数,缺省用 eval_run 的全局 trials,默认 1);
                                # trials > 1 时忽略 retries——测量必须是没有重试干预的原始单次成功率
mock:                           # 可选,mock 模式(见下节;不用真实 API,离线确定性)
  fault: F4                     #   注入故障形态 F0-F5(缺省 F0)
  api: openai-completions       #   协议端点:openai-completions / openai-responses / anthropic-messages(缺省 openai-completions)
  once: true                    #   可选,一次性故障:仅首个 LLM 请求命中,此后回 F0(横评矩阵语义)
  plugins: [dsh-find-plugin@0.4.0]  # 可选,挂载被测插件(见下节;版本必须钉死)
assert:
  turn_end: completed           # turn/end 事件的 reason.kind
  exit_code: 0                  # 可选,dsh 子进程退出码;声明后非零退出进断言层比对(不再直接记 error)
  tools_called: [tool_a]        # tool/call 名称序列须按序包含(保序子序列)
  output_contains: ["关键词"]    # 最终 assistant 文本须包含全部
  max_steps: 8                  # 可选,step/end 数上限
  max_tokens: 50000             # 可选,token 上限(input+output+reasoning;cacheRead/cacheWrite 不计入,防多步膨胀)
  no_tool_errors: true          # 可选,任何 tool/result 硬错误(data.error / isError)即 fail
  tools_exact: [tool_a]         # 可选,工具调用名称序列须完全一致(长度+顺序+内容)
  tools_not_called: [tool_b]    # 可选,列出的工具一次都不能被调用
  output_not_contains: ["抱歉"]  # 可选,最终 assistant 文本不得包含任一子串
  output_matches: ["^okay"]     # 可选,最终 assistant 文本须匹配全部正则(解析期预编译校验)
  tool_args_contains:           # 可选,指定工具至少一次调用的参数 JSON 串包含子串
    - name: tool_a
      contains: '"path"'
  tool_result_contains:         # 可选,指定工具至少一次结果的文本包含子串
    - name: tool_a
      contains: total
  output_judge:                 # 可选,LLM 语义评审(结构断言全过后才调,判 FAIL 记 fail)
    rubric: "回答应解释原因而非只给结论"

LLM-as-judge(output_judge):表达「解释原因而非只给结论」这类写不出正则的语义 期望。定位是结构断言优先、judge 兜语义——一个 attempt 只有结构性断言全过后才会调 judge(结构已失败不白烧 judge token);judge 判 FAIL 时理由进该用例 failures (形如 output_judge: <理由>),判 PASS 不留任何痕迹。judge 走 OpenAI 兼容 chat completions 接口(零依赖,Node 内置 fetch),配置全靠环境变量:EVAL_JUDGE_API_KEY (缺省回落 DEEPSEEK_API_KEY,两者都无时报错)、EVAL_JUDGE_BASE_URL(默认 https://api.deepseek.com)、EVAL_JUDGE_MODEL(默认 deepseek-chat)。judge 调用 本身失败(HTTP 错误/超时/回复解析失败/无 key)按 error 处理而非 fail——infra 抖动 不是断言失败,可被 retries 覆盖。

报告里的 token 是分字段聚合:total (in X+out Y+reas Z; cacheR A+cacheW B)——prompt cache 命中时 inputTokens 只剩零头、真实输入在 cacheReadTokens,分字段展示让 cache 命中情况一眼可见。max_tokens 对 total(input+output+reasoning)生效:cacheRead 是 多步会话里同一段缓存的重复读回,计入会让上限随步数膨胀,故只展示、不计入。

示例见 cases/example.case.yml。 cases/real/ 收录了 12 条针对真实插件(bash/fs/search/todo/web_search/subagent/workflow 等)的实测用例,全部在真实 agent 回合中验证过; 其中 08-read-image.yml 演示 no_tool_errors 如何拦下「工具报错但 agent 兜底答对」的假通过(在无视觉能力的模型上该用例预期 fail,属正常)。

解析约束:harness 内置零依赖 YAML 子集解析器(块级 map、- 标量/map 序列、 flow 序列、引号、数字/布尔/null、|/> 块标量、注释)。不支持锚点、多文档; 解析失败报带行号的 eval_run: 前缀错误。

mock 模式与 chaos 包

用例带 mock: 段时,eval_run 不再依赖真实 LLM API:每个 attempt 起一个独立 ephemeral mock provider(127.0.0.1 随机端口,用后即收),生成隔离 DSH_HOME (settings.yaml + profile patch 指向该 mock;不碰用户真实 ~/.dsh),子进程 env 覆盖 DSH_HOME / MOCK_API_KEY 并清掉代理变量。mock 按 F0-F5 故障矩阵输出 与真实 API 逐事件对齐的 SSE 流(三协议端点同挂),协议异常路径因此变成 离线、零成本、无 flaky 的确定性回归。

  • 故障形态:F0 健康流;F1 中途 FIN;F2 丢终止事件;F3 半事件 FIN;F4 length 截断(Responses 方言为独立 response.incomplete 事件);F5 think-only 截断 (阳性对照)。
  • once 语义:once: true 时仅首个 LLM 请求命中故障、此后回 F0——dsh 的断流 重试会走健康流(横评矩阵即此口径);缺省为持续注入(每次请求都命中)。
  • plugins 挂载:mock.plugins 声明被测插件 spec(npm 包名或 github: 形态), eval_run 生成隔离 DSH_HOME 后、起子进程前,用 dsh 自己的安装器 (dsh plugin --profile headless add <spec>,env 与评测子进程同构)逐 spec 装进 headless profile——保真度最高(与真实安装路径完全一致),且天然把「安装成功率」 纳入评测数据。版本必须钉死(@x.y.z 或 #<sha>):不钉的 spec 装 latest, 版本漂移(上游发新版/删包)造成的红自己负责。安装需要网络属预期(本工具不做 离线安装;本地路径 spec 由 pnpm 原生支持,转发安装器即继承)。安装失败是用例 级 error 而非 run 崩溃:该用例记 error(消息含 spec、安装命令与安装器输出 尾部),同 run 其他用例照常执行——安装失败是数据,不是事故。
  • allowBuilds 预置与解锁(需构建插件):pnpm 11 默认拦截依赖的安装期构建 脚本(git/tarball 形态插件靠 prepare 构建产物,必被拦——装完即死)。eval_run 按最小授权解锁:registry 形态 spec(@scope/name@ver、name@ver)在生成 隔离 home 时把声明的包名预置进 profile 的 pnpm-workspace.yaml allowBuilds (pnpm 对 registry 包按裸名匹配构建许可);URL/git 形态的包名无法从 spec 推导 (pnpm 官方:git/tarball 依赖的裸包名永不放行,必须 name@<精确解析路径>), 安装器首次撞上 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 时,收割 pnpm 自打印的 精确 key(只接受引用本 spec 的),写入 profile 后重试一次——与 dsh 提示的 手工解锁流程("add the exact key pnpm printed, then re-run")完全同构,不猜名、 不全局放行。风险语义:声明 mock.plugins 即授权这些插件的安装期构建脚本在 本机执行(任意代码运行)——只声明信得过源码的插件,并钉死版本/commit。
  • 送达证明:报告 attempt 级附 mockDelivery(每 LLM 请求一条:端口、fault、 事件数、字节数、终止证人送达情况、关流方式)——"故障确实送达"可证,排除 "故障没送达导致的假绿/假红"。空数组同样是信号:dsh 根本没打到 mock。
  • exit_code 断言:声明后非零退出进入断言层比对(未声明的非零退出仍直接记 error)。chaos 用例靠它钉住「截断必须显式失败」。
  • dsh_bin 指向源码仓库跑法时的 tsx 解析坑:子进程 cwd 是用例 workspace, tsx 从 cwd 找不到 dsh 仓库的 tsconfig(@deepseek-ai/* 的 paths 映射失效, 会撞上 stale lib,报 FiberState 类导出缺失错误)。解法:设环境变量 TSX_TSCONFIG_PATH=<dsh 仓库根>/tsconfig.json(runner 透传 process.env)。 npm 包形态(npx -y @deepseek-ai/dsh 或全局 dsh)无此问题。

cases/chaos/ 是内建故障回归包(六条,tags: [chaos], eval_run 传 tags=chaos 单跑),把 dsh 0.1.7-rc.2 在六种故障下的实测行为固化为 断言:F0 对照组;F1-F3 断流静默重试自愈(exit 0 / completed);F4/F5 检出 length 截断(exit 1 / turn_end max-tokens)。全部离线可跑(无需 DEEPSEEK_API_KEY), 已做阳性对照验证(人为改 fault 证明用例能红)。

诚实边界:mock 模式覆盖结构性断言与协议故障路径回归;它不覆盖 output_judge 语义评审和任何依赖真实模型能力的用例——这些仍需真实 API, mock 不是全面断网方案。chaos 断言钉的是 dsh 当前行为(含 L1 级的缺陷现状: F4 检出但无用户文案),行为固化不等于行为背书——上游把检出改善为显式告知 (L2+)或退化为静默(L0),chaos 包都会变红提醒人工确认基线。

工具参数

eval_run

参数类型必填默认说明
cases_dirstring是-用例目录(.yml/.yaml)
output_dirstring是-report.json / report.md 输出目录
session_rootstring否<output_dir>/.sessions隔离的 session 落盘根
profilestring否headlessdsh profile
timeout_msinteger否600000单条用例子进程超时
dsh_binstring否$DSH_BIN 或 dshdsh 可执行命令,按空白拆分;本机无全局 dsh 时用 npx -y @deepseek-ai/dsh
concurrencyinteger否1并行跑用例的并发数;每条用例独占 session 根与 workspace,并行互不干扰
retriesinteger否0失败重跑的全局默认次数;单条用例最多跑 retries+1 次,任一 attempt 全过即停(fail 和 error 含超时都触发重跑);用例 yaml 的 retries 优先于此值;trials > 1 的用例忽略重试
trialsinteger否1可靠性测量的独立 trial 次数全局默认;> 1 时用例跑满 n 次隔离 attempt(每次前清空 workspace、不重试),报告写入 per-case reliability(successRate / pass@k / pass^k),用例状态仍为任一通过即 pass
pass_kinteger否2pass@k / pass^k 的 k;不得超过任何被测量用例的有效 trials,否则报错(小样本外推会给出虚假精确的数)
tagsstring否-逗号分隔标签筛选:只跑 yaml tags 命中任一的用例
onlystring否-逗号分隔用例名(精确匹配);与 tags 同给时取交集;筛选后无命中会直接报错(防 CI 笔误空跑假绿)

输出:JSON 文本(summary + 报告路径 + 各用例状态)。错误一律 throw eval_run: 前缀消息(找不到 dsh 可执行文件、用例解析失败等)。

report.json 当前写入 schemaVersion: 1。eval_gate 会严格校验当前 schema;未带 schemaVersion 的旧版 baseline 会按 legacy schema 0 兼容读取,并为新增的诊断字段补上 安全默认值。未知的未来 schema、重复用例名、非法状态、token 字段或 summary 不一致都会以 eval_gate: invalid report: 前缀报错,不会继续做门禁比较。

每条新报告用例还会写入 attemptResults:按执行顺序保存每次重试的状态、断言失败、 进程诊断、trace 摘要、token 与耗时;CaseResult 顶层字段继续表示最后一次 attempt, 兼容现有 gate 与报告消费者。旧报告没有真实 attempt 历史时,loader 会合成一条单次记录, 不会伪造旧报告不存在的重试信息。报告头部另记 dshVersion(dsh --version 探针首行), 排障时可直接区分「dsh 变了」还是「模型变了」。

trials > 1 的用例额外写入 reliability:successRate(单次成功率)、passAtK (无偏估计 1−C(n−c,k)/C(n,k))、passPowK(无偏估计 C(c,k)/C(n,k);不用 plug-in 的 (c/n)^k——x^k 上凸,Jensen 不等式保证它向上偏); report.md 用例表新增「可靠性 (trials)」列。可靠性默认只做测量展示;需要进门禁时用 eval_gate 的 min_trial_success_rate(见下)。

eval_judge_validate

在人工标注集上校准 judge。标注集是 JSONL,每行 {"rubric": "...", "output": "...", "expect": "pass"|"fail"}。逐条调 judge (配置同 output_judge 的环境变量)后报混淆矩阵。

参数类型必填默认说明
labels_pathstring是-人工标注 JSONL 路径
tpr_thresholdnumber否0.9TPR(真失败被抓到的比例)达标线
tnr_thresholdnumber否0.9TNR(真通过没被冤枉的比例)达标线

TPR 与 TNR 分开看:标注集里 90% 都是 pass 时,什么都放行的橡皮图章 judge 也能拿 90% agreement——agreement 会骗人,所以 calibrated=true 要求两个指标都达标; 某一维没有对应样本(比如没标 fail)时该维记 null 且整体不达标。判定与标注不一致 的条目收在 mismatches 里供人工 review judge 的错法。

judge 使用与校准(工作流)

output_judge 是唯一的非确定性断言——judge 本身是个会犯错的 LLM,它的漏判会直接 变成门禁的假绿。所以 judge 断言的生命周期比结构断言多两步:

  1. 写 rubric:写出「必须/不许」的可判定标准,避免主观词(「回答要好」这类只会 放大抖动)。能落成 output_contains / output_matches 的期望不要用 judge。
  2. 攒标注集:从已有报告的 finalText 里抽真实输出,每条亲手标 PASS/FAIL 存成 JSONL(格式与示例见 examples/judge-labels.example.jsonl)。通过的、失败的样本 都要有——缺了 fail 样本就验证不了「judge 会不会抓失败」。
  3. 校准:eval_judge_validate 跑标注集,TPR / TNR 都 ≥0.9(默认阈值)才算 calibrated;不达标先看 mismatches 里 judge 的错法,调 rubric 措辞再校。
  4. 进门禁:校准通过后,output_judge 的判定才可以信。
  5. 重新校准的触发时机:judge 模型更换(EVAL_JUDGE_MODEL)、harness 升级动了 judge prompt、被评输出的数据分布明显变化(比如换了被测模型)。

eval_gate

参数类型必填默认说明
beforestring否-baseline report.json 路径;缺省或文件不存在 → N/A
afterstring是-本次 report.json 路径
strictboolean否falsestrict 模式下 WARN 退出码为 2
gate_jsonboolean否falsetrue 时输出单条 JSON(供 CI 解析),否则 key=value 文本
max_token_increase_pctinteger否50token total(与 max_tokens 同口径)涨幅阈值百分比:状态不变的用例超阈值记 token 回归(WARN);0 关闭
min_trial_success_ratenumber否关闭trials 可靠性门槛(0-1):带 reliability 的用例若 successRate 的单侧 95% Wilson 下界低于该值记 WARN——判下界不判点估计;缺省关闭,保留「trials 只测量」语义

gate 协议

判定规则(优先级从高到低;软信号的设计理由见 docs/gate-signals.md):

条件判定退出码
有用例 PASS → FAIL/error,或新增用例即 FAIL/errorFAIL1
有用例 FAIL/error → PASS,或用例数量变化(新增通过/移除)WARN0(strict 为 2)
状态不变但 token total 涨幅超阈值(默认 +50%,max_token_increase_pct 可调,0 关闭)WARN0(strict 为 2)
skippedLines 较 baseline 增长(trace 解析漏帧增多,断言可能基于残缺数据)WARN0(strict 为 2)
新增 flaky 用例(重跑后才过)较 baseline 增多;baseline 已有 flaky 不重复告警WARN0(strict 为 2)
pass 但带工具硬错误的用例(agent 自我纠正)较 baseline 新增WARN0(strict 为 2)
同一 stderr 错误签名跨用例/跨 attempt 出现 ≥2 次(崩在同一处,疑似共享态事故)WARN0(strict 为 2)
开启 min_trial_success_rate 时:trials 用例 successRate 的单侧 95% Wilson 下界低于阈值WARN0(strict 为 2)
dsh 版本较 baseline 变化仅 informational reason + DSH_VERSION_CHANGED 行,不影响判定-
全部与 baseline 一致PASS0
无 baselineN/A2

文本输出(key=value 行 + 明细行):

OVERALL=FAIL
EXIT_CODE=1
STRICT=false
REGRESSIONS=1
NEW_FAILURES=0
IMPROVEMENTS=0
ADDED=0
REMOVED=0
TOKEN_REGRESSIONS=0
SKIPPED_LINE_INCREASES=0
FLAKY=0
TOOL_ERROR_RECOVERIES=0
REPEATED_ERROR_SIGNATURES=0
BASELINE_FLAKY=0
UNRELIABLE=0
REASON regression: echo-hello pass -> fail
REGRESSION echo-hello: pass -> fail

gate_json=true 时输出单条 JSON(含 verdict/exitCode/reasons/regressions 等字段)。

CI 集成

快速质量 workflow 见 .github/workflows/ci.yml:每次 push / PR 执行 pnpm install --frozen-lockfile、pnpm build、pnpm test、pnpm lint,不需要真实 LLM 或 API key。真实 agent 回归 workflow 见 .github/workflows/eval.yml: pnpm build && pnpm test 后直调 lib/runner.js 的 runEval 跑 cases/real/ 全量(真实 LLM,需仓库 secret DEEPSEEK_API_KEY),再用 lib/gate.js 的 computeGate 对比 baseline/report.json, 按 EXIT_CODE 拦截;report 作为 artifact 留存。评测步开 concurrency: 3 与 retries: 1(偶发网络/模型抖动重跑一次,flaky 标记留在报告里供排查)。 用例或 harness 代码变更会触发重跑, 另有每日定时跑(近 24h 无新 commit 则跳过)。

baseline 更新走 .github/workflows/update-baseline.yml: Actions 页手动触发 → 全量重跑 → 覆盖 baseline/report.json 并开 PR(附报告摘要), 人工复核后合并,不自动合入。

baseline/report.json 已入库(12 条全量重跑人工复核:全 PASS;read-image 仅在无视觉能力的模型上预期 fail,见上)。用例/断言口径变更时须重跑全量、人工复核后更新 baseline,否则 gate 会把口径变化判成 WARN/FAIL。

session trace 说明

评测依赖 DSH 落盘的会话 trace(默认 $DSH_HOME/sessions/<cwd编码>/<session-id>/session.jsonl[.zstd], 每行一帧信封 { type, seq, time, data })。eval_run 不污染环境变量,而是为每条用例生成一个 --patch overlay (<output_dir>/eval-overlay-<序号>-<用例名>.patch.yml),按 row id 整体替换 base bundle 的 session-persistence-jsonl 配置:把 root 切到该用例的隔离目录 (<session_root>/<序号>-<用例名>,session_root 默认 <output_dir>/.sessions;序号是加载序, 因为 slug 化不是唯一键,如 read image 与 read-image 同 slug);每条用例再以 独立 workspace 作 cwd。per-case session 根 + workspace 让用例可以并行跑(concurrency), 互不干扰;subagent/workflow 的子会话也落在同一用例的根下。用例名重名会直接报错 (gate 按 name 对比 baseline)。 子进程命令形如 dsh --profile headless --patch <overlay> <prompt>(launcher flags 在前, prompt 是 app 位置参数放最后)。

collector 按文件头魔数自动识别编码:默认的多帧 zstd(session.jsonl.zstd)走 decodeZstdLog 直读(结构扫描帧边界 + 逐帧解压,零外部依赖,仅 Node 内置 node:zlib), 纯文本 session.jsonl 走 UTF-8。两种编码都能读,eval_run 不再依赖 overlay 强制 compression: none。真实落盘帧的契约快照见 tests/fixtures/ 与 tests/zstd.spec.ts。

会话发现(findSessionFile):subagent/workflow 用例会在同一 root 额外落下 delegationDepth > 0 的子会话日志;多候选时按 header 行的 delegationDepth 分档, 父会话(0)优先于不可解析、再优先于子会话(>0),同档取最新 mtime。

超时兜底:用例子进程超时(SIGKILL)时不再只记 error,而是尽力采集已落盘的部分 trace(残缺尾帧由 decodeZstdLog 恢复)写进 report,供排查超时原因;采集失败 不掩盖超时本身。

开发命令

pnpm install   # 安装 devDependencies(typescript / vitest / biome / @types/node)
pnpm build     # tsc → lib/(含类型声明 lib/types/)
pnpm test      # vitest run tests
pnpm lint      # biome check --error-on-warnings(仅 lint,format 未启用)

插件管理

已装插件用 plugin-registry 的薄控制台管理(浏览器面板):管理 profile 插件安装态(bundle 层栈 + insert 行 + 启停),无需手改配置。安装: dsh plugin --profile web add <plugin-registry>/packages/plugin/console

Plugins associés