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 +1 -0
- skillbench/assertions.py +250 -0
- skillbench/claude.py +277 -0
- skillbench/cli.py +323 -0
- skillbench/config.py +269 -0
- skillbench/dashboard.py +543 -0
- skillbench/progress.py +219 -0
- skillbench/runner.py +471 -0
- skillbench/scaffold.py +106 -0
- skillbench/skills.py +168 -0
- skillbench/workspace.py +124 -0
- skillbench_cli-0.1.0.dist-info/METADATA +218 -0
- skillbench_cli-0.1.0.dist-info/RECORD +16 -0
- skillbench_cli-0.1.0.dist-info/WHEEL +4 -0
- skillbench_cli-0.1.0.dist-info/entry_points.txt +2 -0
- skillbench_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
skillbench/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""skillbench: eval framework for agent skills."""
|
skillbench/assertions.py
ADDED
|
@@ -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)
|