skillproof 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.
- skillproof/__init__.py +3 -0
- skillproof/__main__.py +3 -0
- skillproof/assertions/__init__.py +62 -0
- skillproof/assertions/base.py +44 -0
- skillproof/assertions/builtin.py +176 -0
- skillproof/assertions/llm_judge.py +24 -0
- skillproof/cli.py +313 -0
- skillproof/config.py +188 -0
- skillproof/engine.py +268 -0
- skillproof/errors.py +72 -0
- skillproof/estimate.py +83 -0
- skillproof/judges/__init__.py +43 -0
- skillproof/judges/anthropic_judge.py +59 -0
- skillproof/judges/base.py +69 -0
- skillproof/judges/mock.py +21 -0
- skillproof/judges/openai_judge.py +58 -0
- skillproof/parsers/__init__.py +6 -0
- skillproof/parsers/claude_code.py +144 -0
- skillproof/parsers/codex.py +112 -0
- skillproof/report/__init__.py +34 -0
- skillproof/report/badge.py +60 -0
- skillproof/report/format.py +75 -0
- skillproof/report/html_report.py +296 -0
- skillproof/report/json_report.py +9 -0
- skillproof/report/markdown.py +78 -0
- skillproof/report/summary.py +216 -0
- skillproof/report/terminal.py +89 -0
- skillproof/results.py +88 -0
- skillproof/runners/__init__.py +33 -0
- skillproof/runners/base.py +102 -0
- skillproof/runners/claude_code.py +117 -0
- skillproof/runners/codex.py +133 -0
- skillproof/runners/mock.py +91 -0
- skillproof/runners/openai_compat.py +238 -0
- skillproof/sandbox.py +85 -0
- skillproof/scaffold.py +153 -0
- skillproof/skill.py +97 -0
- skillproof/stats.py +228 -0
- skillproof-0.1.0.dist-info/METADATA +262 -0
- skillproof-0.1.0.dist-info/RECORD +43 -0
- skillproof-0.1.0.dist-info/WHEEL +4 -0
- skillproof-0.1.0.dist-info/entry_points.txt +2 -0
- skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Claude as a judge (Anthropic Messages API, structured JSON output)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from skillproof.errors import JudgeNotReady
|
|
8
|
+
from skillproof.judges.base import (
|
|
9
|
+
VERDICT_SCHEMA,
|
|
10
|
+
Judge,
|
|
11
|
+
JudgeUnavailable,
|
|
12
|
+
Verdict,
|
|
13
|
+
judge_prompt,
|
|
14
|
+
parse_verdict,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "claude-opus-5-5"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AnthropicJudge(Judge):
|
|
21
|
+
name = "anthropic"
|
|
22
|
+
vendor = "anthropic"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, model: str | None = None, api_key_env: str | None = None, base_url: str | None = None
|
|
26
|
+
) -> None:
|
|
27
|
+
import anthropic
|
|
28
|
+
|
|
29
|
+
self.model = model or DEFAULT_MODEL
|
|
30
|
+
api_key = None
|
|
31
|
+
if api_key_env:
|
|
32
|
+
api_key = os.environ.get(api_key_env)
|
|
33
|
+
if not api_key:
|
|
34
|
+
raise JudgeNotReady(
|
|
35
|
+
f"Judge API key env var {api_key_env} is not set.",
|
|
36
|
+
hint=f"export {api_key_env}=... or change `judge.api_key_env`.",
|
|
37
|
+
)
|
|
38
|
+
self.client = anthropic.AsyncAnthropic(api_key=api_key, base_url=base_url)
|
|
39
|
+
|
|
40
|
+
async def grade(self, rubric: str, response: str) -> Verdict:
|
|
41
|
+
import anthropic
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
message = await self.client.messages.create(
|
|
45
|
+
model=self.model,
|
|
46
|
+
max_tokens=16000,
|
|
47
|
+
messages=[{"role": "user", "content": judge_prompt(rubric, response)}],
|
|
48
|
+
output_config={"format": {"type": "json_schema", "schema": VERDICT_SCHEMA}},
|
|
49
|
+
)
|
|
50
|
+
except anthropic.APIError as exc:
|
|
51
|
+
raise JudgeUnavailable(f"judge API error: {exc}") from exc
|
|
52
|
+
except TypeError as exc: # raised by the SDK when no credentials are found
|
|
53
|
+
raise JudgeUnavailable(
|
|
54
|
+
"no Anthropic credentials for the judge: export ANTHROPIC_API_KEY"
|
|
55
|
+
) from exc
|
|
56
|
+
if message.stop_reason == "refusal":
|
|
57
|
+
raise JudgeUnavailable("judge refused to grade this answer")
|
|
58
|
+
text = next((b.text for b in message.content if b.type == "text"), "")
|
|
59
|
+
return parse_verdict(text)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The Judge interface used by `llm_judge` assertions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Verdict(BaseModel):
|
|
12
|
+
passed: bool
|
|
13
|
+
reason: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JudgeUnavailable(Exception):
|
|
17
|
+
"""The judge itself failed (API error, refusal). The run is marked errored,
|
|
18
|
+
not failed, so judge outages don't skew pass rates."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Judge(ABC):
|
|
22
|
+
name: str
|
|
23
|
+
vendor: str
|
|
24
|
+
model: str | None = None
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def grade(self, rubric: str, response: str) -> Verdict: ...
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def label(self) -> str:
|
|
31
|
+
return f"{self.name}:{self.model}" if self.model else self.name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
JUDGE_PROMPT = """\
|
|
35
|
+
You are grading an AI agent's final answer against a rubric.
|
|
36
|
+
Be strict: the answer passes only if it clearly satisfies the rubric.
|
|
37
|
+
Judge only what is in the answer; do not give credit for intent.
|
|
38
|
+
|
|
39
|
+
<rubric>
|
|
40
|
+
{rubric}
|
|
41
|
+
</rubric>
|
|
42
|
+
|
|
43
|
+
<answer>
|
|
44
|
+
{response}
|
|
45
|
+
</answer>
|
|
46
|
+
|
|
47
|
+
Respond with JSON: {{"passed": true or false, "reason": "<one sentence>"}}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
VERDICT_SCHEMA = {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"passed": {"type": "boolean"},
|
|
54
|
+
"reason": {"type": "string"},
|
|
55
|
+
},
|
|
56
|
+
"required": ["passed", "reason"],
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def judge_prompt(rubric: str, response: str) -> str:
|
|
62
|
+
return JUDGE_PROMPT.format(rubric=rubric.strip(), response=response.strip() or "(empty)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_verdict(text: str) -> Verdict:
|
|
66
|
+
try:
|
|
67
|
+
return Verdict.model_validate(json.loads(text))
|
|
68
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
69
|
+
raise JudgeUnavailable(f"judge returned an unreadable verdict: {text[:200]!r}") from exc
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""A judge for tests: passes when every `pass_if` phrase appears in the answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skillproof.judges.base import Judge, Verdict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockJudge(Judge):
|
|
9
|
+
name = "mock"
|
|
10
|
+
vendor = "mock"
|
|
11
|
+
|
|
12
|
+
def __init__(self, pass_if: list[str] | None = None) -> None:
|
|
13
|
+
self.pass_if = pass_if or []
|
|
14
|
+
self.calls: list[tuple[str, str]] = []
|
|
15
|
+
|
|
16
|
+
async def grade(self, rubric: str, response: str) -> Verdict:
|
|
17
|
+
self.calls.append((rubric, response))
|
|
18
|
+
missing = [p for p in self.pass_if if p.lower() not in response.lower()]
|
|
19
|
+
if missing:
|
|
20
|
+
return Verdict(passed=False, reason=f"missing: {', '.join(missing)}")
|
|
21
|
+
return Verdict(passed=True, reason="mock judge: all phrases present")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""An OpenAI model as a judge (Responses API, structured JSON output)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from skillproof.errors import JudgeNotReady
|
|
8
|
+
from skillproof.judges.base import (
|
|
9
|
+
VERDICT_SCHEMA,
|
|
10
|
+
Judge,
|
|
11
|
+
JudgeUnavailable,
|
|
12
|
+
Verdict,
|
|
13
|
+
judge_prompt,
|
|
14
|
+
parse_verdict,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "gpt-6.1-sol"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenAIJudge(Judge):
|
|
21
|
+
name = "openai"
|
|
22
|
+
vendor = "openai"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, model: str | None = None, api_key_env: str | None = None, base_url: str | None = None
|
|
26
|
+
) -> None:
|
|
27
|
+
import openai
|
|
28
|
+
|
|
29
|
+
self.model = model or DEFAULT_MODEL
|
|
30
|
+
key_env = api_key_env or "OPENAI_API_KEY"
|
|
31
|
+
api_key = os.environ.get(key_env)
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise JudgeNotReady(
|
|
34
|
+
f"{key_env} is not set, and llm_judge needs it.",
|
|
35
|
+
hint=f"export {key_env}=..., or set `judge: {{provider: anthropic}}`.",
|
|
36
|
+
)
|
|
37
|
+
self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
|
|
38
|
+
|
|
39
|
+
async def grade(self, rubric: str, response: str) -> Verdict:
|
|
40
|
+
import openai
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
result = await self.client.responses.create(
|
|
44
|
+
model=self.model,
|
|
45
|
+
input=judge_prompt(rubric, response),
|
|
46
|
+
text={
|
|
47
|
+
"format": {
|
|
48
|
+
"type": "json_schema",
|
|
49
|
+
"name": "verdict",
|
|
50
|
+
"strict": True,
|
|
51
|
+
"schema": VERDICT_SCHEMA,
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
store=False,
|
|
55
|
+
)
|
|
56
|
+
except openai.APIError as exc:
|
|
57
|
+
raise JudgeUnavailable(f"judge API error: {exc}") from exc
|
|
58
|
+
return parse_verdict(result.output_text)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Transcript parsers: turn an agent's raw event log into an AgentRun.
|
|
2
|
+
|
|
3
|
+
Agents change their output formats often, so each parser is a set of small
|
|
4
|
+
pure functions over plain dicts (the same dicts we save as the transcript).
|
|
5
|
+
Tests feed them recorded transcripts; no API calls needed.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Parse a Claude Agent SDK message stream (as dicts) into an AgentRun.
|
|
2
|
+
|
|
3
|
+
Each message is saved as a dict with a `_type` key naming the SDK class
|
|
4
|
+
(`SystemMessage`, `AssistantMessage`, `UserMessage`, `ResultMessage`, ...).
|
|
5
|
+
Content blocks get the same treatment (`TextBlock`, `ToolUseBlock`, ...).
|
|
6
|
+
|
|
7
|
+
A skill invocation shows up as a `Skill` tool call:
|
|
8
|
+
{"_type": "ToolUseBlock", "name": "Skill", "input": {"skill": "my-skill"}}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from skillproof.runners.base import AgentRun, ToolCall, Usage
|
|
17
|
+
|
|
18
|
+
SKILL_TOOL = "Skill"
|
|
19
|
+
DROPPED_FIELDS = {"signature"} # opaque thinking signatures: large and useless for debugging
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_dict(obj: Any) -> Any:
|
|
23
|
+
"""Convert SDK dataclasses (recursively) into JSON-friendly dicts."""
|
|
24
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
25
|
+
out: dict[str, Any] = {"_type": type(obj).__name__}
|
|
26
|
+
for field in dataclasses.fields(obj):
|
|
27
|
+
if field.name not in DROPPED_FIELDS:
|
|
28
|
+
out[field.name] = to_dict(getattr(obj, field.name))
|
|
29
|
+
return out
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
return {str(k): to_dict(v) for k, v in obj.items()}
|
|
32
|
+
if isinstance(obj, (list, tuple)):
|
|
33
|
+
return [to_dict(v) for v in obj]
|
|
34
|
+
if obj is None or isinstance(obj, (str, int, float, bool)):
|
|
35
|
+
return obj
|
|
36
|
+
return str(obj)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
40
|
+
content = message.get("content")
|
|
41
|
+
return content if isinstance(content, list) else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _result_text(content: Any) -> str:
|
|
45
|
+
if isinstance(content, str):
|
|
46
|
+
return content
|
|
47
|
+
if isinstance(content, list):
|
|
48
|
+
return "\n".join(str(c.get("text", "")) for c in content if isinstance(c, dict))
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tool_calls(transcript: list[dict[str, Any]]) -> list[ToolCall]:
|
|
53
|
+
calls: dict[str, ToolCall] = {}
|
|
54
|
+
for message in transcript:
|
|
55
|
+
for block in _blocks(message):
|
|
56
|
+
if block.get("_type") == "ToolUseBlock":
|
|
57
|
+
calls[block["id"]] = ToolCall(name=block["name"], input=block.get("input") or {})
|
|
58
|
+
elif block.get("_type") == "ToolResultBlock" and block.get("tool_use_id") in calls:
|
|
59
|
+
calls[block["tool_use_id"]].output = _result_text(block.get("content"))
|
|
60
|
+
return list(calls.values())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_skill_call(call: ToolCall, skill_name: str) -> bool:
|
|
64
|
+
"""A Skill tool call naming this skill, or a direct read of its SKILL.md."""
|
|
65
|
+
if call.name == SKILL_TOOL:
|
|
66
|
+
invoked = str(call.input.get("skill", ""))
|
|
67
|
+
return invoked == skill_name or invoked.endswith(f":{skill_name}")
|
|
68
|
+
marker = f"skills/{skill_name}/SKILL.md"
|
|
69
|
+
return any(isinstance(v, str) and marker in v for v in call.input.values())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def init_data(transcript: list[dict[str, Any]]) -> dict[str, Any]:
|
|
73
|
+
for message in transcript:
|
|
74
|
+
if message.get("_type") == "SystemMessage" and message.get("subtype") == "init":
|
|
75
|
+
return message.get("data") or {}
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def final_text(transcript: list[dict[str, Any]], result: dict[str, Any] | None) -> str:
|
|
80
|
+
if result and result.get("result"):
|
|
81
|
+
return str(result["result"])
|
|
82
|
+
for message in reversed(transcript): # fall back to the last assistant text
|
|
83
|
+
if message.get("_type") == "AssistantMessage" and not message.get("parent_tool_use_id"):
|
|
84
|
+
text = "".join(b.get("text", "") for b in _blocks(message) if b["_type"] == "TextBlock")
|
|
85
|
+
if text:
|
|
86
|
+
return text
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_usage(result: dict[str, Any] | None) -> Usage:
|
|
91
|
+
usage = (result or {}).get("usage") or {}
|
|
92
|
+
return Usage(
|
|
93
|
+
input_tokens=usage.get("input_tokens") or 0,
|
|
94
|
+
output_tokens=usage.get("output_tokens") or 0,
|
|
95
|
+
cache_read_tokens=usage.get("cache_read_input_tokens") or 0,
|
|
96
|
+
cache_write_tokens=usage.get("cache_creation_input_tokens") or 0,
|
|
97
|
+
cost_usd=(result or {}).get("total_cost_usd"),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_error(result: dict[str, Any] | None) -> tuple[str | None, list[str]]:
|
|
102
|
+
"""Split failures into infrastructure errors and agent-side warnings."""
|
|
103
|
+
if result is None:
|
|
104
|
+
return "Claude Code ended without a result message", []
|
|
105
|
+
if not result.get("is_error"):
|
|
106
|
+
return None, []
|
|
107
|
+
subtype = result.get("subtype", "")
|
|
108
|
+
details = "; ".join(result.get("errors") or []) or str(result.get("result") or "")
|
|
109
|
+
if result.get("api_error_status"):
|
|
110
|
+
return f"API error {result['api_error_status']}: {details}".strip(": "), []
|
|
111
|
+
if subtype in ("error_max_turns", "error_max_budget_usd"):
|
|
112
|
+
# The agent ran out of its allowance: a real outcome, not an infrastructure error.
|
|
113
|
+
return None, [f"stopped early: {subtype}"]
|
|
114
|
+
return f"Claude Code error ({subtype}): {details}".strip(": "), []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_transcript(transcript: list[dict[str, Any]], skill_name: str | None) -> AgentRun:
|
|
118
|
+
result = next((m for m in reversed(transcript) if m.get("_type") == "ResultMessage"), None)
|
|
119
|
+
calls = tool_calls(transcript)
|
|
120
|
+
error, warnings = parse_error(result)
|
|
121
|
+
init = init_data(transcript)
|
|
122
|
+
|
|
123
|
+
triggered: bool | None = None
|
|
124
|
+
if skill_name is not None:
|
|
125
|
+
triggered = any(is_skill_call(c, skill_name) for c in calls)
|
|
126
|
+
listed = init.get("skills")
|
|
127
|
+
if isinstance(listed, list) and skill_name not in listed:
|
|
128
|
+
warnings.append(
|
|
129
|
+
f"skill {skill_name!r} was not listed at startup; "
|
|
130
|
+
"check SKILL.md frontmatter (or `user-invocable: false`)"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return AgentRun(
|
|
134
|
+
final_text=final_text(transcript, result),
|
|
135
|
+
transcript=transcript,
|
|
136
|
+
tool_calls=calls,
|
|
137
|
+
usage=parse_usage(result),
|
|
138
|
+
duration_s=((result or {}).get("duration_ms") or 0) / 1000,
|
|
139
|
+
num_turns=(result or {}).get("num_turns"),
|
|
140
|
+
model=init.get("model"),
|
|
141
|
+
triggered=triggered,
|
|
142
|
+
error=error,
|
|
143
|
+
warnings=warnings,
|
|
144
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Parse `codex exec --json` events (JSONL, one dict per line) into an AgentRun.
|
|
2
|
+
|
|
3
|
+
Event types: thread.started, turn.started, turn.completed {usage},
|
|
4
|
+
turn.failed {error}, item.started/updated/completed {item}, error {message}.
|
|
5
|
+
Item types we use: agent_message {text}, command_execution {command,
|
|
6
|
+
aggregated_output, exit_code}, file_change {changes}, mcp_tool_call, error.
|
|
7
|
+
|
|
8
|
+
Codex has no dedicated skill tool: the model reads SKILL.md (or runs a script
|
|
9
|
+
from the skill folder) with a shell command, so a skill counts as triggered
|
|
10
|
+
when a command touches `skills/<name>/`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from skillproof.runners.base import AgentRun, ToolCall, Usage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_jsonl(text: str) -> list[dict[str, Any]]:
|
|
22
|
+
events = []
|
|
23
|
+
for line in text.splitlines():
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
event = json.loads(line)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
continue # not an event (stray log line)
|
|
31
|
+
if isinstance(event, dict):
|
|
32
|
+
events.append(event)
|
|
33
|
+
return events
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def completed_items(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
37
|
+
return [e["item"] for e in events if e.get("type") == "item.completed" and "item" in e]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_tool_call(item: dict[str, Any]) -> ToolCall | None:
|
|
41
|
+
kind = item.get("type")
|
|
42
|
+
if kind == "command_execution":
|
|
43
|
+
return ToolCall(
|
|
44
|
+
name="command_execution",
|
|
45
|
+
input={"command": item.get("command", ""), "exit_code": item.get("exit_code")},
|
|
46
|
+
output=item.get("aggregated_output"),
|
|
47
|
+
)
|
|
48
|
+
if kind == "file_change":
|
|
49
|
+
return ToolCall(name="file_change", input={"changes": item.get("changes", [])})
|
|
50
|
+
if kind == "mcp_tool_call":
|
|
51
|
+
return ToolCall(
|
|
52
|
+
name=f"mcp:{item.get('server')}/{item.get('tool')}",
|
|
53
|
+
input={"arguments": item.get("arguments")},
|
|
54
|
+
output=json.dumps(item.get("result")) if item.get("result") else None,
|
|
55
|
+
)
|
|
56
|
+
if kind == "web_search":
|
|
57
|
+
return ToolCall(name="web_search", input={"query": item.get("query")})
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_skill_call(call: ToolCall, skill_name: str) -> bool:
|
|
62
|
+
return call.name == "command_execution" and f"skills/{skill_name}/" in str(
|
|
63
|
+
call.input.get("command", "")
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_usage(events: list[dict[str, Any]]) -> Usage:
|
|
68
|
+
"""Sum usage over all turns. Codex's input_tokens include cached tokens."""
|
|
69
|
+
usage = Usage()
|
|
70
|
+
for event in events:
|
|
71
|
+
if event.get("type") != "turn.completed":
|
|
72
|
+
continue
|
|
73
|
+
u = event.get("usage") or {}
|
|
74
|
+
cached = u.get("cached_input_tokens") or 0
|
|
75
|
+
written = u.get("cache_write_input_tokens") or 0
|
|
76
|
+
usage.input_tokens += max(0, (u.get("input_tokens") or 0) - cached - written)
|
|
77
|
+
usage.cache_read_tokens += cached
|
|
78
|
+
usage.cache_write_tokens += written
|
|
79
|
+
usage.output_tokens += u.get("output_tokens") or 0
|
|
80
|
+
return usage
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_events(events: list[dict[str, Any]], skill_name: str | None) -> AgentRun:
|
|
84
|
+
items = completed_items(events)
|
|
85
|
+
calls = [c for c in (to_tool_call(i) for i in items) if c is not None]
|
|
86
|
+
messages = [i.get("text", "") for i in items if i.get("type") == "agent_message"]
|
|
87
|
+
|
|
88
|
+
error = None
|
|
89
|
+
failed = [e for e in events if e.get("type") == "turn.failed"]
|
|
90
|
+
if failed:
|
|
91
|
+
error = str((failed[-1].get("error") or {}).get("message") or "turn failed")
|
|
92
|
+
elif not any(e.get("type") == "turn.completed" for e in events):
|
|
93
|
+
top_errors = [e.get("message") for e in events if e.get("type") == "error"]
|
|
94
|
+
error = str(top_errors[-1]) if top_errors else "codex exited without completing a turn"
|
|
95
|
+
|
|
96
|
+
# Non-fatal problems Codex reports as error items (e.g. unknown model metadata).
|
|
97
|
+
warnings = [str(i.get("message")) for i in items if i.get("type") == "error"]
|
|
98
|
+
|
|
99
|
+
triggered = None
|
|
100
|
+
if skill_name is not None:
|
|
101
|
+
triggered = any(is_skill_call(c, skill_name) for c in calls)
|
|
102
|
+
|
|
103
|
+
return AgentRun(
|
|
104
|
+
final_text=messages[-1] if messages else "",
|
|
105
|
+
transcript=events,
|
|
106
|
+
tool_calls=calls,
|
|
107
|
+
usage=parse_usage(events),
|
|
108
|
+
num_turns=sum(1 for e in events if e.get("type") == "turn.completed") or None,
|
|
109
|
+
triggered=triggered,
|
|
110
|
+
error=error,
|
|
111
|
+
warnings=warnings,
|
|
112
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Report renderers. Every format reads the same `Summary` (see summary.py)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from skillproof.report import html_report, json_report, markdown
|
|
8
|
+
from skillproof.report.summary import Summary, build_summary
|
|
9
|
+
from skillproof.results import SuiteResult
|
|
10
|
+
|
|
11
|
+
FORMATS = ("terminal", "md", "html", "json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_text(fmt: str, summary: Summary, suite: SuiteResult) -> str:
|
|
15
|
+
"""Render a file-style format (md, html, json) to a string."""
|
|
16
|
+
if fmt == "md":
|
|
17
|
+
return markdown.render(summary)
|
|
18
|
+
if fmt == "html":
|
|
19
|
+
return html_report.render(summary, suite)
|
|
20
|
+
if fmt == "json":
|
|
21
|
+
return json_report.render(summary)
|
|
22
|
+
raise ValueError(f"not a file format: {fmt}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_reports(suite: SuiteResult) -> Summary:
|
|
26
|
+
"""Save summary.json, report.md and report.html next to the transcripts."""
|
|
27
|
+
summary = build_summary(suite)
|
|
28
|
+
run_dir = Path(suite.run_dir)
|
|
29
|
+
for name, fmt in (("summary.json", "json"), ("report.md", "md"), ("report.html", "html")):
|
|
30
|
+
(run_dir / name).write_text(render_text(fmt, summary, suite), encoding="utf-8")
|
|
31
|
+
return summary
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["FORMATS", "Summary", "build_summary", "render_text", "write_reports"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""shields.io badges for a skill's README.
|
|
2
|
+
|
|
3
|
+
Static badge (markdown): [](link)
|
|
4
|
+
Endpoint JSON (--json): {"schemaVersion": 1, "label": ..., "message": ..., "color": ...}
|
|
5
|
+
Host the JSON (e.g. a gist, updated by CI) and point shields.io/endpoint at it
|
|
6
|
+
to get a badge that updates itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from skillproof.report.summary import Summary
|
|
15
|
+
|
|
16
|
+
LABEL = "skillproof"
|
|
17
|
+
HOMEPAGE = "https://github.com/akhilgarg07/skillproof"
|
|
18
|
+
COLORS = {
|
|
19
|
+
"helps": "brightgreen",
|
|
20
|
+
"hurts": "red",
|
|
21
|
+
"no clear effect": "lightgrey",
|
|
22
|
+
"too few runs": "lightgrey",
|
|
23
|
+
"not measured": "lightgrey",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def message(s: Summary) -> str:
|
|
28
|
+
if s.lift is None:
|
|
29
|
+
text = "lift not measured"
|
|
30
|
+
else:
|
|
31
|
+
text = f"{s.lift * 100:+.0f}% lift"
|
|
32
|
+
if s.verdict == "no clear effect":
|
|
33
|
+
text += " (n.s.)" # not significant: the 95% interval includes zero
|
|
34
|
+
elif s.verdict == "too few runs":
|
|
35
|
+
text += " (too few runs)"
|
|
36
|
+
if s.trigger_runs:
|
|
37
|
+
text += f" · {s.triggers}/{s.trigger_runs} triggered"
|
|
38
|
+
if s.approximate:
|
|
39
|
+
text += " · approx"
|
|
40
|
+
return text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _escape(text: str) -> str:
|
|
44
|
+
"""shields.io path escaping: '-' -> '--', '_' -> '__', then URL-encode."""
|
|
45
|
+
return quote(text.replace("-", "--").replace("_", "__"), safe="")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def badge_url(s: Summary) -> str:
|
|
49
|
+
return (
|
|
50
|
+
f"https://img.shields.io/badge/{_escape(LABEL)}-{_escape(message(s))}-{COLORS[s.verdict]}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def markdown(s: Summary, link: str = HOMEPAGE) -> str:
|
|
55
|
+
return f"[})]({link})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def endpoint_json(s: Summary) -> str:
|
|
59
|
+
data = {"schemaVersion": 1, "label": LABEL, "message": message(s), "color": COLORS[s.verdict]}
|
|
60
|
+
return json.dumps(data, ensure_ascii=False)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Number formatting shared by all report renderers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from skillproof.report.summary import Summary
|
|
9
|
+
|
|
10
|
+
DASH = "–"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pct(value: float | None) -> str:
|
|
14
|
+
return DASH if value is None else f"{value * 100:.0f}%"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def signed_pct(value: float | None) -> str:
|
|
18
|
+
"""+23% / −12% (typographic minus) / – for unknown."""
|
|
19
|
+
if value is None:
|
|
20
|
+
return DASH
|
|
21
|
+
text = f"{value * 100:+.0f}%"
|
|
22
|
+
return "±0%" if text in ("+0%", "-0%") else text.replace("-", "−")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def interval(ci: tuple[float, float] | None) -> str:
|
|
26
|
+
if ci is None:
|
|
27
|
+
return DASH
|
|
28
|
+
return f"{signed_pct(ci[0])} to {signed_pct(ci[1])}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fraction(num: int, den: int) -> str:
|
|
32
|
+
return DASH if den == 0 else f"{num}/{den}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def seconds(value: float | None) -> str:
|
|
36
|
+
if value is None:
|
|
37
|
+
return DASH
|
|
38
|
+
return f"{value:.1f}s" if value < 100 else f"{value:.0f}s"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tokens(value: float | None) -> str:
|
|
42
|
+
if value is None:
|
|
43
|
+
return DASH
|
|
44
|
+
return f"{value / 1000:.1f}k" if value >= 1000 else f"{value:.0f}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def usd(value: float | None) -> str:
|
|
48
|
+
if value is None:
|
|
49
|
+
return DASH
|
|
50
|
+
return f"${value:.3f}" if value < 1 else f"${value:.2f}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def summary_line(s: Summary) -> str:
|
|
54
|
+
"""skill lift: +23% (95% CI +5% to +41%) | triggers 9/10 | tokens −12% ..."""
|
|
55
|
+
parts = [f"skill lift: {signed_pct(s.lift)}"]
|
|
56
|
+
if s.lift_ci is not None:
|
|
57
|
+
parts[0] += f" (95% CI {interval(s.lift_ci)})"
|
|
58
|
+
parts.append(f"triggers {fraction(s.triggers, s.trigger_runs)}")
|
|
59
|
+
if s.false_trigger_runs:
|
|
60
|
+
parts.append(f"false triggers {fraction(s.false_triggers, s.false_trigger_runs)}")
|
|
61
|
+
parts.append(f"tokens {signed_pct(s.token_delta)}")
|
|
62
|
+
if s.cost_delta is not None:
|
|
63
|
+
parts.append(f"cost {signed_pct(s.cost_delta)}")
|
|
64
|
+
w, wo = s.with_skill.mean_duration_s, s.without_skill.mean_duration_s
|
|
65
|
+
parts.append(f"latency {signed_pct(s.latency_delta)} ({seconds(w)} vs {seconds(wo)})")
|
|
66
|
+
return " | ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
VERDICT_TEXT = {
|
|
70
|
+
"helps": "the skill helps",
|
|
71
|
+
"hurts": "the skill hurts",
|
|
72
|
+
"no clear effect": "no clear effect",
|
|
73
|
+
"too few runs": "too few runs for a verdict",
|
|
74
|
+
"not measured": "lift not measured",
|
|
75
|
+
}
|