python-yama 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {python_yama-0.1.0 → python_yama-0.2.0}/CLAUDE.md +7 -0
  2. python_yama-0.2.0/PKG-INFO +99 -0
  3. python_yama-0.2.0/README.md +74 -0
  4. python_yama-0.1.0/README.md → python_yama-0.2.0/README.zh-CN.md +2 -1
  5. {python_yama-0.1.0 → python_yama-0.2.0}/docs/agent-skill-test-framework-design.md +38 -7
  6. {python_yama-0.1.0 → python_yama-0.2.0}/skills/eval-plugin-with-yama/SKILL.md +22 -6
  7. {python_yama-0.1.0 → python_yama-0.2.0}/skills/eval-plugin-with-yama/references/case-schema.md +2 -1
  8. {python_yama-0.1.0 → python_yama-0.2.0}/skills/eval-plugin-with-yama/references/debugging.md +13 -6
  9. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_cli.py +33 -42
  10. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_framework.py +128 -2
  11. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_mocks.py +52 -0
  12. {python_yama-0.1.0 → python_yama-0.2.0}/yama/cli.py +11 -12
  13. {python_yama-0.1.0 → python_yama-0.2.0}/yama/llm.py +4 -1
  14. {python_yama-0.1.0 → python_yama-0.2.0}/yama/mocks.py +22 -1
  15. {python_yama-0.1.0 → python_yama-0.2.0}/yama/report.py +1 -1
  16. {python_yama-0.1.0 → python_yama-0.2.0}/yama/runner.py +33 -6
  17. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/bash/fs.py +16 -5
  18. python_yama-0.1.0/PKG-INFO +0 -98
  19. {python_yama-0.1.0 → python_yama-0.2.0}/.github/workflows/publish.yml +0 -0
  20. {python_yama-0.1.0 → python_yama-0.2.0}/.gitignore +0 -0
  21. {python_yama-0.1.0 → python_yama-0.2.0}/LICENSE +0 -0
  22. {python_yama-0.1.0 → python_yama-0.2.0}/pyproject.toml +0 -0
  23. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_assertions.py +0 -0
  24. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_cli_shim.py +0 -0
  25. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_main_entrypoint.py +0 -0
  26. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_public_api.py +0 -0
  27. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_report.py +0 -0
  28. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_skill_tool.py +0 -0
  29. {python_yama-0.1.0 → python_yama-0.2.0}/tests/test_workspace.py +0 -0
  30. {python_yama-0.1.0 → python_yama-0.2.0}/uv.lock +0 -0
  31. {python_yama-0.1.0 → python_yama-0.2.0}/yama/__init__.py +0 -0
  32. {python_yama-0.1.0 → python_yama-0.2.0}/yama/__main__.py +0 -0
  33. {python_yama-0.1.0 → python_yama-0.2.0}/yama/assertions.py +0 -0
  34. {python_yama-0.1.0 → python_yama-0.2.0}/yama/loaders/__init__.py +0 -0
  35. {python_yama-0.1.0 → python_yama-0.2.0}/yama/loaders/case.py +0 -0
  36. {python_yama-0.1.0 → python_yama-0.2.0}/yama/loaders/common.py +0 -0
  37. {python_yama-0.1.0 → python_yama-0.2.0}/yama/loaders/tool_loader.py +0 -0
  38. {python_yama-0.1.0 → python_yama-0.2.0}/yama/models.py +0 -0
  39. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/__init__.py +0 -0
  40. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/base.py +0 -0
  41. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/bash/__init__.py +0 -0
  42. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/bash/_cli_shim.py +0 -0
  43. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/bash/bash.py +0 -0
  44. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/skill/__init__.py +0 -0
  45. {python_yama-0.1.0 → python_yama-0.2.0}/yama/tools/skill/skill.py +0 -0
  46. {python_yama-0.1.0 → python_yama-0.2.0}/yama/workspace.py +0 -0
@@ -37,6 +37,11 @@ Reproducible evaluation runner for tool-using Agent skills (see `README.md` and
37
37
  default `.yama/runs` / `.yama/reports/latest.html` locations) changes.
38
38
  Everything beyond that basic subset only needs to stay in
39
39
  `docs/agent-skill-test-framework-design.md`.
40
+ - `README.zh-CN.md` is the Chinese mirror of `README.md` (English, also the
41
+ PyPI page). Whenever `README.md` changes, apply the same change to
42
+ `README.zh-CN.md` so the two never drift. Links inside `README.md` use
43
+ absolute GitHub URLs (relative links break on PyPI); `README.zh-CN.md` may
44
+ use relative links.
40
45
  - When changing the Case syntax (schema fields, mock/assertion/judge
41
46
  configuration, path conventions) or the Case execution logic (tool loop,
42
47
  step advancement, failure semantics, artifact layout, CLI behavior), update
