skillbench-cli 0.1.0__py3-none-any.whl

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.
skillbench/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """skillbench: eval framework for agent skills."""
@@ -0,0 +1,250 @@
1
+ """Built-in assertions: parsing a test's `assert:` list and checking it against a finished run."""
2
+
3
+ import subprocess
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath, PureWindowsPath
7
+ from typing import Any
8
+
9
+ from skillbench.claude import SKILL_TOOL, final_text, walk_tool_calls
10
+
11
+ # How much of a failed command's output its reason quotes: enough to see the error, not a log.
12
+ _COMMAND_OUTPUT_TAIL_CHARS = 2000
13
+ _TOOL_ORDER_SHOWN_CALLS = 20
14
+
15
+
16
+ class InvalidAssertionError(Exception):
17
+ """An `assert:` entry is malformed; the message says what is wrong with it."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Assertion:
22
+ type: str
23
+ expected: Any
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RunFacts:
28
+ """What assertions are checked against."""
29
+
30
+ trace: dict[str, Any]
31
+ workspace: Path
32
+ prompt: str
33
+ loaded_skills: list[Any]
34
+
35
+ @property
36
+ def tool_names(self) -> list[str]:
37
+ """Every tool call's name in order, subagents included."""
38
+ return [call["name"] for call in walk_tool_calls(self.trace["items"])]
39
+
40
+ @property
41
+ def invoked_skills(self) -> list[Any]:
42
+ """The skill named by each Skill tool call, in order."""
43
+ return [
44
+ call["input"].get("skill") if isinstance(call["input"], dict) else None
45
+ for call in walk_tool_calls(self.trace["items"])
46
+ if call["name"] == SKILL_TOOL
47
+ ]
48
+
49
+ @property
50
+ def user_messages(self) -> list[str]:
51
+ """The prompt, then each answer sent."""
52
+ answers = [item["text"] for item in self.trace["items"] if item["kind"] == "user_message"]
53
+ return [self.prompt, *answers]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AssertionResult:
58
+ assertion: Assertion
59
+ passed: bool
60
+ reason: str
61
+
62
+ def to_json(self) -> dict[str, Any]:
63
+ return {
64
+ "type": self.assertion.type,
65
+ "expected": self.assertion.expected,
66
+ "passed": self.passed,
67
+ "reason": self.reason,
68
+ }
69
+
70
+
71
+ def parse_assertion(entry: Any) -> Assertion:
72
+ """One `assert:` list entry, e.g. `{"tool_not_called": "Write"}`."""
73
+ if not isinstance(entry, dict) or len(entry) != 1:
74
+ raise InvalidAssertionError(
75
+ f"must be a mapping with exactly one assertion type, got {entry!r}"
76
+ )
77
+ [(assertion_type, expected)] = entry.items()
78
+ if assertion_type not in _ASSERTION_TYPES:
79
+ known = ", ".join(_ASSERTION_TYPES)
80
+ raise InvalidAssertionError(
81
+ f"unknown assertion '{assertion_type}' (known assertions: {known})"
82
+ )
83
+ is_valid, description, _ = _ASSERTION_TYPES[assertion_type]
84
+ if not is_valid(expected):
85
+ raise InvalidAssertionError(f"'{assertion_type}' must be {description}, got {expected!r}")
86
+ return Assertion(type=assertion_type, expected=expected)
87
+
88
+
89
+ def evaluate_assertions(assertions: list[Assertion], facts: RunFacts) -> list[AssertionResult]:
90
+ results = []
91
+ for assertion in assertions:
92
+ _, _, check = _ASSERTION_TYPES[assertion.type]
93
+ passed, reason = check(assertion.expected, facts)
94
+ results.append(AssertionResult(assertion=assertion, passed=passed, reason=reason))
95
+ return results
96
+
97
+
98
+ # --- checks: each returns (passed, reason) -------------------------------------------------
99
+
100
+ _Check = Callable[[Any, RunFacts], tuple[bool, str]]
101
+
102
+
103
+ def _negated(check: _Check) -> _Check:
104
+ """The opposite check, with the same reason."""
105
+
106
+ def negated_check(expected: Any, facts: RunFacts) -> tuple[bool, str]:
107
+ passed, reason = check(expected, facts)
108
+ return not passed, reason
109
+
110
+ return negated_check
111
+
112
+
113
+ def _check_skill_invoked(skill_name: str, facts: RunFacts) -> tuple[bool, str]:
114
+ """Through the Skill tool, or by a `/skill` prompt or answer when Claude loaded it."""
115
+ count = sum(1 for name in facts.invoked_skills if _skill_name_matches(name, skill_name))
116
+ if count:
117
+ return True, f"{skill_name} was invoked {_times(count)}"
118
+ if any(_skill_name_matches(name, skill_name) for name in facts.loaded_skills):
119
+ for index, message in enumerate(facts.user_messages):
120
+ if _is_slash_command_for(message, skill_name):
121
+ by = "the prompt" if index == 0 else "a user message"
122
+ return True, f"{skill_name} was invoked by {by}"
123
+ others = ", ".join(str(name) for name in facts.invoked_skills) or "none"
124
+ return False, f"{skill_name} was never invoked (skills invoked: {others})"
125
+
126
+
127
+ def _skill_name_matches(actual: Any, expected: str) -> bool:
128
+ """`planner` matches both `planner` and a plugin's `my-plugin:planner`."""
129
+ if not isinstance(actual, str):
130
+ return False
131
+ return actual == expected or (":" not in expected and actual.rsplit(":", 1)[-1] == expected)
132
+
133
+
134
+ def _is_slash_command_for(message: str, skill_name: str) -> bool:
135
+ command = message.split(maxsplit=1)[0] if message.strip() else ""
136
+ return command.startswith("/") and _skill_name_matches(command[1:], skill_name)
137
+
138
+
139
+ def _check_tool_called(tool_name: str, facts: RunFacts) -> tuple[bool, str]:
140
+ count = facts.tool_names.count(tool_name)
141
+ if count == 0:
142
+ return False, f"{tool_name} was never called"
143
+ return True, f"{tool_name} was called {_times(count)}"
144
+
145
+
146
+ def _check_tool_order(expected: list[str], facts: RunFacts) -> tuple[bool, str]:
147
+ """The tools were called in this order; other calls may come in between."""
148
+ called = facts.tool_names
149
+ matched = 0
150
+ for name in called:
151
+ if matched < len(expected) and name == expected[matched]:
152
+ matched += 1
153
+ if matched == len(expected):
154
+ return True, f"called in order: {' -> '.join(expected)}"
155
+ shown = ", ".join(called[:_TOOL_ORDER_SHOWN_CALLS]) or "none"
156
+ if len(called) > _TOOL_ORDER_SHOWN_CALLS:
157
+ shown += f", ... ({len(called)} calls)"
158
+ after = " -> ".join(expected[:matched]) or "nothing"
159
+ return False, f"{expected[matched]} was not called after {after} (calls in order: {shown})"
160
+
161
+
162
+ def _check_file_contains(spec: dict[str, str], facts: RunFacts) -> tuple[bool, str]:
163
+ relative_path, text = spec["path"], spec["text"]
164
+ file_path = facts.workspace / relative_path
165
+ if not file_path.is_file():
166
+ return False, f"{relative_path} does not exist in the workspace"
167
+ if text in file_path.read_text(encoding="utf-8", errors="replace"):
168
+ return True, f"{relative_path} contains {text!r}"
169
+ return False, f"{relative_path} does not contain {text!r}"
170
+
171
+
172
+ def _check_final_text_contains(text: str, facts: RunFacts) -> tuple[bool, str]:
173
+ agent_final_text = final_text(facts.trace["items"])
174
+ if agent_final_text is None:
175
+ return False, "the agent produced no text"
176
+ if text in agent_final_text:
177
+ return True, f"final text contains {text!r}"
178
+ return False, f"final text does not contain {text!r} (final text: {agent_final_text!r})"
179
+
180
+
181
+ def _check_command_succeeds(command: str, facts: RunFacts) -> tuple[bool, str]:
182
+ """Runs through the platform shell: sh on macOS and Linux, cmd on Windows."""
183
+ completed = subprocess.run(
184
+ command,
185
+ shell=True,
186
+ cwd=facts.workspace,
187
+ stdin=subprocess.DEVNULL,
188
+ capture_output=True,
189
+ encoding="utf-8",
190
+ errors="replace",
191
+ )
192
+ reason = f"{command!r} exited with code {completed.returncode}"
193
+ if completed.returncode == 0:
194
+ return True, reason
195
+ output_tail = (completed.stdout + completed.stderr).strip()[-_COMMAND_OUTPUT_TAIL_CHARS:]
196
+ return False, f"{reason}; output: {output_tail!r}"
197
+
198
+
199
+ def _times(count: int) -> str:
200
+ return "1 time" if count == 1 else f"{count} times"
201
+
202
+
203
+ # --- validators for the expected value -----------------------------------------------------
204
+
205
+
206
+ def _is_non_empty_string(value: Any) -> bool:
207
+ return isinstance(value, str) and value != ""
208
+
209
+
210
+ def _is_non_empty_string_list(value: Any) -> bool:
211
+ return isinstance(value, list) and bool(value) and all(map(_is_non_empty_string, value))
212
+
213
+
214
+ def _is_workspace_path(value: Any) -> bool:
215
+ """A relative path that stays inside the workspace (checked lexically, before any run)."""
216
+ if not _is_non_empty_string(value):
217
+ return False
218
+ if PurePosixPath(value).is_absolute() or PureWindowsPath(value).anchor:
219
+ return False
220
+ depth = 0
221
+ for part in PureWindowsPath(value).parts:
222
+ depth += -1 if part == ".." else 0 if part == "." else 1
223
+ if depth < 0:
224
+ return False
225
+ return True
226
+
227
+
228
+ def _is_file_contains_spec(value: Any) -> bool:
229
+ return (
230
+ isinstance(value, dict)
231
+ and value.keys() == {"path", "text"}
232
+ and _is_workspace_path(value["path"])
233
+ and _is_non_empty_string(value["text"])
234
+ )
235
+
236
+
237
+ # assertion type -> (validator of the expected value, its description, check)
238
+ _ASSERTION_TYPES: dict[str, tuple[Callable[[Any], bool], str, _Check]] = {
239
+ "skill_invoked": (_is_non_empty_string, "a skill name", _check_skill_invoked),
240
+ "skill_not_invoked": (_is_non_empty_string, "a skill name", _negated(_check_skill_invoked)),
241
+ "tool_not_called": (_is_non_empty_string, "a tool name", _negated(_check_tool_called)),
242
+ "tool_order": (_is_non_empty_string_list, "a non-empty list of tool names", _check_tool_order),
243
+ "final_text_contains": (_is_non_empty_string, "a string", _check_final_text_contains),
244
+ "file_contains": (
245
+ _is_file_contains_spec,
246
+ "a mapping with 'path' (a relative path inside the workspace) and 'text'",
247
+ _check_file_contains,
248
+ ),
249
+ "command_succeeds": (_is_non_empty_string, "a command string", _check_command_succeeds),
250
+ }
skillbench/claude.py ADDED
@@ -0,0 +1,277 @@
1
+ """Claude Code: finding the CLI, the command line for each turn, and reading its `stream-json`
2
+ output into a trace.
3
+
4
+ A trace is plain JSON data:
5
+ {"items": [...], "usage": {...}, "cost_usd": float | None, "num_turns": int | None}
6
+ Each item is one of
7
+ {"kind": "assistant_text", "text": str}
8
+ {"kind": "tool_call", "id", "name", "input", "output", "ok", "children": [items]}
9
+ {"kind": "user_message", "turn": int, "text": str, "source": "scripted" | "live"}
10
+ {"kind": "unknown", "raw_type": str}
11
+ Items emitted inside a subagent go into the `children` of the tool call that spawned it. A
12
+ multi-turn run's trace is the turns' traces joined in order, each turn after the first preceded
13
+ by the user message that started it.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ SKILL_TOOL = "Skill"
26
+ _INSTALL_HINT = "install Claude Code: npm install -g @anthropic-ai/claude-code"
27
+ # A healthy CLI prints its version in well under this; a hung stub must not stall the run.
28
+ _VERSION_CHECK_TIMEOUT_S = 30
29
+ # Asking the user mid-turn would block a headless run; skillbench answers between turns instead.
30
+ _ALWAYS_DENIED_TOOLS = ("AskUserQuestion",)
31
+ _EMPTY_MCP_CONFIG = json.dumps({"mcpServers": {}})
32
+
33
+
34
+ class ClaudeNotFoundError(Exception):
35
+ """`claude` is not on PATH, or is installed but won't run."""
36
+
37
+
38
+ @dataclass
39
+ class TurnInfo:
40
+ """Everything recovered from one turn's output."""
41
+
42
+ trace: dict[str, Any]
43
+ session_id: str | None = None
44
+ reported_model: str | None = None
45
+ plugins: list[Any] = field(default_factory=list)
46
+ skills: list[Any] = field(default_factory=list)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class UserMessage:
51
+ """A message sent to the agent to start a follow-up turn."""
52
+
53
+ text: str
54
+ source: str # "scripted" (from the test's answers:) or "live" (typed in the terminal)
55
+
56
+
57
+ def find_claude() -> str:
58
+ """The path of a `claude` that answers `--version`. Raises ClaudeNotFoundError."""
59
+ executable = shutil.which("claude")
60
+ if executable is None:
61
+ raise ClaudeNotFoundError(f"claude is not on PATH ({_INSTALL_HINT})")
62
+ try:
63
+ check = subprocess.run(
64
+ [executable, "--version"],
65
+ stdin=subprocess.DEVNULL,
66
+ capture_output=True,
67
+ timeout=_VERSION_CHECK_TIMEOUT_S,
68
+ check=False,
69
+ )
70
+ except (OSError, subprocess.TimeoutExpired) as error:
71
+ raise ClaudeNotFoundError(f"{executable} does not run ({error})") from error
72
+ if check.returncode != 0:
73
+ raise ClaudeNotFoundError(f"{executable} --version failed ({_INSTALL_HINT})")
74
+ return executable
75
+
76
+
77
+ def turn_command(
78
+ executable: str,
79
+ message: str,
80
+ session_id: str | None,
81
+ model: str | None,
82
+ plugin_dirs: list[Path],
83
+ ) -> list[str]:
84
+ """The full command for one turn. The message comes first: it is positional, and the
85
+ list-valued flags (--disallowedTools, ...) would otherwise swallow it."""
86
+ command = [executable, "-p", message]
87
+ if session_id is not None:
88
+ command += ["--resume", session_id]
89
+ command += ["--output-format", "stream-json", "--verbose"]
90
+ command += ["--permission-mode", "bypassPermissions"]
91
+ command += ["--disallowedTools", *_ALWAYS_DENIED_TOOLS]
92
+ if model is not None:
93
+ command += ["--model", model]
94
+ command += _ignore_user_config_args()
95
+ for plugin_dir in plugin_dirs:
96
+ command += ["--plugin-dir", str(plugin_dir)]
97
+ return command
98
+
99
+
100
+ def _ignore_user_config_args() -> list[str]:
101
+ """--bare needs an API key; with a subscription login, keep only project settings and no
102
+ MCP servers instead."""
103
+ if os.environ.get("ANTHROPIC_API_KEY"):
104
+ return ["--bare"]
105
+ return [
106
+ "--setting-sources",
107
+ "project",
108
+ "--strict-mcp-config",
109
+ "--mcp-config",
110
+ _EMPTY_MCP_CONFIG,
111
+ ]
112
+
113
+
114
+ def parse_stream(raw_stdout: bytes) -> TurnInfo:
115
+ """Parse raw stream-json stdout. Never raises on odd input; odd lines become `unknown`."""
116
+ builder = _TraceBuilder()
117
+ for line in raw_stdout.decode("utf-8", errors="replace").splitlines():
118
+ if line.strip():
119
+ builder.add_line(line)
120
+ return builder.info
121
+
122
+
123
+ def empty_trace() -> dict[str, Any]:
124
+ return {"items": [], "usage": {}, "cost_usd": None, "num_turns": None}
125
+
126
+
127
+ def walk_tool_calls(items: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
128
+ """Every tool call in order, including those made inside subagents."""
129
+ for item in items:
130
+ if item["kind"] == "tool_call":
131
+ yield item
132
+ yield from walk_tool_calls(item["children"])
133
+
134
+
135
+ def final_text(items: list[dict[str, Any]]) -> str | None:
136
+ """The last top-level assistant text: what the agent ended on."""
137
+ texts = [item["text"] for item in items if item["kind"] == "assistant_text"]
138
+ return texts[-1] if texts else None
139
+
140
+
141
+ def merge_turns(turns: list[tuple[UserMessage | None, dict[str, Any]]]) -> dict[str, Any]:
142
+ """One trace for the whole conversation from each turn's (starting message, trace): items in
143
+ order, usage, cost and turn counts summed."""
144
+ items: list[dict[str, Any]] = []
145
+ for turn_number, (message, trace) in enumerate(turns, start=1):
146
+ if message is not None:
147
+ items.append(
148
+ {"kind": "user_message", "turn": turn_number, **vars(message)},
149
+ )
150
+ items += trace["items"]
151
+ traces = [trace for _, trace in turns]
152
+ return {
153
+ "items": items,
154
+ "usage": _sum_usage([trace["usage"] for trace in traces]),
155
+ "cost_usd": _sum_known([trace["cost_usd"] for trace in traces]),
156
+ "num_turns": _sum_known([trace["num_turns"] for trace in traces]),
157
+ }
158
+
159
+
160
+ def _sum_known(values: list[Any]) -> Any:
161
+ """Sum of the values that were reported; None if no turn reported one."""
162
+ known = [value for value in values if value is not None]
163
+ return sum(known) if known else None
164
+
165
+
166
+ def _sum_usage(usages: list[dict[str, Any]]) -> dict[str, Any]:
167
+ """Numeric counters add up across turns; anything else keeps the latest turn's value."""
168
+ total: dict[str, Any] = {}
169
+ for usage in usages:
170
+ for key, value in usage.items():
171
+ is_counter = isinstance(value, int | float) and not isinstance(value, bool)
172
+ if is_counter and isinstance(total.get(key, 0), int | float):
173
+ total[key] = total.get(key, 0) + value
174
+ else:
175
+ total[key] = value
176
+ return total
177
+
178
+
179
+ class _TraceBuilder:
180
+ """Builds a TurnInfo from stream-json events, nesting subagent events under their parent."""
181
+
182
+ def __init__(self) -> None:
183
+ self.info = TurnInfo(trace=empty_trace())
184
+ self._tool_calls_by_id: dict[str, dict[str, Any]] = {}
185
+
186
+ def add_line(self, line: str) -> None:
187
+ try:
188
+ event = json.loads(line)
189
+ except json.JSONDecodeError:
190
+ event = None
191
+ if isinstance(event, dict):
192
+ self._add_event(event)
193
+ else:
194
+ self._add_unknown(None, "unparseable")
195
+
196
+ def _add_event(self, event: dict[str, Any]) -> None:
197
+ event_type = event.get("type")
198
+ if event_type == "system" and event.get("subtype") == "init":
199
+ self._add_init(event)
200
+ elif event_type == "assistant":
201
+ self._add_assistant(event)
202
+ elif event_type == "user":
203
+ self._add_user(event)
204
+ elif event_type == "result":
205
+ self._add_result(event)
206
+ elif event_type == "system":
207
+ self._add_unknown(None, f"system.{event.get('subtype')}")
208
+ else:
209
+ self._add_unknown(None, str(event_type))
210
+
211
+ def _add_init(self, event: dict[str, Any]) -> None:
212
+ self.info.session_id = event.get("session_id")
213
+ self.info.reported_model = event.get("model")
214
+ self.info.plugins = event.get("plugins", [])
215
+ self.info.skills = event.get("skills", [])
216
+
217
+ def _add_assistant(self, event: dict[str, Any]) -> None:
218
+ parent_id = event.get("parent_tool_use_id")
219
+ for block in event.get("message", {}).get("content", []):
220
+ block_type = block.get("type")
221
+ if block_type == "text":
222
+ text_item = {"kind": "assistant_text", "text": block["text"]}
223
+ self._items_under(parent_id).append(text_item)
224
+ elif block_type == "tool_use":
225
+ self._add_tool_call(parent_id, block)
226
+ else:
227
+ self._add_unknown(parent_id, f"assistant.{block_type}")
228
+
229
+ def _add_tool_call(self, parent_id: str | None, block: dict[str, Any]) -> None:
230
+ tool_call = {
231
+ "kind": "tool_call",
232
+ "id": block["id"],
233
+ "name": block["name"],
234
+ "input": block.get("input", {}),
235
+ "output": None,
236
+ "ok": None,
237
+ "children": [],
238
+ }
239
+ self._tool_calls_by_id[block["id"]] = tool_call
240
+ self._items_under(parent_id).append(tool_call)
241
+
242
+ def _add_user(self, event: dict[str, Any]) -> None:
243
+ parent_id = event.get("parent_tool_use_id")
244
+ content = event.get("message", {}).get("content", [])
245
+ if isinstance(content, str):
246
+ self._add_unknown(parent_id, "user.text")
247
+ return
248
+ for block in content:
249
+ tool_call = self._tool_calls_by_id.get(block.get("tool_use_id", ""))
250
+ if block.get("type") == "tool_result" and tool_call is not None:
251
+ tool_call["output"] = _tool_result_text(block.get("content"))
252
+ tool_call["ok"] = not block.get("is_error", False)
253
+ else:
254
+ self._add_unknown(parent_id, f"user.{block.get('type')}")
255
+
256
+ def _add_result(self, event: dict[str, Any]) -> None:
257
+ self.info.session_id = event.get("session_id", self.info.session_id)
258
+ trace = self.info.trace
259
+ trace["usage"] = event.get("usage", {})
260
+ trace["cost_usd"] = event.get("total_cost_usd")
261
+ trace["num_turns"] = event.get("num_turns")
262
+
263
+ def _add_unknown(self, parent_id: str | None, raw_type: str) -> None:
264
+ self._items_under(parent_id).append({"kind": "unknown", "raw_type": raw_type})
265
+
266
+ def _items_under(self, parent_id: str | None) -> list[dict[str, Any]]:
267
+ parent = self._tool_calls_by_id.get(parent_id) if parent_id else None
268
+ return parent["children"] if parent is not None else self.info.trace["items"]
269
+
270
+
271
+ def _tool_result_text(content: Any) -> str:
272
+ if isinstance(content, str):
273
+ return content
274
+ is_all_text = isinstance(content, list) and all(
275
+ isinstance(block, dict) and block.get("type") == "text" for block in content
276
+ )
277
+ return "".join(block["text"] for block in content) if is_all_text else json.dumps(content)