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/progress.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""What the terminal shows while runs go: a live status with the current turn, the latest tool
|
|
2
|
+
call, elapsed time, an overall bar and an ETA; or, when output isn't a terminal, plain lines."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.progress_bar import ProgressBar
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
_RUN_RECORD_FILE_NAME = "run.json"
|
|
18
|
+
_REFRESHES_PER_SECOND = 4
|
|
19
|
+
# The bar never shows a run as done before it is, however long it overruns its estimate.
|
|
20
|
+
_MAX_RUN_FRACTION = 0.95
|
|
21
|
+
_BAR_WIDTH = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RunHeading:
|
|
26
|
+
"""What is about to run."""
|
|
27
|
+
|
|
28
|
+
index: int # 1-based position among all runs of this invocation
|
|
29
|
+
total: int
|
|
30
|
+
test_name: str
|
|
31
|
+
model: str | None
|
|
32
|
+
planned_turns: int | None # the prompt plus scripted answers; None if not known up front
|
|
33
|
+
repeat_index: int = 1
|
|
34
|
+
repeat_count: int = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Progress(Protocol):
|
|
38
|
+
def run_started(self, heading: RunHeading, eta: "Eta") -> None: ...
|
|
39
|
+
|
|
40
|
+
def activity(self, turn: int, last_tool: str | None) -> None: ...
|
|
41
|
+
|
|
42
|
+
def run_finished(self, line: str) -> None: ...
|
|
43
|
+
|
|
44
|
+
def pause(self) -> None: ...
|
|
45
|
+
|
|
46
|
+
def resume(self) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_history(runs_root: Path) -> dict[str, float]:
|
|
50
|
+
"""The most recent duration of each test recorded under runs_root."""
|
|
51
|
+
latest: dict[str, tuple[str, float]] = {}
|
|
52
|
+
if not runs_root.is_dir():
|
|
53
|
+
return {}
|
|
54
|
+
for record_path in runs_root.glob(f"*/{_RUN_RECORD_FILE_NAME}"):
|
|
55
|
+
try:
|
|
56
|
+
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
57
|
+
key = record["test_name"]
|
|
58
|
+
started, duration = record["start_time"], float(record["duration_s"])
|
|
59
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
60
|
+
continue
|
|
61
|
+
if duration <= 0 or record.get("stop_reason") == "setup_failed":
|
|
62
|
+
continue
|
|
63
|
+
if key not in latest or started > latest[key][0]:
|
|
64
|
+
latest[key] = (started, duration)
|
|
65
|
+
return {key: duration for key, (_, duration) in latest.items()}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Eta:
|
|
69
|
+
"""Estimates how long the rest of the invocation takes. Each run's estimate is its test's
|
|
70
|
+
last recorded duration, else the mean of runs finished so far; if any run
|
|
71
|
+
still has no estimate, there is no ETA rather than a made-up one."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, history: dict[str, float], run_keys: list[str]) -> None:
|
|
74
|
+
self._history = history
|
|
75
|
+
self._run_keys = run_keys
|
|
76
|
+
self._finished: list[float] = []
|
|
77
|
+
self._current = 0 # index into run_keys of the run in progress
|
|
78
|
+
|
|
79
|
+
def start_run(self, index: int) -> None:
|
|
80
|
+
self._current = index
|
|
81
|
+
|
|
82
|
+
def finish_run(self, duration_s: float) -> None:
|
|
83
|
+
self._finished.append(duration_s)
|
|
84
|
+
|
|
85
|
+
def estimate(self, key: str) -> float | None:
|
|
86
|
+
if key in self._history:
|
|
87
|
+
return self._history[key]
|
|
88
|
+
if self._finished:
|
|
89
|
+
return sum(self._finished) / len(self._finished)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def remaining_s(self, elapsed_s: float) -> float | None:
|
|
93
|
+
estimates = [self.estimate(key) for key in self._run_keys[self._current :]]
|
|
94
|
+
if any(estimate is None for estimate in estimates):
|
|
95
|
+
return None
|
|
96
|
+
current, *later = estimates
|
|
97
|
+
return max(current - elapsed_s, 0.0) + sum(later)
|
|
98
|
+
|
|
99
|
+
def fraction_done(self, elapsed_s: float) -> float:
|
|
100
|
+
if not self._run_keys:
|
|
101
|
+
return 1.0
|
|
102
|
+
estimate = self.estimate(self._run_keys[self._current])
|
|
103
|
+
current_fraction = min(elapsed_s / estimate, _MAX_RUN_FRACTION) if estimate else 0.0
|
|
104
|
+
return (self._current + current_fraction) / len(self._run_keys)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def format_seconds(seconds: float) -> str:
|
|
108
|
+
seconds = int(round(seconds))
|
|
109
|
+
if seconds < 60:
|
|
110
|
+
return f"{seconds}s"
|
|
111
|
+
minutes, seconds = divmod(seconds, 60)
|
|
112
|
+
if minutes < 60:
|
|
113
|
+
return f"{minutes}m {seconds:02d}s"
|
|
114
|
+
hours, minutes = divmod(minutes, 60)
|
|
115
|
+
return f"{hours}h {minutes:02d}m"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def describe_run(heading: RunHeading) -> str:
|
|
119
|
+
repeat = (
|
|
120
|
+
f" (repeat {heading.repeat_index}/{heading.repeat_count})"
|
|
121
|
+
if heading.repeat_count > 1
|
|
122
|
+
else ""
|
|
123
|
+
)
|
|
124
|
+
model = heading.model or "default model"
|
|
125
|
+
return f"[{heading.index}/{heading.total}] {heading.test_name}{repeat} {model}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PlainProgress:
|
|
129
|
+
"""For pipes and CI: one line when a run starts and one when it ends, no escape codes."""
|
|
130
|
+
|
|
131
|
+
def run_started(self, heading: RunHeading, eta: Eta) -> None:
|
|
132
|
+
print(describe_run(heading), flush=True)
|
|
133
|
+
|
|
134
|
+
def activity(self, turn: int, last_tool: str | None) -> None:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def run_finished(self, line: str) -> None:
|
|
138
|
+
print(line, flush=True)
|
|
139
|
+
|
|
140
|
+
def pause(self) -> None:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def resume(self) -> None:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class LiveProgress:
|
|
148
|
+
"""A status block redrawn in place while a run goes, replaced by its result line after."""
|
|
149
|
+
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
self._console = Console(highlight=False)
|
|
152
|
+
self._live: Live | None = None
|
|
153
|
+
self._heading = RunHeading(0, 0, "", None, None)
|
|
154
|
+
self._eta = Eta({}, [])
|
|
155
|
+
self._started = 0.0
|
|
156
|
+
self._turn = 1
|
|
157
|
+
self._last_tool: str | None = None
|
|
158
|
+
|
|
159
|
+
def run_started(self, heading: RunHeading, eta: Eta) -> None:
|
|
160
|
+
self._heading, self._eta = heading, eta
|
|
161
|
+
self._started = time.monotonic()
|
|
162
|
+
self._turn, self._last_tool = 1, None
|
|
163
|
+
self._live = Live(
|
|
164
|
+
get_renderable=self._render,
|
|
165
|
+
console=self._console,
|
|
166
|
+
refresh_per_second=_REFRESHES_PER_SECOND,
|
|
167
|
+
transient=True,
|
|
168
|
+
)
|
|
169
|
+
self._live.start()
|
|
170
|
+
|
|
171
|
+
def activity(self, turn: int, last_tool: str | None) -> None:
|
|
172
|
+
if turn != self._turn:
|
|
173
|
+
self._turn, self._last_tool = turn, None
|
|
174
|
+
self._last_tool = last_tool or self._last_tool
|
|
175
|
+
|
|
176
|
+
def run_finished(self, line: str) -> None:
|
|
177
|
+
self.pause()
|
|
178
|
+
self._live = None
|
|
179
|
+
self._console.print(line, markup=False)
|
|
180
|
+
|
|
181
|
+
def pause(self) -> None:
|
|
182
|
+
if self._live is not None:
|
|
183
|
+
self._live.stop()
|
|
184
|
+
|
|
185
|
+
def resume(self) -> None:
|
|
186
|
+
if self._live is not None:
|
|
187
|
+
self._live.start()
|
|
188
|
+
|
|
189
|
+
def _render(self) -> Group:
|
|
190
|
+
elapsed = time.monotonic() - self._started
|
|
191
|
+
return Group(
|
|
192
|
+
Text(describe_run(self._heading), style="bold"),
|
|
193
|
+
Text(self._activity_line(elapsed), style="dim"),
|
|
194
|
+
self._bar(elapsed),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _activity_line(self, elapsed: float) -> str:
|
|
198
|
+
planned = self._heading.planned_turns
|
|
199
|
+
turns = f"{self._turn}/{planned}" if planned else str(self._turn)
|
|
200
|
+
doing = self._last_tool or "thinking"
|
|
201
|
+
return f" turn {turns} · {doing} · {format_seconds(elapsed)} elapsed"
|
|
202
|
+
|
|
203
|
+
def _bar(self, elapsed: float) -> Table:
|
|
204
|
+
fraction = self._eta.fraction_done(elapsed)
|
|
205
|
+
remaining = self._eta.remaining_s(elapsed)
|
|
206
|
+
estimate = "" if remaining is None else f"est. {format_seconds(remaining)} left"
|
|
207
|
+
bar = Table.grid(padding=(0, 1))
|
|
208
|
+
bar.add_row(
|
|
209
|
+
Text(" "),
|
|
210
|
+
ProgressBar(total=1.0, completed=fraction, width=_BAR_WIDTH),
|
|
211
|
+
Text(f"{fraction:>4.0%}"),
|
|
212
|
+
Text(estimate),
|
|
213
|
+
)
|
|
214
|
+
return bar
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def make_progress() -> Progress:
|
|
218
|
+
"""Live in a terminal, plain lines otherwise."""
|
|
219
|
+
return LiveProgress() if sys.stdout.isatty() else PlainProgress()
|
skillbench/runner.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Running one test as a conversation with Claude Code and recording it under runs/<run-id>/."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field, replace
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from skillbench import claude, skills
|
|
18
|
+
from skillbench.assertions import Assertion, AssertionResult, RunFacts, evaluate_assertions
|
|
19
|
+
from skillbench.claude import TurnInfo, UserMessage
|
|
20
|
+
from skillbench.config import TestSpec
|
|
21
|
+
from skillbench.progress import Progress
|
|
22
|
+
from skillbench.workspace import FixtureError, create_workspace
|
|
23
|
+
|
|
24
|
+
_IS_WINDOWS = os.name == "nt"
|
|
25
|
+
_POLL_INTERVAL_S = 0.5
|
|
26
|
+
# Typed in place of a live answer, these end the conversation.
|
|
27
|
+
_END_COMMANDS = ("", "/done")
|
|
28
|
+
|
|
29
|
+
# Shows the agent's final text to the user and returns what they typed (None at end of input).
|
|
30
|
+
AskUser = Callable[[str], str | None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RunOptions:
|
|
35
|
+
"""Choices made per invocation rather than in the test file."""
|
|
36
|
+
|
|
37
|
+
executable: str # the claude CLI
|
|
38
|
+
eval_id: str # shared by every run of one invocation
|
|
39
|
+
model_override: str | None = None
|
|
40
|
+
ask_user: AskUser | None = None # None when there is no terminal to ask live answers in
|
|
41
|
+
progress: Progress | None = None # told about each turn's activity as it runs
|
|
42
|
+
|
|
43
|
+
def model_for(self, spec: TestSpec) -> str | None:
|
|
44
|
+
return self.model_override or spec.model
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_test(
|
|
48
|
+
spec: TestSpec, runs_root: Path, options: RunOptions, repeat_index: int = 1
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Run `spec` once, check its assertions, and write run.json and the raw output under
|
|
51
|
+
runs_root; return the record. A fixture or skill that can't be set up gives an `error`
|
|
52
|
+
record without launching the agent."""
|
|
53
|
+
run_dir = runs_root / _new_run_id(spec.name)
|
|
54
|
+
(run_dir / "raw").mkdir(parents=True)
|
|
55
|
+
record = {
|
|
56
|
+
"run_id": run_dir.name,
|
|
57
|
+
"eval_id": options.eval_id,
|
|
58
|
+
"backend": "claude",
|
|
59
|
+
**_run_context(spec, repeat_index),
|
|
60
|
+
}
|
|
61
|
+
setup_started = datetime.now().astimezone()
|
|
62
|
+
workspace = None
|
|
63
|
+
try:
|
|
64
|
+
workspace, record["fixture_commit"] = create_workspace(spec.fixture, spec.fixture_exclude)
|
|
65
|
+
spec = _prepare_for_workspace(spec, workspace)
|
|
66
|
+
except (FixtureError, skills.SkillNotFoundError) as error:
|
|
67
|
+
record |= _setup_failed_record(spec, options, workspace, setup_started, error)
|
|
68
|
+
else:
|
|
69
|
+
conversation = _converse(spec, options, workspace, run_dir / "raw")
|
|
70
|
+
record |= _conversation_record(spec, options, workspace, conversation)
|
|
71
|
+
(run_dir / "run.json").write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_context(spec: TestSpec, repeat_index: int) -> dict[str, Any]:
|
|
76
|
+
"""What the run was asked to do, recorded alongside what happened."""
|
|
77
|
+
return {
|
|
78
|
+
"kind": spec.kind,
|
|
79
|
+
"skill": spec.skill,
|
|
80
|
+
"trigger": None if spec.trigger is None else vars(spec.trigger),
|
|
81
|
+
"fixture": None if spec.fixture is None else str(spec.fixture),
|
|
82
|
+
"fixture_commit": None,
|
|
83
|
+
"repeat_index": repeat_index,
|
|
84
|
+
"repeat_count": spec.repeat,
|
|
85
|
+
"min_pass_rate": spec.min_pass_rate,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _prepare_for_workspace(spec: TestSpec, workspace: Path) -> TestSpec:
|
|
90
|
+
"""Load every plugin and skill the repo has, and turn `skill:` into the prompt that runs
|
|
91
|
+
it (or, for a trigger run, the trigger prompt and the check that the skill did or didn't
|
|
92
|
+
fire). Raises SkillNotFoundError."""
|
|
93
|
+
found = skills.discover(workspace)
|
|
94
|
+
skills.make_visible(workspace, found)
|
|
95
|
+
spec = replace(spec, plugin_dirs=found.plugin_dirs)
|
|
96
|
+
if spec.skill is None:
|
|
97
|
+
return spec
|
|
98
|
+
target = skills.resolve(workspace, found, spec.skill)
|
|
99
|
+
if spec.trigger is None:
|
|
100
|
+
return replace(spec, prompt=f"{target.invocation} {spec.skill_input}".strip())
|
|
101
|
+
check = "skill_invoked" if spec.trigger.should_invoke else "skill_not_invoked"
|
|
102
|
+
return replace(
|
|
103
|
+
spec,
|
|
104
|
+
prompt=spec.trigger.prompt,
|
|
105
|
+
answers=[],
|
|
106
|
+
is_interactive=False,
|
|
107
|
+
assertions=[Assertion(type=check, expected=target.name)],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- the conversation ----------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _StopReason(StrEnum):
|
|
115
|
+
"""Why a conversation ended; recorded in run.json as `stop_reason`."""
|
|
116
|
+
|
|
117
|
+
NO_MORE_ANSWERS = "no_more_answers"
|
|
118
|
+
TURN_FAILED = "turn_failed"
|
|
119
|
+
NEEDS_LIVE_ANSWER = "needs_live_answer"
|
|
120
|
+
NO_SESSION_ID = "no_session_id"
|
|
121
|
+
TRIGGER_DECIDED = "trigger_decided" # a trigger run stopped at the first skill call
|
|
122
|
+
SETUP_FAILED = "setup_failed" # the workspace or skill could not be prepared
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class _Stop:
|
|
127
|
+
reason: _StopReason
|
|
128
|
+
error: str | None = None # set when the stop makes the run an error
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class _Turn:
|
|
133
|
+
user_message: UserMessage | None # None for the first turn, which sends the test prompt
|
|
134
|
+
outcome: "_ProcessOutcome"
|
|
135
|
+
info: TurnInfo
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class _Conversation:
|
|
140
|
+
turns: list[_Turn] = field(default_factory=list)
|
|
141
|
+
stop: _Stop = _Stop(_StopReason.NO_MORE_ANSWERS)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def session_id(self) -> str | None:
|
|
145
|
+
reported = [turn.info.session_id for turn in self.turns if turn.info.session_id]
|
|
146
|
+
return reported[-1] if reported else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _LiveAnswerUnavailableError(Exception):
|
|
150
|
+
"""A live answer is needed but there is no terminal to ask in."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _converse(spec: TestSpec, options: RunOptions, workspace: Path, raw_dir: Path) -> _Conversation:
|
|
154
|
+
"""Send the prompt, then each answer as a follow-up turn in the same session, until the
|
|
155
|
+
answers run out or a turn fails."""
|
|
156
|
+
launcher = _TurnLauncher(spec, options, workspace, raw_dir)
|
|
157
|
+
answers = _AnswerSource(spec.answers, spec.is_interactive, options.ask_user)
|
|
158
|
+
conversation = _Conversation()
|
|
159
|
+
user_message: UserMessage | None = None
|
|
160
|
+
while True:
|
|
161
|
+
message = spec.prompt if user_message is None else user_message.text
|
|
162
|
+
turn_number = len(conversation.turns) + 1
|
|
163
|
+
outcome, info = launcher.run_turn(turn_number, message, conversation.session_id)
|
|
164
|
+
conversation.turns.append(_Turn(user_message, outcome, info))
|
|
165
|
+
stop = _stop_before_next_turn(conversation, answers)
|
|
166
|
+
if stop is not None:
|
|
167
|
+
conversation.stop = stop
|
|
168
|
+
return conversation
|
|
169
|
+
try:
|
|
170
|
+
user_message = answers.next_answer(claude.final_text(info.trace["items"]))
|
|
171
|
+
except _LiveAnswerUnavailableError as error:
|
|
172
|
+
conversation.stop = _Stop(_StopReason.NEEDS_LIVE_ANSWER, f"{spec.source_path}: {error}")
|
|
173
|
+
return conversation
|
|
174
|
+
if user_message is None:
|
|
175
|
+
return conversation
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _stop_before_next_turn(conversation: _Conversation, answers: "_AnswerSource") -> _Stop | None:
|
|
179
|
+
"""Why no further turn may follow the last one, or None if one may."""
|
|
180
|
+
last_outcome = conversation.turns[-1].outcome
|
|
181
|
+
if last_outcome.stopped_early:
|
|
182
|
+
return _Stop(_StopReason.TRIGGER_DECIDED)
|
|
183
|
+
if last_outcome.run_status != "completed":
|
|
184
|
+
return _Stop(_StopReason.TURN_FAILED)
|
|
185
|
+
if not answers.has_more:
|
|
186
|
+
return _Stop(_StopReason.NO_MORE_ANSWERS)
|
|
187
|
+
if conversation.session_id is None:
|
|
188
|
+
turn_count = len(conversation.turns)
|
|
189
|
+
error = f"turn {turn_count} reported no session id, so the next answer cannot resume it"
|
|
190
|
+
return _Stop(_StopReason.NO_SESSION_ID, error)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _AnswerSource:
|
|
195
|
+
"""Hands out scripted answers in order; once they run out, asks the user if the test is
|
|
196
|
+
interactive."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, scripted: list[str], is_interactive: bool, ask_user: AskUser | None):
|
|
199
|
+
self._scripted = list(scripted)
|
|
200
|
+
self._is_interactive = is_interactive
|
|
201
|
+
self._ask_user = ask_user
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def has_more(self) -> bool:
|
|
205
|
+
"""Whether the conversation would continue after this turn."""
|
|
206
|
+
return bool(self._scripted) or self._is_interactive
|
|
207
|
+
|
|
208
|
+
def next_answer(self, agent_final_text: str | None) -> UserMessage | None:
|
|
209
|
+
"""The next answer, or None when the conversation is over. Raises
|
|
210
|
+
_LiveAnswerUnavailableError if the user must be asked but cannot be."""
|
|
211
|
+
if self._scripted:
|
|
212
|
+
return UserMessage(text=self._scripted.pop(0), source="scripted")
|
|
213
|
+
if not self._is_interactive:
|
|
214
|
+
return None
|
|
215
|
+
if self._ask_user is None:
|
|
216
|
+
raise _LiveAnswerUnavailableError(
|
|
217
|
+
"the agent is waiting for an answer but stdin is not a terminal, "
|
|
218
|
+
"so scripted answers are required (add them under 'answers:')"
|
|
219
|
+
)
|
|
220
|
+
typed = self._ask_user(agent_final_text or "")
|
|
221
|
+
if typed is None or typed.strip() in _END_COMMANDS:
|
|
222
|
+
self._is_interactive = False
|
|
223
|
+
return None
|
|
224
|
+
return UserMessage(text=typed.strip(), source="live")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _TurnLauncher:
|
|
228
|
+
"""Launches each turn of one run: the same CLI, flags and workspace every time."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, spec: TestSpec, options: RunOptions, workspace: Path, raw_dir: Path) -> None:
|
|
231
|
+
self._spec = spec
|
|
232
|
+
self._options = options
|
|
233
|
+
self._workspace = workspace
|
|
234
|
+
self._raw_dir = raw_dir
|
|
235
|
+
|
|
236
|
+
def run_turn(
|
|
237
|
+
self, turn_number: int, message: str, session_id: str | None
|
|
238
|
+
) -> tuple["_ProcessOutcome", TurnInfo]:
|
|
239
|
+
command = claude.turn_command(
|
|
240
|
+
self._options.executable,
|
|
241
|
+
message,
|
|
242
|
+
session_id,
|
|
243
|
+
self._options.model_for(self._spec),
|
|
244
|
+
self._spec.plugin_dirs,
|
|
245
|
+
)
|
|
246
|
+
stdout_path = self._raw_dir / f"{turn_number}.jsonl"
|
|
247
|
+
watcher = _TurnWatcher(stdout_path, turn_number, self._options.progress)
|
|
248
|
+
outcome = _run_process(
|
|
249
|
+
command, self._workspace, stdout_path, self._spec.timeout_s, self._stop_check(watcher)
|
|
250
|
+
)
|
|
251
|
+
return outcome, claude.parse_stream(stdout_path.read_bytes())
|
|
252
|
+
|
|
253
|
+
def _stop_check(self, watcher: "_TurnWatcher") -> Callable[[], bool] | None:
|
|
254
|
+
"""What to check while the turn runs: a trigger run stops at its first skill call;
|
|
255
|
+
otherwise the watcher only reports progress, if anyone is listening."""
|
|
256
|
+
if self._spec.trigger is not None:
|
|
257
|
+
return watcher.has_invoked_a_skill
|
|
258
|
+
return watcher.report if self._options.progress is not None else None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class _TurnWatcher:
|
|
262
|
+
"""Re-reads a running turn's output whenever it grows, to report its latest tool call and
|
|
263
|
+
to see whether a skill has been invoked yet."""
|
|
264
|
+
|
|
265
|
+
def __init__(self, stdout_path: Path, turn_number: int, progress: Progress | None) -> None:
|
|
266
|
+
self._stdout_path = stdout_path
|
|
267
|
+
self._turn_number = turn_number
|
|
268
|
+
self._progress = progress
|
|
269
|
+
self._seen_size = -1
|
|
270
|
+
self._tool_names: list[str] = []
|
|
271
|
+
if progress is not None:
|
|
272
|
+
progress.activity(turn_number, None)
|
|
273
|
+
|
|
274
|
+
def report(self) -> bool:
|
|
275
|
+
"""Tell progress about the newest tool call; never asks for the turn to stop."""
|
|
276
|
+
self._refresh()
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
def has_invoked_a_skill(self) -> bool:
|
|
280
|
+
self._refresh()
|
|
281
|
+
return claude.SKILL_TOOL in self._tool_names
|
|
282
|
+
|
|
283
|
+
def _refresh(self) -> None:
|
|
284
|
+
try:
|
|
285
|
+
size = self._stdout_path.stat().st_size
|
|
286
|
+
except OSError:
|
|
287
|
+
return
|
|
288
|
+
if size == self._seen_size:
|
|
289
|
+
return
|
|
290
|
+
self._seen_size = size
|
|
291
|
+
trace = claude.parse_stream(self._stdout_path.read_bytes()).trace
|
|
292
|
+
self._tool_names = [call["name"] for call in claude.walk_tool_calls(trace["items"])]
|
|
293
|
+
if self._progress is not None:
|
|
294
|
+
last_tool = self._tool_names[-1] if self._tool_names else None
|
|
295
|
+
self._progress.activity(self._turn_number, last_tool)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --- the run record ------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _conversation_record(
|
|
302
|
+
spec: TestSpec, options: RunOptions, workspace: Path, conversation: _Conversation
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
first_turn, last_turn = conversation.turns[0], conversation.turns[-1]
|
|
305
|
+
trace = claude.merge_turns(
|
|
306
|
+
[(turn.user_message, turn.info.trace) for turn in conversation.turns]
|
|
307
|
+
)
|
|
308
|
+
facts = RunFacts(
|
|
309
|
+
trace=trace, workspace=workspace, prompt=spec.prompt, loaded_skills=first_turn.info.skills
|
|
310
|
+
)
|
|
311
|
+
assertion_results = evaluate_assertions(spec.assertions, facts)
|
|
312
|
+
run_status = last_turn.outcome.run_status
|
|
313
|
+
return {
|
|
314
|
+
"test_name": spec.name,
|
|
315
|
+
"test_file": str(spec.source_path),
|
|
316
|
+
"prompt": spec.prompt,
|
|
317
|
+
"model": options.model_for(spec) or first_turn.info.reported_model,
|
|
318
|
+
"start_time": first_turn.outcome.start_time.isoformat(),
|
|
319
|
+
"end_time": last_turn.outcome.end_time.isoformat(),
|
|
320
|
+
# Time the CLI spent running; excludes time spent waiting for live answers.
|
|
321
|
+
"duration_s": round(sum(turn.outcome.duration_s for turn in conversation.turns), 3),
|
|
322
|
+
"session_id": conversation.session_id,
|
|
323
|
+
"status": _test_status(run_status, conversation.stop.error, assertion_results),
|
|
324
|
+
"run_status": run_status,
|
|
325
|
+
"exit_code": last_turn.outcome.exit_code,
|
|
326
|
+
"conversation_turns": len(conversation.turns),
|
|
327
|
+
"stop_reason": conversation.stop.reason,
|
|
328
|
+
"error": conversation.stop.error,
|
|
329
|
+
"workspace": str(workspace),
|
|
330
|
+
"plugins": first_turn.info.plugins,
|
|
331
|
+
"skills": first_turn.info.skills,
|
|
332
|
+
"assertions": [result.to_json() for result in assertion_results],
|
|
333
|
+
"trace": trace,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _setup_failed_record(
|
|
338
|
+
spec: TestSpec,
|
|
339
|
+
options: RunOptions,
|
|
340
|
+
workspace: Path | None,
|
|
341
|
+
started: datetime,
|
|
342
|
+
error: Exception,
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""A run that never reached the agent: the fixture or skill couldn't be prepared."""
|
|
345
|
+
return {
|
|
346
|
+
"test_name": spec.name,
|
|
347
|
+
"test_file": str(spec.source_path),
|
|
348
|
+
"prompt": spec.prompt,
|
|
349
|
+
"model": options.model_for(spec),
|
|
350
|
+
"start_time": started.isoformat(),
|
|
351
|
+
"end_time": datetime.now().astimezone().isoformat(),
|
|
352
|
+
"duration_s": 0.0,
|
|
353
|
+
"session_id": None,
|
|
354
|
+
"status": "error",
|
|
355
|
+
"run_status": "not_started",
|
|
356
|
+
"exit_code": None,
|
|
357
|
+
"conversation_turns": 0,
|
|
358
|
+
"stop_reason": _StopReason.SETUP_FAILED,
|
|
359
|
+
"error": f"{spec.source_path}: {error}",
|
|
360
|
+
"workspace": None if workspace is None else str(workspace),
|
|
361
|
+
"plugins": [],
|
|
362
|
+
"skills": [],
|
|
363
|
+
"assertions": [],
|
|
364
|
+
"trace": claude.empty_trace(),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _test_status(
|
|
369
|
+
run_status: str, conversation_error: str | None, assertion_results: list[AssertionResult]
|
|
370
|
+
) -> str:
|
|
371
|
+
"""`error` if the CLI or the conversation did not finish cleanly, else `fail` if any
|
|
372
|
+
assertion failed."""
|
|
373
|
+
if run_status != "completed" or conversation_error is not None:
|
|
374
|
+
return "error"
|
|
375
|
+
return "pass" if all(result.passed for result in assertion_results) else "fail"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _new_run_id(test_name: str) -> str:
|
|
379
|
+
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", test_name).strip("-")[:60] or "test"
|
|
380
|
+
return f"{datetime.now():%Y%m%d-%H%M%S}-{safe_name}-{uuid.uuid4().hex[:6]}"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# --- the subprocess ------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass(frozen=True)
|
|
387
|
+
class _ProcessOutcome:
|
|
388
|
+
exit_code: int | None # None when the process was killed
|
|
389
|
+
timed_out: bool
|
|
390
|
+
start_time: datetime
|
|
391
|
+
end_time: datetime
|
|
392
|
+
duration_s: float
|
|
393
|
+
stopped_early: bool = False # killed on purpose once the run had what it needed
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def run_status(self) -> str:
|
|
397
|
+
if self.timed_out:
|
|
398
|
+
return "timeout"
|
|
399
|
+
return "completed" if self.stopped_early or self.exit_code == 0 else "error"
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass(frozen=True)
|
|
403
|
+
class _Ending:
|
|
404
|
+
exit_code: int | None = None
|
|
405
|
+
timed_out: bool = False
|
|
406
|
+
stopped_early: bool = False
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _run_process(
|
|
410
|
+
command: list[str],
|
|
411
|
+
cwd: Path,
|
|
412
|
+
stdout_path: Path,
|
|
413
|
+
timeout_s: float,
|
|
414
|
+
should_stop: Callable[[], bool] | None,
|
|
415
|
+
) -> _ProcessOutcome:
|
|
416
|
+
"""Run to completion or timeout, or until `should_stop` says so. Stdout streams straight to
|
|
417
|
+
disk so partial output survives and can be read while the turn runs."""
|
|
418
|
+
start_time = datetime.now().astimezone()
|
|
419
|
+
started = time.monotonic()
|
|
420
|
+
stderr_path = stdout_path.with_suffix(".stderr.txt")
|
|
421
|
+
with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file:
|
|
422
|
+
process = subprocess.Popen(
|
|
423
|
+
command,
|
|
424
|
+
cwd=cwd,
|
|
425
|
+
stdin=subprocess.DEVNULL,
|
|
426
|
+
stdout=stdout_file,
|
|
427
|
+
stderr=stderr_file,
|
|
428
|
+
start_new_session=not _IS_WINDOWS,
|
|
429
|
+
)
|
|
430
|
+
ending = _wait(process, started + timeout_s, should_stop)
|
|
431
|
+
if ending.exit_code is None:
|
|
432
|
+
_kill_process_tree(process)
|
|
433
|
+
return _ProcessOutcome(
|
|
434
|
+
exit_code=ending.exit_code,
|
|
435
|
+
timed_out=ending.timed_out,
|
|
436
|
+
start_time=start_time,
|
|
437
|
+
end_time=datetime.now().astimezone(),
|
|
438
|
+
duration_s=round(time.monotonic() - started, 3),
|
|
439
|
+
stopped_early=ending.stopped_early,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _wait(
|
|
444
|
+
process: subprocess.Popen[bytes], deadline: float, should_stop: Callable[[], bool] | None
|
|
445
|
+
) -> _Ending:
|
|
446
|
+
"""Wait for the process to exit, the deadline to pass, or `should_stop` to say so."""
|
|
447
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
448
|
+
wait_s = remaining if should_stop is None else min(_POLL_INTERVAL_S, remaining)
|
|
449
|
+
try:
|
|
450
|
+
return _Ending(exit_code=process.wait(timeout=wait_s))
|
|
451
|
+
except subprocess.TimeoutExpired:
|
|
452
|
+
if should_stop is not None and should_stop():
|
|
453
|
+
return _Ending(stopped_early=True)
|
|
454
|
+
return _Ending(timed_out=True)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _kill_process_tree(process: subprocess.Popen[bytes]) -> None:
|
|
458
|
+
"""The agent CLI spawns its own children (tools, subagents); kill all of them."""
|
|
459
|
+
if _IS_WINDOWS:
|
|
460
|
+
# taskkill fails if the process already exited; the poll below handles that case.
|
|
461
|
+
subprocess.run(
|
|
462
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
463
|
+
stdout=subprocess.DEVNULL,
|
|
464
|
+
stderr=subprocess.DEVNULL,
|
|
465
|
+
check=False,
|
|
466
|
+
)
|
|
467
|
+
if process.poll() is None:
|
|
468
|
+
process.kill()
|
|
469
|
+
else:
|
|
470
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
471
|
+
process.wait()
|