@@ -59,6 +64,8 @@ Reproducible evaluation runner for tool-using Agent skills (see `README.md` and
59
64
 
60
65
  ## Releasing
61
66
 
67
+ - The PyPI distribution is named `python-yama` (PyPI rejects `yama` as too
68
+ similar to `yaml`); the import package and CLI command are still `yama`.
62
69
  - The package version is derived from git tags by `hatch-vcs`; there is no
63
70
  `version` field in `pyproject.toml` to edit. Untagged commits build as
64
71
  `+g<hash>` dev versions, which PyPI rejects, so only tagged commits can be
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-yama
3
+ Version: 0.2.0
4
+ Summary: A reproducible evaluation runner for tool-using Agent skills
5
+ Project-URL: Repository, https://github.com/world-sim-dev/yama
6
+ Project-URL: Issues, https://github.com/world-sim-dev/yama/issues
7
+ Author-email: Farmer Sun <podpodiumapp@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,evaluation,llm,skill,testing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: fastapi>=0.139.0
19
+ Requires-Dist: jinja2>=3.1
20
+ Requires-Dist: litellm<2.0,>=1.80
21
+ Requires-Dist: orjson>=3.11.9
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: rich<15.0,>=14.1
24
+ Description-Content-Type: text/markdown
25
+
26
+ # yama
27
+
28
+ English | [简体中文](https://github.com/world-sim-dev/yama/blob/main/README.zh-CN.md)
29
+
30
+ `yama` is a reproducible evaluation framework for tool-using Agent Skills: a declarative YAML Case describes what the model sees — the system prompt, Skill metadata, tool schemas, and multi-turn user messages — then yama calls the LLM through LiteLLM, drives the tool loop, and scores the transcript with deterministic hard checks or an LLM judge. See the [design doc](https://github.com/world-sim-dev/yama/blob/main/docs/agent-skill-test-framework-design.md) (Chinese) for the full Case schema and execution contract.
31
+
32
+ ## Quick start
33
+
34
+ ```bash
35
+ # Run from a plugin root; collects __evals__/cases/**/*.yaml by default
36
+ cd dingding-simple
37
+ OPENAI_API_KEY=... uv run --project yama yama
38
+
39
+ # Or, from a workspace root (a directory with yama.toml), pick a plugin by name
40
+ OPENAI_API_KEY=... uv run --project yama yama --plugin dingding-simple
41
+ ```
42
+
43
+ ## Writing a Case
44
+
45
+ A Case is a YAML file organized as `context` (what the model sees) → `mocks` (how tool calls get executed) → `steps` (user turns sent in order, each with assertions) → `outcome` (the pass bar for the whole Case):
46
+
47
+ ```yaml
48
+ context:
49
+ system_prompt: { default: true } # use SYSTEM.md from the plugin root
50
+
51
+ tools:
52
+ - file: tools/read-dsl.yaml # tool schema, resolved relative to __evals__
53
+
54
+ mocks:
55
+ tools:
56
+ read_dsl:
57
+ respond:
58
+ result:
59
+ visualStyle: { name: Vintage Film }
60
+
61
+ steps:
62
+ - id: request-directions
63
+ user: Give me a few creative directions
64
+ assert:
65
+ hard:
66
+ - tool_called: { name: read_dsl }
67
+ - assistant_contains: creative direction
68
+
69
+ outcome:
70
+ require:
71
+ hard_checks: all_pass
72
+ ```
73
+
74
+ - `context.tools` is the tool schema sent to the model; `mocks.tools` is how the runner responds when a tool call arrives. The two sets of tool names must match exactly.
75
+ - To use a Skill, declare it by name under `context.skills` and also declare `{builtin: skill}` under `context.tools`; the model reads the Skill body via `skill(name, file?)`.
76
+ - To simulate command-line tools, declare `{builtin: bash}` and configure per-command output under `mocks.cli`.
77
+ - `steps[].assert.hard` are deterministic checks (nine types, including `tool_called`, `tool_arguments`, and `assistant_contains`); add `assert.judge` for LLM scoring.
78
+
79
+ For the full schema (every Skill/tool/mock form, the `bash` sandbox, message injection, judge configuration, and more), see the [design doc](https://github.com/world-sim-dev/yama/blob/main/docs/agent-skill-test-framework-design.md) (Chinese).
80
+
81
+ ## CLI usage
82
+
83
+ ```bash
84
+ uv run --project yama yama --plugin dingding-simple --report
85
+ ```
86
+
87
+ | Flag | Meaning |
88
+ | --- | --- |
89
+ | `paths` (positional, repeatable) | Explicit Case YAML paths to run |
90
+ | `--plugin-root PATH` | Use the given path as the single plugin root |
91
+ | `--plugin NAME` (repeatable) | Select plugins by name from `yama.toml` |
92
+ | `--all-plugins` | Run every plugin configured in `yama.toml` |
93
+ | `--result-dir PATH` | Override the artifact root (default `<plugin_root>/.yama/runs`) |
94
+ | `--report [PATH]` | Also generate a single-file HTML report (default `.yama/reports/latest.html`) |
95
+ | `--list` | Only print the collected Cases, without running them |
96
+ | `--no-artifacts` | Write no artifact files at all |
97
+ | `--json` | Emit machine-readable JSON instead of Rich tables |
98
+
99
+ `--plugin`, `--all-plugins`, and `--plugin-root` are mutually exclusive. When none of them is given, yama walks up from the current directory to the nearest directory containing `yama.toml` and uses it as the workspace root (falling back to the cwd), running it as a single plugin root.
@@ -0,0 +1,74 @@
1
+ # yama
2
+
3
+ English | [简体中文](https://github.com/world-sim-dev/yama/blob/main/README.zh-CN.md)
4
+
5
+ `yama` is a reproducible evaluation framework for tool-using Agent Skills: a declarative YAML Case describes what the model sees — the system prompt, Skill metadata, tool schemas, and multi-turn user messages — then yama calls the LLM through LiteLLM, drives the tool loop, and scores the transcript with deterministic hard checks or an LLM judge. See the [design doc](https://github.com/world-sim-dev/yama/blob/main/docs/agent-skill-test-framework-design.md) (Chinese) for the full Case schema and execution contract.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # Run from a plugin root; collects __evals__/cases/**/*.yaml by default
11
+ cd dingding-simple
12
+ OPENAI_API_KEY=... uv run --project yama yama
13
+
14
+ # Or, from a workspace root (a directory with yama.toml), pick a plugin by name
15
+ OPENAI_API_KEY=... uv run --project yama yama --plugin dingding-simple
16
+ ```
17
+
18
+ ## Writing a Case
19
+
20
+ A Case is a YAML file organized as `context` (what the model sees) → `mocks` (how tool calls get executed) → `steps` (user turns sent in order, each with assertions) → `outcome` (the pass bar for the whole Case):
21
+
22
+ ```yaml
23
+ context:
24
+ system_prompt: { default: true } # use SYSTEM.md from the plugin root
25
+
26
+ tools:
27
+ - file: tools/read-dsl.yaml # tool schema, resolved relative to __evals__
28
+
29
+ mocks:
30
+ tools:
31
+ read_dsl:
32
+ respond:
33
+ result:
34
+ visualStyle: { name: Vintage Film }
35
+
36
+ steps:
37
+ - id: request-directions
38
+ user: Give me a few creative directions
39
+ assert:
40
+ hard:
41
+ - tool_called: { name: read_dsl }
42
+ - assistant_contains: creative direction
43
+
44
+ outcome:
45
+ require:
46
+ hard_checks: all_pass
47
+ ```
48
+
49
+ - `context.tools` is the tool schema sent to the model; `mocks.tools` is how the runner responds when a tool call arrives. The two sets of tool names must match exactly.
50
+ - To use a Skill, declare it by name under `context.skills` and also declare `{builtin: skill}` under `context.tools`; the model reads the Skill body via `skill(name, file?)`.
51
+ - To simulate command-line tools, declare `{builtin: bash}` and configure per-command output under `mocks.cli`.
52
+ - `steps[].assert.hard` are deterministic checks (nine types, including `tool_called`, `tool_arguments`, and `assistant_contains`); add `assert.judge` for LLM scoring.
53
+
54
+ For the full schema (every Skill/tool/mock form, the `bash` sandbox, message injection, judge configuration, and more), see the [design doc](https://github.com/world-sim-dev/yama/blob/main/docs/agent-skill-test-framework-design.md) (Chinese).
55
+
56
+ ## CLI usage
57
+
58
+ ```bash
59
+ uv run --project yama yama --plugin dingding-simple --report
60
+ ```
61
+
62
+ | Flag | Meaning |
63
+ | --- | --- |
64
+ | `paths` (positional, repeatable) | Explicit Case YAML paths to run |
65
+ | `--plugin-root PATH` | Use the given path as the single plugin root |
66
+ | `--plugin NAME` (repeatable) | Select plugins by name from `yama.toml` |
67
+ | `--all-plugins` | Run every plugin configured in `yama.toml` |
68
+ | `--result-dir PATH` | Override the artifact root (default `<plugin_root>/.yama/runs`) |
69
+ | `--report [PATH]` | Also generate a single-file HTML report (default `.yama/reports/latest.html`) |
70
+ | `--list` | Only print the collected Cases, without running them |
71
+ | `--no-artifacts` | Write no artifact files at all |
72
+ | `--json` | Emit machine-readable JSON instead of Rich tables |
73
+
74
+ `--plugin`, `--all-plugins`, and `--plugin-root` are mutually exclusive. When none of them is given, yama walks up from the current directory to the nearest directory containing `yama.toml` and uses it as the workspace root (falling back to the cwd), running it as a single plugin root.
@@ -1,5 +1,7 @@
1
1
  # yama
2
2
 
3
+ [English](README.md) | 简体中文
4
+
3
5
  `yama` 是一个可复现的 Agent Skill 评测框架:用声明式 YAML Case 描述模型看到的 System Prompt、Skill metadata、Tool Schema 与多轮 User Message,通过 LiteLLM 统一调用 LLM 并驱动 Tool Loop,再对 Transcript 做确定性 Hard Check 或 LLM Judge 评分。完整 Case Schema 与执行契约见[设计文档](docs/agent-skill-test-framework-design.md)。
4
6
 
5
7
  ## 快速开始
@@ -63,7 +65,6 @@ uv run --project yama yama --plugin dingding-simple --report
63
65
  | `--plugin-root PATH` | 以指定路径作为单一 Plugin Root |
64
66
  | `--plugin NAME`(可重复) | 按 `yama.toml` 中的 Plugin 名字选择 |
65
67
  | `--all-plugins` | 运行 `yama.toml` 中配置的全部 Plugin |
66
- | `--response-script PATH` | 用脚本化 LLM 响应回放,不调用真实模型 |
67
68
  | `--result-dir PATH` | 覆盖产物根目录(默认 `<plugin_root>/.yama/runs`) |
68
69
  | `--report [PATH]` | 额外生成单文件 HTML 报告(默认 `.yama/reports/latest.html`) |
69
70
  | `--list` | 只打印匹配到的 Case,不运行 |
@@ -141,6 +141,9 @@ cli.main
141
141
  `id(context.state)` 做 `ShellSession` 的 key)。
142
142
  - **`LLMClient`**:`LiteLLMClient` 走真实 Provider,`ScriptedLLMClient` 用于
143
143
  确定性测试/回放,两者共用同一套消息/工具转换逻辑 (`_litellm_call_arguments`)。
144
+ 脚本响应可带 `expect`(`tools`/`last_role`/`last_user_contains`)对当次请求做
145
+ 断言;请求没有工具时 litellm 参数会整体省略 `tools` 键,`expect: {tools: []}`
146
+ 按空列表匹配(Judge 请求正是这种无工具请求)。
144
147
 
145
148
  ## 4. Workspace 与 Plugin
146
149
 
@@ -214,7 +217,6 @@ yama --plugin-root ./dingding-simple
214
217
  | `--plugin-root PATH` | 以指定路径作为单一 Plugin Root |
215
218
  | `--plugin NAME`(可重复) | 按 `yama.toml` 中的 Plugin 名字选择,一次可传多次 |
216
219
  | `--all-plugins` | 运行 `yama.toml` 中配置的全部 Plugin |
217
- | `--response-script PATH` | 用 `ScriptedLLMClient` 回放一份确定性响应脚本,不调用真实模型 |
218
220
  | `--result-dir PATH` | 覆盖产物根目录(默认每个 Case 写到其所属 `plugin_root/.yama/runs`) |
219
221
  | `--report [PATH]` | 额外写一份单文件 HTML 报告;省略路径时默认写到 `<第一个 result_root 的上级>/reports/latest.html` |
220
222
  | `--list` | 只收集并打印 `case_key` 列表,不运行 |
@@ -1295,10 +1297,21 @@ mocks:
1295
1297
  持久 bash 子进程绑定。这个路径是 `plugin_root/case_key/run_index` 的纯函数,Python
1296
1298
  Handler 可以在物化发生之前就用包根导出的 `workspace_dir(context)`(定义在
1297
1299
  `tools/bash/fs.py`)预先计算它;首次物化时同一路径也会写入共享 state 的
1298
- `__fs__.workspace_dir`,供没有 `ToolMockContext` 的 Python Checker 读取。run 结束(成功、断言失败或异常)时框架在 `runner.py` 的
1299
- `finally` 块里保证调用 `cleanup_session` 杀掉这个进程,工作目录本身不自动清理,
1300
- 方便事后排查。因为是同一个真实进程贯穿整个 run,`cd`、`export`、写文件等状态天然
1301
- 延续到下一次 Tool Call,不需要额外的状态字典维护 cwd/环境。
1300
+ `__fs__.workspace_dir`,供没有 `ToolMockContext` 的 Python Checker 读取。
1301
+
1302
+ 物化时机以每 run 共享 state 里的 `__fs__` 标记为准,而不是"目录是否已存在"
1303
+ (`fs.py:_get_session`):run 的第一次 `bash` 调用总会执行 `materialize_workspace`
1304
+ (即使 Python Handler 已提前创建了该目录),把当前 Case 配置声明的 `files`、
1305
+ `.mockbin/` 假命令和编译后的 `mocks.cli` 规则文件全部写到最新状态;run 中途因超时/
1306
+ 会话死亡而重建 shell 会话时则不再重复物化,模型已写入的文件得以保留。此外
1307
+ `YamaRunner.run_case` 在一个 Case 的第一轮 run 开始前会整体删除
1308
+ `.yama/fs-runs/<sanitized-case-key>/`(`runner.py:_clean_case_dirs`)——run 编号每次
1309
+ 执行都从 1 重新计数,不先清掉上一次执行遗留的同名工作目录,本次执行就会复用过期的
1310
+ mock 脚本与旧文件,结果不可复现。run 结束(成功、断言失败或异常)时框架在
1311
+ `runner.py` 的 `finally` 块里保证调用 `cleanup_session` 杀掉持久 shell 进程,
1312
+ 工作目录本身保留到下一次执行该 Case 前才被清掉,方便事后排查。因为是同一个真实
1313
+ 进程贯穿整个 run,`cd`、`export`、写文件等状态天然延续到下一次 Tool Call,不需要
1314
+ 额外的状态字典维护 cwd/环境。
1302
1315
 
1303
1316
  **命令执行:持久 shell + marker 协议。** 每次 Tool Call 把命令包装成如下 payload
1304
1317
  写入这个 shell 的 stdin:
@@ -1919,7 +1932,11 @@ Registry 的执行顺序固定为:
1919
1932
  `request_index`、`call_index`、`handler_kind`(`"python"` 或 `"declarative"`)、
1920
1933
  `callable`(Python Handler 的点分路径,声明式 Mock 为 `None`)、`match_failed`、
1921
1934
  `arguments`、`result`、`is_error`、`duration_ms`、`state_diff`(`{op: add|remove|replace,
1922
- path, value|old}` 形式的差异列表)。Run 结束额外保存 `mock-state.json`;Python
1935
+ path, value|old}` 形式的差异列表)。`is_error` 的判定是语义化的
1936
+ (`mocks.py:result_is_error`,Mock Event 与 `ToolCallRecord.is_error` 共用同一函数):
1937
+ 结果携带**非 null** 的 `error` 字段,或是 bash 形状的 `{stdout, stderr, exit_code}`
1938
+ 信封且 `exit_code != 0` 时才算错误;成功结果里字面的 `error: null` 不算错误,
1939
+ `ToolMockError`/`match_failed` 路径则始终算错误。Run 结束额外保存 `mock-state.json`;Python
1923
1940
  Checker 从同一个最终 state 读取业务结果。Handler 源码本身不复制到 Judge 输入,
1924
1941
  也不发送给被测模型。
1925
1942
 
@@ -1981,7 +1998,7 @@ async def run_step(case, step, messages, registry, run_index, requests):
1981
1998
  context = ToolMockContext(..., call_index=registry.next_call_index(call.name))
1982
1999
  result = await registry.call(call.name, call.arguments, context)
1983
2000
  records.append(ToolCallRecord(..., call=call, result=result,
1984
- is_error=isinstance(result, dict) and "error" in result))
2001
+ is_error=result_is_error(result))) # 语义化判定,见第 9
1985
2002
  messages.append({
1986
2003
  "role": "tool",
1987
2004
  "tool_call_id": call.id,
@@ -2106,6 +2123,13 @@ class ToolCallRecord:
2106
2123
  └── result.json # RunResult.to_dict() + generated_at + traceback?
2107
2124
  ```
2108
2125
 
2126
+ 写产物前,`YamaRunner.run_case` 会先整体删除该 Case 的产物目录
2127
+ `<result_root>/<sanitize(case_key)>/`(`runner.py:_clean_case_dirs`,与 8.4.3 节的
2128
+ `.yama/fs-runs/<case>` 清理同一处):run 编号每次执行都从 1 重新计数,若本次
2129
+ `execution.repeat` 比上次小,不清理就会留下过期的 `run-002` 等目录,人工或脚本
2130
+ 扫描 `.yama/runs` 时会把旧执行误当成本次结果。`--no-artifacts` 时不写也不删任何
2131
+ 产物目录(用户显式选择不落盘,不应顺带删掉历史产物),但 fs-runs 工作目录照常清理。
2132
+
2109
2133
  `--report` 触发的单文件 HTML 报告(`report.py:write_html_report`)是 CLI 层面单独
2110
2134
  生成的汇总产物,**不属于**上面这份逐 Case/逐 run 的产物列表,也不是按 Plugin 拆分的
2111
2135
  多个文件——一次 CLI 调用只写一个 HTML 文件,汇总本次涉及的所有 Case(见第 13 节)。
@@ -2383,6 +2407,13 @@ Details
2383
2407
  `--json` 输出机器可读的 `[CaseResult.to_dict(), ...]` 数组;`--list` 只打印
2384
2408
  `case_key` 列表,不运行任何 Case。
2385
2409
 
2410
+ `cli.py:main` 的错误出口按层归类(退出码都是 2):`ConfigurationError`/
2411
+ `CollectionError`/`ValueError` 打印 `Collection error: ...`(Case 还没跑起来,
2412
+ 问题出在工作区配置或 Case 收集/解析);`LLMError` 单独打印 `LLM error: ...`——
2413
+ 此时 Case 往往已经收集甚至执行过(例如脚本回放响应未被消费、Provider 配置缺失),
2414
+ 把它标成 Collection error 会把排查引到错误的层级。run 内部的 LLM 异常仍由
2415
+ `YamaRunner._run_once` 捕获进 `RunResult.error`,不走这个出口。
2416
+
2386
2417
  一个 Plugin 的 Skill、Tool、Case、产物目录不会泄漏到另一个 Plugin——这一点由
2387
2418
  "每个 Case 归属唯一 Plugin Root、产物写在该 Plugin 自己的 `.yama/` 下"这一实现
2388
2419
  天然保证,不依赖某种显式的隔离机制。
@@ -105,23 +105,38 @@ outcome:
105
105
  完整 Schema(多模态消息、`message_inject`、`mocks.cli` 命令树、`mocks.fs` 沙箱、
106
106
  Python Handler / Checker、Judge 配置)见 [references/case-schema.md](references/case-schema.md)。
107
107
 
108
+ ## 安装
109
+
110
+ yama 在 PyPI 上的发行名是 `python-yama`(安装后的命令行入口仍叫 `yama`),要求
111
+ Python ≥ 3.12。推荐装成全局命令行工具:
112
+
113
+ ```bash
114
+ uv tool install python-yama # 或 pipx install python-yama / pip install python-yama
115
+ yama --help # 验证安装成功
116
+ ```
117
+
118
+ 已安装过的话,用 `uv tool upgrade python-yama` 升级到最新版。
119
+
108
120
  ## 运行
109
121
 
122
+ 以下命令假定 `yama` 已按上一节安装、可直接调用:
123
+
110
124
  ```bash
111
125
  # 在 Plugin 根目录直接运行(默认收集 __evals__/cases/**/*.yaml)
112
126
  cd <plugin-root>
113
- OPENAI_API_KEY=... uv run --project yama yama
127
+ OPENAI_API_KEY=... yama
114
128
 
115
129
  # 在 Workspace 根目录(存在 yama.toml)按名字选 Plugin,并生成 HTML 报告
116
- uv run --project yama yama --plugin <name> --report
130
+ yama --plugin <name> --report
117
131
 
118
132
  # 只跑一个 Case;只列出会收集到哪些 Case
119
- uv run --project yama yama __evals__/cases/foo.yaml
120
- uv run --project yama yama --list
133
+ yama __evals__/cases/foo.yaml
134
+ yama --list
121
135
  ```
122
136
 
123
- 退出码:`0` 全部通过;`1` 有 Case 失败;`2` Collection error 或没收集到任何 Case;
124
- `130` 手动中断。
137
+ 退出码:`0` 全部通过;`1` 有 Case 失败;`2` Collection error、LLM error
138
+ (stderr 分别打印 `Collection error: ...` / `LLM error: ...`)或没收集到任何
139
+ Case;`130` 手动中断。
125
140
 
126
141
  ## 排查不通过的用例
127
142
 
@@ -132,6 +147,7 @@ uv run --project yama yama --list
132
147
  | 现象 | 层级 | 去哪看 |
133
148
  | --- | --- | --- |
134
149
  | stderr 打印 `Collection error: ...`,退出码 2 | 用例根本没跑:YAML/Schema/路径/配置错误 | 错误消息本身,对照上面「硬规则」 |
150
+ | stderr 打印 `LLM error: ...`,退出码 2 | LLM 层错误(Provider 配置缺失、脚本回放响应未消费),Case 可能已经收集甚至执行过 | 错误消息本身;已写出的产物目录 |
135
151
  | 退出码 1,某 Run 标红且带 error | run 级基础设施失败(未 Mock 的 Tool 被调用、sequence 用尽、Step 超时、Handler 抛异常、LLM 请求失败) | `result.json` 的 `runs[].error` / `traceback` |
136
152
  | 退出码 1,某 Step 的 hard check 失败 | 断言失败(模型行为不符合预期,或断言写错) | 终端展开的 `expected/actual/reason`;`hard-checks.json` |
137
153
  | hard check 全过但 Case 失败 | Judge 不达标 | `judge-result.json` 各维度 `score/reason/evidence` |
@@ -235,7 +235,8 @@ mocks:
235
235
  ```
236
236
 
237
237
  - 工作目录:`<plugin_root>/.yama/fs-runs/<case-key>/run-00N/`,run 结束**不清理**,
238
- 供排查。持久 bash 会话贯穿整个 run,`cd`/`export`/写文件状态自然延续。
238
+ 供排查;下一次执行同一 Case 时才整体清掉重建(改 mock 配置后重跑不会复用旧
239
+ `.mockbin` 脚本)。持久 bash 会话贯穿整个 run,`cd`/`export`/写文件状态自然延续。
239
240
  - Python Handler 可用 `from yama import workspace_dir` 提前算出该路径
240
241
  (`workspace_dir(context)`,纯函数,物化前即可调用);首次 bash 调用后同一路径
241
242
  写入共享 state 的 `__fs__.workspace_dir`,供没有 context 的 Python Checker 读取。
@@ -14,13 +14,15 @@
14
14
  每次 run 写入 `<plugin_root>/.yama/runs/<目录名>/run-{NNN}/`,目录名 =
15
15
  `case_key` 把 `/` 换成 `-` 并去掉 `.yaml` 扩展名(如
16
16
  `__evals__/cases/image-led-mv.yaml` → `__evals__-cases-image-led-mv`)。
17
- `--result-dir` 可覆盖根目录;`--no-artifacts` 则完全不写。
17
+ `--result-dir` 可覆盖根目录;`--no-artifacts` 则完全不写(也不删历史产物)。
18
+ 每次执行开始时会先整体清掉该 Case 的产物目录再重写,所以目录下看到的
19
+ `run-00N` 一定全部来自最近一次执行,不会混入上一次执行的残留。
18
20
 
19
21
  | 文件 | 内容 | 什么时候看 |
20
22
  | --- | --- | --- |
21
23
  | `result.json` | run 是否通过、`error`/`traceback`、全量数据汇总 | **永远先看**:error 非空 = run 级错误 |
22
24
  | `hard-checks.json` | `{step_id: [每条断言的 type/passed/label/expected/actual/reason/detail]}` | Step 断言失败时 |
23
- | `mock-events.json` | 每次 Tool 调用:`tool/arguments/result/is_error/match_failed/state_diff/duration_ms` | 怀疑 Mock 匹配 / Tool 行为时 |
25
+ | `mock-events.json` | 每次 Tool 调用:`tool/arguments/result/is_error/match_failed/state_diff/duration_ms`。`is_error` 是语义判定:结果带**非 null** `error`,或 bash 信封 `exit_code != 0`;成功结果里的 `error: null` 不算错误 | 怀疑 Mock 匹配 / Tool 行为时 |
24
26
  | `mock-state.json` | run 结束时的最终共享 state | 有状态 Handler / Python Checker 断言失败时 |
25
27
  | `requests/request-00N.json` | 每次真实 LLM 调用的完整入参(messages/tools)与响应;请求失败时带 `error` 字段 | 怀疑「模型到底看到了什么」时 |
26
28
  | `transcript.json` | 每个 Step 结束后的完整对话快照(含注入消息、Tool Result) | 复盘模型行为时 |
@@ -34,7 +36,10 @@
34
36
 
35
37
  ## 第 1 层:Collection error(退出码 2)
36
38
 
37
- 用例在收集/解析阶段就被拒绝,一条也没运行。错误消息即答案,常见对照:
39
+ 用例在收集/解析阶段就被拒绝,一条也没运行。若 stderr 打印的是
40
+ `LLM error: ...`(退出码同样是 2),问题不在收集层而在 LLM 层
41
+ (如 Provider 配置缺失、脚本回放响应未消费),不要按下表排查。
42
+ 错误消息即答案,常见对照:
38
43
 
39
44
  | 错误消息(关键词) | 原因与修法 |
40
45
  | --- | --- |
@@ -130,6 +135,8 @@ Step 全部失败项。先回答一个问题:**是模型行为错了,还是
130
135
 
131
136
  - 沙箱工作目录保留在 `<plugin_root>/.yama/fs-runs/<case-key>/run-00N/`,run 结束
132
137
  不清理——直接进去看模型写了什么文件、`.mockbin/` 里编译出了哪些假命令。
138
+ 该目录在**下一次执行同一 Case 时**才被整体清掉重建,因此排查时看到的内容
139
+ 总是最近一次执行的;改了 `mocks.cli`/`mocks.fs` 配置后重跑不会复用旧脚本。
133
140
  - `command not found`(exit 127)三种可能:命令没在 `mocks.cli` 声明;模型用了
134
141
  绝对路径/带 `/` 的调用(绕过 PATH shadow,Mock 不生效,要求模型用裸命令名);
135
142
  命令名拼错。
@@ -137,6 +144,8 @@ Step 全部失败项。先回答一个问题:**是模型行为错了,还是
137
144
  「未声明命令一律 not found」对内建不成立。
138
145
  - 模型「没意识到」命令失败:发给模型的 Tool Result 只有 `stdout + stderr` 拼接文本,
139
146
  `exit_code` 不可见——mock 的报错信息要写进 `stderr` 文本才对模型有意义。
147
+ (框架侧则看得见:非零 `exit_code` 会让该调用在 `mock-events.json` 里标记
148
+ `is_error: true`,可用来快速过滤失败调用。)
140
149
  - 链式命令输出无人工换行(`a && b` 的两段 stdout 直接相连);需要分行就在每段
141
150
  `stdout` 末尾自带 `\n`。
142
151
  - `BASH_TIMEOUT`:单条命令超过 `timeout_seconds`(默认 30s),会话被重启且 cwd 重置。
@@ -146,8 +155,6 @@ Step 全部失败项。先回答一个问题:**是模型行为错了,还是
146
155
 
147
156
  ## 隔离变量的调试手段
148
157
 
149
- - 只跑出问题的 Case:`uv run --project yama yama __evals__/cases/foo.yaml`。
150
- - 怀疑框架/断言本身而非模型:`--response-script <path>` 用 `ScriptedLLMClient` 回放
151
- 固定 LLM 响应,让 run 完全确定,再单独验证断言与 Mock 行为。
158
+ - 只跑出问题的 Case:`yama __evals__/cases/foo.yaml`。
152
159
  - 结果不稳定:`execution.repeat` 调大看 `pass_rate`,每次 run 的产物目录独立
153
160
  (`run-001`、`run-002`…),可横向对比 transcript 找分叉点。
@@ -7,8 +7,10 @@ import os
7
7
  import tempfile
8
8
  import unittest
9
9
  from pathlib import Path
10
+ from unittest.mock import patch
10
11
 
11
12
  from yama.cli import DEFAULT_REPORT, _parser, main
13
+ from yama.llm import LLMError, ScriptedLLMClient
12
14
 
13
15
 
14
16
  def write_plugin(root: Path, *, case_names: list[str] | None = None) -> Path:
@@ -32,15 +34,14 @@ steps:
32
34
  return root
33
35
 
34
36
 
35
- def write_response_script(root: Path, *, count: int = 1) -> Path:
36
- fixtures_dir = root / "__evals__" / "fixtures"
37
- fixtures_dir.mkdir(parents=True, exist_ok=True)
38
- script_path = fixtures_dir / "responses.yaml"
39
- responses = "\n".join(
40
- [' - content: "hi there, friend!"' for _ in range(count)]
37
+ def scripted_llm(*, count: int = 1):
38
+ """Patch the CLI's LLM client with deterministic scripted responses."""
39
+ return patch(
40
+ "yama.cli.LiteLLMClient",
41
+ lambda: ScriptedLLMClient(
42
+ [{"content": "hi there, friend!"} for _ in range(count)]
43
+ ),
41
44
  )
42
- script_path.write_text(f"responses:\n{responses}\n", encoding="utf-8")
43
- return script_path
44
45
 
45
46
 
46
47
  @contextlib.contextmanager
@@ -60,7 +61,6 @@ class ParserTests(unittest.TestCase):
60
61
  self.assertIsNone(args.plugin_root)
61
62
  self.assertIsNone(args.plugin)
62
63
  self.assertFalse(args.all_plugins)
63
- self.assertIsNone(args.response_script)
64
64
  self.assertIsNone(args.result_dir)
65
65
  self.assertIsNone(args.report)
66
66
  self.assertFalse(args.list)
@@ -102,15 +102,12 @@ class MainEndToEndTests(unittest.TestCase):
102
102
  def test_successful_run_with_scripted_responses_and_json_output(self) -> None:
103
103
  with tempfile.TemporaryDirectory() as tmp:
104
104
  plugin_root = write_plugin(Path(tmp) / "plugin")
105
- script = write_response_script(plugin_root)
106
105
  stdout = io.StringIO()
107
- with contextlib.redirect_stdout(stdout):
106
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
108
107
  code = main(
109
108
  [
110
109
  "--plugin-root",
111
110
  str(plugin_root),
112
- "--response-script",
113
- str(script),
114
111
  "--no-artifacts",
115
112
  "--json",
116
113
  ]
@@ -123,15 +120,12 @@ class MainEndToEndTests(unittest.TestCase):
123
120
  def test_text_output_renders_results_table(self) -> None:
124
121
  with tempfile.TemporaryDirectory() as tmp:
125
122
  plugin_root = write_plugin(Path(tmp) / "plugin")
126
- script = write_response_script(plugin_root)
127
123
  stdout = io.StringIO()
128
- with contextlib.redirect_stdout(stdout):
124
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
129
125
  code = main(
130
126
  [
131
127
  "--plugin-root",
132
128
  str(plugin_root),
133
- "--response-script",
134
- str(script),
135
129
  "--no-artifacts",
136
130
  ]
137
131
  )
@@ -141,16 +135,13 @@ class MainEndToEndTests(unittest.TestCase):
141
135
  def test_report_flag_writes_html_report(self) -> None:
142
136
  with tempfile.TemporaryDirectory() as tmp:
143
137
  plugin_root = write_plugin(Path(tmp) / "plugin")
144
- script = write_response_script(plugin_root)
145
138
  report_path = Path(tmp) / "report.html"
146
139
  stdout = io.StringIO()
147
- with contextlib.redirect_stdout(stdout):
140
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
148
141
  code = main(
149
142
  [
150
143
  "--plugin-root",
151
144
  str(plugin_root),
152
- "--response-script",
153
- str(script),
154
145
  "--no-artifacts",
155
146
  "--report",
156
147
  str(report_path),
@@ -162,16 +153,13 @@ class MainEndToEndTests(unittest.TestCase):
162
153
  def test_default_report_path_is_derived_from_result_root(self) -> None:
163
154
  with tempfile.TemporaryDirectory() as tmp:
164
155
  plugin_root = write_plugin(Path(tmp) / "plugin")
165
- script = write_response_script(plugin_root)
166
156
  result_dir = Path(tmp) / "results"
167
157
  stdout = io.StringIO()
168
- with contextlib.redirect_stdout(stdout):
158
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
169
159
  code = main(
170
160
  [
171
161
  "--plugin-root",
172
162
  str(plugin_root),
173
- "--response-script",
174
- str(script),
175
163
  "--result-dir",
176
164
  str(result_dir),
177
165
  "--no-artifacts",
@@ -190,10 +178,24 @@ class MainEndToEndTests(unittest.TestCase):
190
178
  self.assertEqual(code, 2)
191
179
  self.assertIn("Collection error", stderr.getvalue())
192
180
 
181
+ def test_llm_error_is_reported_as_llm_error(self) -> None:
182
+ with tempfile.TemporaryDirectory() as tmp:
183
+ plugin_root = write_plugin(Path(tmp) / "plugin")
184
+ stderr = io.StringIO()
185
+
186
+ def raise_llm_error():
187
+ raise LLMError("scripted LLM has 1 unconsumed responses")
188
+
189
+ with patch("yama.cli.LiteLLMClient", raise_llm_error), \
190
+ contextlib.redirect_stderr(stderr):
191
+ code = main(["--plugin-root", str(plugin_root), "--no-artifacts"])
192
+ self.assertEqual(code, 2)
193
+ self.assertIn("LLM error", stderr.getvalue())
194
+ self.assertNotIn("Collection error", stderr.getvalue())
195
+
193
196
  def test_keyboard_interrupt_returns_130(self) -> None:
194
197
  with tempfile.TemporaryDirectory() as tmp:
195
198
  plugin_root = write_plugin(Path(tmp) / "plugin")
196
- from unittest.mock import patch
197
199
 
198
200
  def _raise(coro):
199
201
  coro.close()
@@ -206,14 +208,11 @@ class MainEndToEndTests(unittest.TestCase):
206
208
  def test_default_target_is_current_workspace_root(self) -> None:
207
209
  with tempfile.TemporaryDirectory() as tmp:
208
210
  plugin_root = write_plugin(Path(tmp) / "plugin")
209
- script = write_response_script(plugin_root)
210
211
  with chdir(plugin_root):
211
212
  stdout = io.StringIO()
212
- with contextlib.redirect_stdout(stdout):
213
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
213
214
  code = main(
214
215
  [
215
- "--response-script",
216
- str(script),
217
216
  "--no-artifacts",
218
217
  "--json",
219
218
  ]
@@ -225,19 +224,16 @@ class MainEndToEndTests(unittest.TestCase):
225
224
  def test_all_plugins_and_named_plugin_selection(self) -> None:
226
225
  with tempfile.TemporaryDirectory() as tmp:
227
226
  workspace_root = Path(tmp)
228
- plugin_root = write_plugin(workspace_root / "demo")
229
- script = write_response_script(plugin_root)
227
+ write_plugin(workspace_root / "demo")
230
228
  (workspace_root / "yama.toml").write_text(
231
229
  '[plugins.demo]\npath = "demo"\n', encoding="utf-8"
232
230
  )
233
231
  with chdir(workspace_root):
234
232
  stdout = io.StringIO()
235
- with contextlib.redirect_stdout(stdout):
233
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
236
234
  code = main(
237
235
  [
238
236
  "--all-plugins",
239
- "--response-script",
240
- str(script),
241
237
  "--no-artifacts",
242
238
  "--json",
243
239
  ]
@@ -247,13 +243,11 @@ class MainEndToEndTests(unittest.TestCase):
247
243
  self.assertEqual(len(payload), 1)
248
244
 
249
245
  stdout2 = io.StringIO()
250
- with contextlib.redirect_stdout(stdout2):
246
+ with scripted_llm(), contextlib.redirect_stdout(stdout2):
251
247
  code2 = main(
252
248
  [
253
249
  "--plugin",
254
250
  "demo",
255
- "--response-script",
256
- str(script),
257
251
  "--no-artifacts",
258
252
  "--json",
259
253
  ]
@@ -276,17 +270,14 @@ class MainEndToEndTests(unittest.TestCase):
276
270
  plugin_root = write_plugin(
277
271
  Path(tmp) / "plugin", case_names=["one", "two"]
278
272
  )
279
- script = write_response_script(plugin_root)
280
273
  only_one = plugin_root / "__evals__" / "cases" / "one.yaml"
281
274
  stdout = io.StringIO()
282
- with contextlib.redirect_stdout(stdout):
275
+ with scripted_llm(), contextlib.redirect_stdout(stdout):
283
276
  code = main(
284
277
  [
285
278
  str(only_one),
286
279
  "--plugin-root",
287
280
  str(plugin_root),
288
- "--response-script",
289
- str(script),
290
281
  "--no-artifacts",
291
282
  "--json",
292
283
  ]