agent-skill-eval 0.2.1__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.
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import time
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from skill_eval.models import AgentType, TimingData
12
+
13
+ DEFAULT_TIMEOUT_SECONDS = 600
14
+ DEFAULT_MAX_RETRIES = 1
15
+ RETRY_BACKOFF_SECONDS = 2.0
16
+
17
+
18
+ class AgentHarness(ABC):
19
+ agent_type: AgentType
20
+ # Environment variable each agent CLI reads its API base URL from.
21
+ base_url_env: Optional[str] = None
22
+
23
+ def __init__(
24
+ self,
25
+ workspace: Path,
26
+ model: Optional[str] = None,
27
+ base_url: Optional[str] = None,
28
+ timeout: Optional[int] = None,
29
+ max_retries: Optional[int] = None,
30
+ ):
31
+ self.workspace = workspace
32
+ self.model = model
33
+ self.base_url = base_url
34
+ self.timeout = timeout or int(os.environ.get("SKILL_EVAL_AGENT_TIMEOUT", DEFAULT_TIMEOUT_SECONDS))
35
+ if max_retries is None:
36
+ max_retries = int(os.environ.get("SKILL_EVAL_AGENT_RETRIES", DEFAULT_MAX_RETRIES))
37
+ self.max_retries = max_retries
38
+
39
+ @abstractmethod
40
+ def build_command(self, prompt: str, output_dir: Path) -> list[str]:
41
+ pass
42
+
43
+ @abstractmethod
44
+ def parse_output(self, stdout: str, stderr: str) -> tuple[str, TimingData]:
45
+ pass
46
+
47
+ def _build_env(self) -> Optional[dict[str, str]]:
48
+ if self.base_url and self.base_url_env:
49
+ env = dict(os.environ)
50
+ env[self.base_url_env] = self.base_url
51
+ return env
52
+ return None
53
+
54
+ def _workspace_fingerprint(self) -> Optional[tuple[str, str]]:
55
+ """(HEAD sha, porcelain status) of the workspace, or None outside git.
56
+
57
+ Used to refuse retries after a failed attempt that already mutated
58
+ the workspace — re-running there would grade the union of both
59
+ attempts' side effects.
60
+ """
61
+ try:
62
+ head = subprocess.run(
63
+ ["git", "rev-parse", "HEAD"],
64
+ cwd=self.workspace,
65
+ capture_output=True,
66
+ text=True,
67
+ encoding="utf-8",
68
+ timeout=15,
69
+ check=False,
70
+ )
71
+ status = subprocess.run(
72
+ ["git", "status", "--porcelain"],
73
+ cwd=self.workspace,
74
+ capture_output=True,
75
+ text=True,
76
+ encoding="utf-8",
77
+ timeout=15,
78
+ check=False,
79
+ )
80
+ except (subprocess.SubprocessError, OSError):
81
+ return None
82
+ if head.returncode != 0 or status.returncode != 0:
83
+ return None
84
+ return (head.stdout.strip(), status.stdout)
85
+
86
+ def run(self, prompt: str, output_dir: Path) -> tuple[str, TimingData, str, str]:
87
+ output_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ cmd = self.build_command(prompt, output_dir)
90
+ env = self._build_env()
91
+
92
+ attempts = self.max_retries + 1
93
+ stdout = ""
94
+ stderr = ""
95
+ exit_code: Optional[int] = None
96
+ timed_out = False
97
+ retries_used = 0
98
+ start = time.time()
99
+ fingerprint_before = self._workspace_fingerprint()
100
+
101
+ for attempt in range(attempts):
102
+ if attempt > 0:
103
+ retries_used = attempt
104
+ time.sleep(RETRY_BACKOFF_SECONDS * attempt)
105
+ try:
106
+ result = subprocess.run(
107
+ cmd,
108
+ cwd=self.workspace,
109
+ capture_output=True,
110
+ text=True,
111
+ encoding="utf-8",
112
+ timeout=self.timeout,
113
+ env=env,
114
+ )
115
+ stdout = result.stdout or ""
116
+ stderr = result.stderr or ""
117
+ exit_code = result.returncode
118
+ timed_out = False
119
+ if result.returncode == 0:
120
+ break
121
+ except subprocess.TimeoutExpired as e:
122
+ stdout = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or "")
123
+ stderr = (e.stderr or b"").decode() if isinstance(e.stderr, bytes) else (e.stderr or "")
124
+ stderr += f"\n[skill-eval] agent timed out after {self.timeout}s (attempt {attempt + 1}/{attempts})"
125
+ exit_code = None
126
+ timed_out = True
127
+
128
+ # Only retry into a pristine workspace: if the failed attempt
129
+ # already committed/wrote files, a second run would produce a
130
+ # graded union of both attempts.
131
+ if attempt < attempts - 1 and fingerprint_before is not None:
132
+ if self._workspace_fingerprint() != fingerprint_before:
133
+ stderr += "\n[skill-eval] not retrying: the failed attempt modified the workspace"
134
+ break
135
+
136
+ duration_ms = int((time.time() - start) * 1000)
137
+
138
+ final_output, timing = self.parse_output(stdout, stderr)
139
+ if timing.duration_ms == 0:
140
+ timing.duration_ms = duration_ms
141
+ timing.exit_code = exit_code
142
+ timing.timed_out = timed_out
143
+ timing.retries = retries_used
144
+
145
+ with open(output_dir / "stdout.log", "w", encoding="utf-8") as f:
146
+ f.write(stdout)
147
+ with open(output_dir / "stderr.log", "w", encoding="utf-8") as f:
148
+ f.write(stderr)
149
+
150
+ return final_output, timing, stdout, stderr
151
+
152
+
153
+ class OpenCodeHarness(AgentHarness):
154
+ agent_type = AgentType.OPENCODE
155
+ base_url_env = "OPENAI_BASE_URL"
156
+
157
+ def build_command(self, prompt: str, output_dir: Path) -> list[str]:
158
+ cmd = [
159
+ "opencode",
160
+ "run",
161
+ "--dir",
162
+ str(self.workspace),
163
+ "--format",
164
+ "json",
165
+ "--dangerously-skip-permissions",
166
+ ]
167
+ if self.model:
168
+ cmd.extend(["--model", self.model])
169
+ cmd.append(prompt)
170
+ return cmd
171
+
172
+ def parse_output(self, stdout: str, stderr: str) -> tuple[str, TimingData]:
173
+ timing = TimingData()
174
+ messages = []
175
+
176
+ for line in stdout.strip().split("\n"):
177
+ if not line.strip():
178
+ continue
179
+ try:
180
+ event = json.loads(line)
181
+ etype = event.get("type", "")
182
+
183
+ if etype == "text":
184
+ part = event.get("part", {})
185
+ text = part.get("text", "")
186
+ if text:
187
+ messages.append(text)
188
+
189
+ elif etype == "step_finish":
190
+ part = event.get("part", {})
191
+ tokens = part.get("tokens", {})
192
+ timing.input_tokens += tokens.get("input", 0)
193
+ timing.output_tokens += tokens.get("output", 0)
194
+ cache = tokens.get("cache", {})
195
+ timing.cached_tokens += cache.get("read", 0)
196
+
197
+ except json.JSONDecodeError:
198
+ continue
199
+
200
+ timing.total_tokens = timing.input_tokens + timing.output_tokens
201
+ return "\n".join(messages), timing
202
+
203
+
204
+ class ClaudeCodeHarness(AgentHarness):
205
+ agent_type = AgentType.CLAUDE_CODE
206
+ base_url_env = "ANTHROPIC_BASE_URL"
207
+
208
+ def build_command(self, prompt: str, output_dir: Path) -> list[str]:
209
+ cmd = [
210
+ "claude",
211
+ "-p",
212
+ "--output-format",
213
+ "json",
214
+ "--dangerously-skip-permissions",
215
+ ]
216
+ if self.model:
217
+ cmd.extend(["--model", self.model])
218
+ cmd.append(prompt)
219
+ return cmd
220
+
221
+ def parse_output(self, stdout: str, stderr: str) -> tuple[str, TimingData]:
222
+ timing = TimingData()
223
+ final_text = ""
224
+
225
+ try:
226
+ data = json.loads(stdout)
227
+ if isinstance(data, dict):
228
+ final_text = data.get("result", "")
229
+ usage = data.get("usage", {})
230
+ timing.input_tokens = usage.get("input_tokens", 0)
231
+ timing.output_tokens = usage.get("output_tokens", 0)
232
+ timing.cached_tokens = usage.get("cache_read_input_tokens", 0)
233
+ timing.total_tokens = timing.input_tokens + timing.output_tokens
234
+ duration_s = data.get("duration_ms", 0)
235
+ if duration_s:
236
+ timing.duration_ms = duration_s
237
+ except json.JSONDecodeError:
238
+ final_text = stdout
239
+
240
+ return final_text, timing
241
+
242
+
243
+ class CodexHarness(AgentHarness):
244
+ agent_type = AgentType.CODEX
245
+ base_url_env = "OPENAI_BASE_URL"
246
+
247
+ def build_command(self, prompt: str, output_dir: Path) -> list[str]:
248
+ cmd = [
249
+ "codex",
250
+ "exec",
251
+ "--json",
252
+ "--full-auto",
253
+ ]
254
+ if self.model:
255
+ cmd.extend(["--model", self.model])
256
+ cmd.append(prompt)
257
+ return cmd
258
+
259
+ def parse_output(self, stdout: str, stderr: str) -> tuple[str, TimingData]:
260
+ timing = TimingData()
261
+ messages = []
262
+
263
+ for line in stdout.strip().split("\n"):
264
+ if not line.strip():
265
+ continue
266
+ try:
267
+ event = json.loads(line)
268
+ etype = event.get("type", "")
269
+
270
+ if etype == "item.completed":
271
+ item = event.get("item", {})
272
+ if item.get("type") == "agent_message":
273
+ messages.append(item.get("text", ""))
274
+
275
+ elif etype == "turn.completed":
276
+ usage = event.get("usage", {})
277
+ timing.input_tokens += usage.get("input_tokens", 0)
278
+ timing.output_tokens += usage.get("output_tokens", 0)
279
+ timing.cached_tokens += usage.get("cached_input_tokens", 0)
280
+
281
+ except json.JSONDecodeError:
282
+ continue
283
+
284
+ timing.total_tokens = timing.input_tokens + timing.output_tokens
285
+ return "\n".join(messages), timing
286
+
287
+
288
+ class FakeHarness(AgentHarness):
289
+ agent_type = AgentType.FAKE
290
+
291
+ def build_command(self, prompt: str, output_dir: Path) -> list[str]:
292
+ return ["fake-agent", prompt]
293
+
294
+ def parse_output(self, stdout: str, stderr: str) -> tuple[str, TimingData]:
295
+ timing = TimingData(total_tokens=1, input_tokens=1, duration_ms=1)
296
+ return stdout, timing
297
+
298
+ def run(self, prompt: str, output_dir: Path) -> tuple[str, TimingData, str, str]:
299
+ output_dir.mkdir(parents=True, exist_ok=True)
300
+ skill_installed = any((self.workspace / ".fake" / "skills").glob("*/SKILL.md"))
301
+ if skill_installed and ("format" in prompt.lower() or "$format-json" in prompt.lower()):
302
+ final_output = '{\n "formatted": true,\n "status": "formatted-json-ok"\n}'
303
+ else:
304
+ final_output = "fake-agent baseline output: no formatting performed"
305
+
306
+ stdout = final_output
307
+ stderr = ""
308
+ timing = TimingData(
309
+ total_tokens=1, input_tokens=1, output_tokens=0, cached_tokens=0, duration_ms=1, exit_code=0
310
+ )
311
+
312
+ with open(output_dir / "stdout.log", "w", encoding="utf-8") as f:
313
+ f.write(stdout)
314
+ with open(output_dir / "stderr.log", "w", encoding="utf-8") as f:
315
+ f.write(stderr)
316
+
317
+ return final_output, timing, stdout, stderr
318
+
319
+
320
+ HARNESSES: dict[AgentType, type[AgentHarness]] = {
321
+ AgentType.OPENCODE: OpenCodeHarness,
322
+ AgentType.CLAUDE_CODE: ClaudeCodeHarness,
323
+ AgentType.CODEX: CodexHarness,
324
+ AgentType.FAKE: FakeHarness,
325
+ }
326
+
327
+
328
+ def get_harness(
329
+ agent_type: AgentType,
330
+ workspace: Path,
331
+ model: Optional[str] = None,
332
+ base_url: Optional[str] = None,
333
+ timeout: Optional[int] = None,
334
+ max_retries: Optional[int] = None,
335
+ ) -> AgentHarness:
336
+ cls = HARNESSES[agent_type]
337
+ return cls(workspace, model, base_url=base_url, timeout=timeout, max_retries=max_retries)
skill_eval/models.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+
10
+ class AgentType(str, Enum):
11
+ OPENCODE = "opencode"
12
+ CLAUDE_CODE = "claude-code"
13
+ CODEX = "codex"
14
+ FAKE = "fake"
15
+
16
+
17
+ class EvalCase(BaseModel):
18
+ id: int | str
19
+ prompt: str
20
+ expected_output: str
21
+ files: list[str] = Field(default_factory=list)
22
+ assertions: list[str] = Field(default_factory=list)
23
+ should_trigger: bool = True
24
+ force_skill_invocation: bool = False
25
+ stage_files: bool = False
26
+
27
+ @model_validator(mode="after")
28
+ def validate_contradiction(self) -> EvalCase:
29
+ if self.force_skill_invocation and not self.should_trigger:
30
+ raise ValueError("Contradiction: force_skill_invocation=True cannot be set when should_trigger=False.")
31
+ return self
32
+
33
+
34
+ class EvalSuite(BaseModel):
35
+ skill_name: str
36
+ evals: list[EvalCase]
37
+
38
+
39
+ class GitStateSnapshot(BaseModel):
40
+ local_branches: list[str] = Field(default_factory=list)
41
+ remote_branches: list[str] = Field(default_factory=list)
42
+ current_branch: str = ""
43
+ head_sha: str = ""
44
+ commit_count: int = 0
45
+ commits: list[str] = Field(default_factory=list)
46
+ remote_names: list[str] = Field(default_factory=list)
47
+ open_prs: list[dict] = Field(default_factory=list)
48
+ branch_heads: dict[str, str] = Field(default_factory=dict)
49
+ remote_branch_heads: dict[str, str] = Field(default_factory=dict)
50
+ commit_shas: list[str] = Field(default_factory=list)
51
+
52
+
53
+ class RunMeta(BaseModel):
54
+ eval_id: int | str
55
+ agent: str
56
+ with_skill: bool
57
+ iteration: int = 1
58
+ skill_name: str = ""
59
+ source_repo: Optional[str] = None
60
+ run_id: str = ""
61
+ run_index: int = 1
62
+
63
+
64
+ class CleanupManifest(BaseModel):
65
+ source_repo: Optional[str] = None
66
+ source_repo_slug: Optional[str] = None
67
+ remote_branches: list[str] = Field(default_factory=list)
68
+ pr_numbers: list[int] = Field(default_factory=list)
69
+ workspaces: list[str] = Field(default_factory=list)
70
+
71
+
72
+ class TimingData(BaseModel):
73
+ total_tokens: int = 0
74
+ input_tokens: int = 0
75
+ output_tokens: int = 0
76
+ cached_tokens: int = 0
77
+ duration_ms: int = 0
78
+ exit_code: Optional[int] = None
79
+ timed_out: bool = False
80
+ retries: int = 0
81
+
82
+
83
+ class AssertionResult(BaseModel):
84
+ text: str
85
+ passed: bool
86
+ evidence: str
87
+ method: str = "deterministic"
88
+ skipped: bool = False
89
+
90
+
91
+ class GradingResult(BaseModel):
92
+ assertion_results: list[AssertionResult]
93
+ summary: GradingSummary
94
+
95
+
96
+ class GradingSummary(BaseModel):
97
+ passed: int
98
+ failed: int
99
+ total: int
100
+ pass_rate: float
101
+ skipped: int = 0
102
+
103
+
104
+ GradingResult.model_rebuild()
105
+
106
+
107
+ class RunConfig(BaseModel):
108
+ skill_path: Path
109
+ evals_path: Path
110
+ workspace_path: Path
111
+ agents: list[AgentType]
112
+ concurrency: int = 1
113
+ with_baseline: bool = True
114
+ grader_model: str = "deepseek/deepseek-v4-flash"
115
+ grader_base_url: Optional[str] = None
116
+
117
+
118
+ class BenchmarkStats(BaseModel):
119
+ pass_rate: StatsPair
120
+ time_seconds: StatsPair
121
+ tokens: StatsPair
122
+ # Fraction of runs where ALL assertions passed (pass@1 estimate).
123
+ full_pass_rate: float = 0.0
124
+ # Fraction of evals where at least one of k runs fully passed.
125
+ pass_at_k: float = 0.0
126
+ k: int = 1
127
+
128
+
129
+ class StatsPair(BaseModel):
130
+ mean: float
131
+ stddev: float
132
+
133
+
134
+ class BenchmarkResult(BaseModel):
135
+ run_summary: dict[str, BenchmarkStats]
136
+ # Per-agent with_skill - without_skill deltas, keyed by agent value.
137
+ deltas: dict[str, DeltaStats] = Field(default_factory=dict)
138
+ # Kept for backward compatibility: equals the single agent's delta when
139
+ # exactly one agent was run, otherwise None.
140
+ delta: Optional[DeltaStats] = None
141
+
142
+
143
+ class DeltaStats(BaseModel):
144
+ pass_rate: float
145
+ time_seconds: float
146
+ tokens: float