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
skillproof/results.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Result models. These are what gets saved to results.json and fed to reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from skillproof.assertions import AssertionResult
|
|
12
|
+
from skillproof.errors import SkillproofError
|
|
13
|
+
from skillproof.runners.base import AgentRun
|
|
14
|
+
|
|
15
|
+
Variant = Literal["with", "without"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TrialResult(BaseModel):
|
|
19
|
+
case: str
|
|
20
|
+
variant: Variant
|
|
21
|
+
trial: int # 0-based
|
|
22
|
+
run: AgentRun
|
|
23
|
+
assertions: list[AssertionResult] = Field(default_factory=list)
|
|
24
|
+
artifact: str | None = None # path of the saved transcript, relative to the run dir
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def errored(self) -> bool:
|
|
28
|
+
"""Infrastructure failure: excluded from pass rates, reported separately."""
|
|
29
|
+
return self.run.error is not None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def passed(self) -> bool:
|
|
33
|
+
return (
|
|
34
|
+
not self.errored and not self.run.timed_out and all(a.passed for a in self.assertions)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CaseResult(BaseModel):
|
|
39
|
+
name: str
|
|
40
|
+
should_trigger: bool = True
|
|
41
|
+
has_assertions: bool = True
|
|
42
|
+
trials: list[TrialResult] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def variant(self, variant: Variant) -> list[TrialResult]:
|
|
45
|
+
return [t for t in self.trials if t.variant == variant]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SuiteResult(BaseModel):
|
|
49
|
+
skill_name: str
|
|
50
|
+
skill_path: str
|
|
51
|
+
runner: str
|
|
52
|
+
approximate: bool = False # the runner only simulates skill loading
|
|
53
|
+
model: str | None = None
|
|
54
|
+
judge: str | None = None
|
|
55
|
+
started_at: datetime
|
|
56
|
+
finished_at: datetime | None = None
|
|
57
|
+
run_dir: str
|
|
58
|
+
complete: bool = True # False when the run was interrupted or stopped early
|
|
59
|
+
cases: list[CaseResult] = Field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
RESULTS_FILE = "results.json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_suite(path: Path) -> SuiteResult:
|
|
66
|
+
"""Load a saved run from its results.json or its run directory."""
|
|
67
|
+
file = path / RESULTS_FILE if path.is_dir() else path
|
|
68
|
+
if not file.is_file():
|
|
69
|
+
raise SkillproofError(
|
|
70
|
+
f"No {RESULTS_FILE} at {path}",
|
|
71
|
+
hint="Pass a run directory like .skillproof/runs/20261017-120000.",
|
|
72
|
+
)
|
|
73
|
+
return SuiteResult.model_validate_json(file.read_text(encoding="utf-8"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_latest_run(out_dir: Path, skill_path: Path | None = None) -> Path:
|
|
77
|
+
"""The newest run directory under <out_dir>/runs, optionally for one skill."""
|
|
78
|
+
runs = sorted((out_dir / "runs").glob(f"*/{RESULTS_FILE}"), reverse=True)
|
|
79
|
+
for results in runs:
|
|
80
|
+
if skill_path is None:
|
|
81
|
+
return results.parent
|
|
82
|
+
if Path(load_suite(results).skill_path) == skill_path.resolve():
|
|
83
|
+
return results.parent
|
|
84
|
+
where = f" for {skill_path}" if skill_path else ""
|
|
85
|
+
raise SkillproofError(
|
|
86
|
+
f"No skillproof runs found in {out_dir / 'runs'}{where}.",
|
|
87
|
+
hint="Run `skillproof run <skill>` first, or pass --out if you saved runs elsewhere.",
|
|
88
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Runner registry.
|
|
2
|
+
|
|
3
|
+
To add a runner: write a `Runner` subclass in this package and add one line to
|
|
4
|
+
`RUNNERS` below. Runners are imported lazily so that an optional SDK for one
|
|
5
|
+
agent is never required to use another.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from skillproof.runners.base import AgentRun, Runner, RunRequest, ToolCall, Usage
|
|
14
|
+
|
|
15
|
+
# runner name -> "module:ClassName"
|
|
16
|
+
RUNNERS: dict[str, str] = {
|
|
17
|
+
"claude-code": "skillproof.runners.claude_code:ClaudeCodeRunner",
|
|
18
|
+
"codex": "skillproof.runners.codex:CodexRunner",
|
|
19
|
+
"openai-compatible": "skillproof.runners.openai_compat:OpenAICompatibleRunner",
|
|
20
|
+
"mock": "skillproof.runners.mock:MockRunner",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_runner_class(name: str) -> type[Runner]:
|
|
25
|
+
module_name, class_name = RUNNERS[name].split(":")
|
|
26
|
+
return getattr(importlib.import_module(module_name), class_name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_runner(name: str, options: dict[str, Any] | None = None) -> Runner:
|
|
30
|
+
return get_runner_class(name)(options)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["RUNNERS", "AgentRun", "RunRequest", "Runner", "ToolCall", "Usage", "get_runner"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The Runner interface. Adding a new agent = one subclass of `Runner`.
|
|
2
|
+
|
|
3
|
+
A runner gets a `RunRequest` (prompt, a sandbox directory with fixtures already
|
|
4
|
+
copied in, and the skill or None for the baseline) and returns an `AgentRun`.
|
|
5
|
+
|
|
6
|
+
Rules every runner must follow:
|
|
7
|
+
* Only touch `request.workdir` (and temp dirs you create yourself).
|
|
8
|
+
* When `request.skill` is None the agent must NOT be able to see the skill,
|
|
9
|
+
including any copy installed in the user's home config.
|
|
10
|
+
* Fill `triggered` from the transcript if you can tell, else leave it None.
|
|
11
|
+
* Infrastructure failures (API down, CLI crashed) can be raised or put in
|
|
12
|
+
`error`; either way the run is reported as errored, not failed.
|
|
13
|
+
* The engine enforces the timeout by cancelling `run()`. If you start a
|
|
14
|
+
subprocess, kill it in a `finally:` block.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
from skillproof.skill import Skill
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolCall(BaseModel):
|
|
30
|
+
name: str
|
|
31
|
+
input: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
output: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Usage(BaseModel):
|
|
36
|
+
input_tokens: int = 0
|
|
37
|
+
output_tokens: int = 0
|
|
38
|
+
cache_read_tokens: int = 0
|
|
39
|
+
cache_write_tokens: int = 0
|
|
40
|
+
cost_usd: float | None = None # only when the agent reports it
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def total_tokens(self) -> int:
|
|
44
|
+
return (
|
|
45
|
+
self.input_tokens
|
|
46
|
+
+ self.output_tokens
|
|
47
|
+
+ self.cache_read_tokens
|
|
48
|
+
+ self.cache_write_tokens
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentRun(BaseModel):
|
|
53
|
+
"""Everything we captured from one agent run."""
|
|
54
|
+
|
|
55
|
+
final_text: str = ""
|
|
56
|
+
transcript: list[dict[str, Any]] = Field(default_factory=list) # raw, runner-specific
|
|
57
|
+
tool_calls: list[ToolCall] = Field(default_factory=list)
|
|
58
|
+
usage: Usage = Field(default_factory=Usage)
|
|
59
|
+
duration_s: float = 0.0
|
|
60
|
+
num_turns: int | None = None
|
|
61
|
+
model: str | None = None # the model that actually ran, when the agent reports it
|
|
62
|
+
triggered: bool | None = None # None = this runner can't tell
|
|
63
|
+
timed_out: bool = False
|
|
64
|
+
error: str | None = None # infrastructure error; the run is excluded from pass rates
|
|
65
|
+
warnings: list[str] = Field(default_factory=list) # e.g. "skill not listed at startup"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class RunRequest:
|
|
70
|
+
prompt: str
|
|
71
|
+
workdir: Path # fresh sandbox, fixtures already copied in
|
|
72
|
+
skill: Skill | None # None = baseline run
|
|
73
|
+
skill_exclude: list[Path] # pass to copy_skill() when installing the skill
|
|
74
|
+
model: str | None
|
|
75
|
+
timeout_s: float
|
|
76
|
+
case_name: str
|
|
77
|
+
trial: int # 0-based
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Runner(ABC):
|
|
81
|
+
name: ClassVar[str]
|
|
82
|
+
# Who makes the model. Used to pick a judge from a *different* vendor.
|
|
83
|
+
vendor: ClassVar[str]
|
|
84
|
+
# True when the runner only simulates skill loading (e.g. stuffing SKILL.md
|
|
85
|
+
# into the system prompt). Reports label these results as approximate.
|
|
86
|
+
approximate: ClassVar[bool] = False
|
|
87
|
+
# True when there is no sensible default model, so evals.yaml must set `model:`.
|
|
88
|
+
requires_model: ClassVar[bool] = False
|
|
89
|
+
|
|
90
|
+
class Options(BaseModel):
|
|
91
|
+
"""Runner-specific settings from `runner_options:` in evals.yaml."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="forbid")
|
|
94
|
+
|
|
95
|
+
def __init__(self, options: dict[str, Any] | None = None) -> None:
|
|
96
|
+
self.options = self.Options.model_validate(options or {})
|
|
97
|
+
|
|
98
|
+
def check_ready(self) -> None: # noqa: B027 - optional hook, no-op by default
|
|
99
|
+
"""Raise RunnerNotReady (with a hint) if the runner can't start."""
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def run(self, request: RunRequest) -> AgentRun: ...
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Claude Code runner, via the Claude Agent SDK (`claude-agent-sdk` on PyPI).
|
|
2
|
+
|
|
3
|
+
Isolation:
|
|
4
|
+
* The agent runs in the sandbox (`cwd`), with only *project* settings loaded
|
|
5
|
+
(`setting_sources=["project"]`). Your personal ~/.claude skills, CLAUDE.md,
|
|
6
|
+
settings and plugins are not loaded, for both variants.
|
|
7
|
+
* With the skill: it is copied to `<sandbox>/.claude/skills/<name>/` and is
|
|
8
|
+
the only skill the model may invoke (`skills=[name]`).
|
|
9
|
+
* Baseline: nothing is installed and no skill may be invoked (`skills=[]`).
|
|
10
|
+
* Sessions are not saved to ~/.claude (`--no-session-persistence`).
|
|
11
|
+
|
|
12
|
+
Trigger detection: a `Skill` tool call naming the skill (see parsers/claude_code.py).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, ConfigDict
|
|
23
|
+
|
|
24
|
+
from skillproof.errors import RunnerNotReady
|
|
25
|
+
from skillproof.parsers.claude_code import parse_transcript, to_dict
|
|
26
|
+
from skillproof.runners.base import AgentRun, Runner, RunRequest
|
|
27
|
+
from skillproof.skill import copy_skill
|
|
28
|
+
|
|
29
|
+
MAX_STDERR_LINES = 50
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _hide_reaped_child_warning(record: logging.LogRecord) -> bool:
|
|
33
|
+
"""CPython 3.10/3.11 race: when a finished subprocess object is garbage-collected,
|
|
34
|
+
a later `subprocess.Popen` (e.g. a `command` assertion) can reap it before
|
|
35
|
+
asyncio's child watcher does. asyncio then logs "Unknown child process pid ...,
|
|
36
|
+
will report returncode 255". It only affects Claude Code processes that already
|
|
37
|
+
delivered their result (which is what skillproof uses), so hide the noise."""
|
|
38
|
+
return "will report returncode 255" not in record.getMessage()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
logging.getLogger("asyncio").addFilter(_hide_reaped_child_warning)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeCodeRunner(Runner):
|
|
45
|
+
name = "claude-code"
|
|
46
|
+
vendor = "anthropic"
|
|
47
|
+
|
|
48
|
+
class Options(BaseModel):
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
# The sandbox is a throwaway temp dir, so by default the agent may edit
|
|
52
|
+
# files and run commands without prompts, like a real unattended session.
|
|
53
|
+
permission_mode: Literal["default", "acceptEdits", "bypassPermissions", "dontAsk"] = (
|
|
54
|
+
"bypassPermissions"
|
|
55
|
+
)
|
|
56
|
+
max_turns: int | None = None
|
|
57
|
+
max_budget_usd: float | None = None # per run; the SDK stops the run past this
|
|
58
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
|
|
59
|
+
tools: list[str] | None = None # restrict built-in tools, e.g. ["Read", "Bash"]
|
|
60
|
+
cli_path: str | None = None # use this `claude` binary instead of the bundled one
|
|
61
|
+
|
|
62
|
+
options: Options
|
|
63
|
+
|
|
64
|
+
def check_ready(self) -> None:
|
|
65
|
+
try:
|
|
66
|
+
import claude_agent_sdk # noqa: F401
|
|
67
|
+
except ImportError as exc:
|
|
68
|
+
raise RunnerNotReady(
|
|
69
|
+
"The claude-code runner needs the Claude Agent SDK.",
|
|
70
|
+
hint='Install it with: pip install "claude-agent-sdk"',
|
|
71
|
+
) from exc
|
|
72
|
+
cli = self.options.cli_path
|
|
73
|
+
if cli and not (Path(cli).is_file() or shutil.which(cli)):
|
|
74
|
+
raise RunnerNotReady(
|
|
75
|
+
f"Claude Code CLI not found at {cli!r}.",
|
|
76
|
+
hint="Fix `runner_options.cli_path`, or remove it to use the bundled CLI.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def run(self, request: RunRequest) -> AgentRun:
|
|
80
|
+
from claude_agent_sdk import ClaudeAgentOptions, query
|
|
81
|
+
|
|
82
|
+
skill_name = request.skill.name if request.skill else None
|
|
83
|
+
if request.skill is not None:
|
|
84
|
+
dest = request.workdir / ".claude" / "skills" / request.skill.name
|
|
85
|
+
copy_skill(request.skill, dest, exclude=request.skill_exclude)
|
|
86
|
+
|
|
87
|
+
stderr: list[str] = []
|
|
88
|
+
opts = self.options
|
|
89
|
+
options = ClaudeAgentOptions(
|
|
90
|
+
cwd=request.workdir,
|
|
91
|
+
setting_sources=["project"],
|
|
92
|
+
skills=[skill_name] if skill_name else [],
|
|
93
|
+
permission_mode=opts.permission_mode,
|
|
94
|
+
model=request.model,
|
|
95
|
+
max_turns=opts.max_turns,
|
|
96
|
+
max_budget_usd=opts.max_budget_usd,
|
|
97
|
+
effort=opts.effort,
|
|
98
|
+
tools=opts.tools,
|
|
99
|
+
cli_path=opts.cli_path,
|
|
100
|
+
extra_args={"no-session-persistence": None},
|
|
101
|
+
stderr=lambda line: stderr.append(line) if len(stderr) < MAX_STDERR_LINES else None,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
transcript: list[dict[str, Any]] = []
|
|
105
|
+
stream = query(prompt=request.prompt, options=options)
|
|
106
|
+
try:
|
|
107
|
+
async for message in stream:
|
|
108
|
+
transcript.append(to_dict(message))
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
# The SDK raises after yielding an error result; the result says it all.
|
|
111
|
+
if not any(m.get("_type") == "ResultMessage" for m in transcript):
|
|
112
|
+
detail = "\n".join(stderr[-5:])
|
|
113
|
+
raise RuntimeError(f"{exc}\n{detail}".strip()) from exc
|
|
114
|
+
finally:
|
|
115
|
+
await stream.aclose()
|
|
116
|
+
|
|
117
|
+
return parse_transcript(transcript, skill_name)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Codex CLI runner, via `codex exec --json` (non-interactive mode).
|
|
2
|
+
|
|
3
|
+
Isolation (each run):
|
|
4
|
+
* A fresh CODEX_HOME and HOME, so your ~/.codex config, AGENTS.md and personal
|
|
5
|
+
skills (~/.codex/skills, ~/.agents/skills) are not loaded for either variant.
|
|
6
|
+
Auth is carried over: CODEX_API_KEY / OPENAI_API_KEY from the environment,
|
|
7
|
+
else your `codex login` auth.json is copied into the temp CODEX_HOME.
|
|
8
|
+
* Codex's bundled system skills are disabled (`skills.bundled.enabled=false`).
|
|
9
|
+
* With the skill: it is copied to `<sandbox>/.agents/skills/<name>/`, where
|
|
10
|
+
Codex discovers repo skills. Baseline: nothing is installed.
|
|
11
|
+
* `--ephemeral`: no session files are written.
|
|
12
|
+
|
|
13
|
+
Trigger detection: a shell command touching `skills/<name>/` (see parsers/codex.py).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
26
|
+
|
|
27
|
+
from skillproof.errors import RunnerNotReady
|
|
28
|
+
from skillproof.parsers.codex import parse_events, parse_jsonl
|
|
29
|
+
from skillproof.runners.base import AgentRun, Runner, RunRequest
|
|
30
|
+
from skillproof.skill import copy_skill
|
|
31
|
+
|
|
32
|
+
STREAM_LIMIT = 64 * 1024 * 1024 # JSONL lines carry whole command outputs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def user_codex_home() -> Path:
|
|
36
|
+
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CodexRunner(Runner):
|
|
40
|
+
name = "codex"
|
|
41
|
+
vendor = "openai"
|
|
42
|
+
|
|
43
|
+
class Options(BaseModel):
|
|
44
|
+
model_config = ConfigDict(extra="forbid")
|
|
45
|
+
|
|
46
|
+
sandbox: Literal["read-only", "workspace-write", "danger-full-access"] = "workspace-write"
|
|
47
|
+
cli_path: str = "codex"
|
|
48
|
+
# Extra `-c key=value` overrides, e.g. {"model_reasoning_effort": "low"}.
|
|
49
|
+
config: dict[str, str] = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
options: Options
|
|
52
|
+
|
|
53
|
+
def check_ready(self) -> None:
|
|
54
|
+
if shutil.which(self.options.cli_path) is None:
|
|
55
|
+
raise RunnerNotReady(
|
|
56
|
+
f"Codex CLI not found ({self.options.cli_path!r}).",
|
|
57
|
+
hint="Install it with: npm install -g @openai/codex",
|
|
58
|
+
)
|
|
59
|
+
has_key = os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
60
|
+
if not has_key and not (user_codex_home() / "auth.json").is_file():
|
|
61
|
+
raise RunnerNotReady(
|
|
62
|
+
"No credentials for Codex.",
|
|
63
|
+
hint="export CODEX_API_KEY=... (or OPENAI_API_KEY), or run `codex login`.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def command(self, request: RunRequest) -> list[str]:
|
|
67
|
+
cmd = [
|
|
68
|
+
self.options.cli_path,
|
|
69
|
+
"exec",
|
|
70
|
+
"--json",
|
|
71
|
+
"--ephemeral",
|
|
72
|
+
"--skip-git-repo-check",
|
|
73
|
+
"--ignore-user-config",
|
|
74
|
+
"--sandbox",
|
|
75
|
+
self.options.sandbox,
|
|
76
|
+
"-C",
|
|
77
|
+
str(request.workdir),
|
|
78
|
+
"-c",
|
|
79
|
+
"skills.bundled.enabled=false",
|
|
80
|
+
# Otherwise a network problem makes codex retry forever.
|
|
81
|
+
"--disable",
|
|
82
|
+
"unbounded_connection_retries",
|
|
83
|
+
]
|
|
84
|
+
if request.model:
|
|
85
|
+
cmd += ["-m", request.model]
|
|
86
|
+
for key, value in self.options.config.items():
|
|
87
|
+
cmd += ["-c", f"{key}={value}"]
|
|
88
|
+
return [*cmd, request.prompt]
|
|
89
|
+
|
|
90
|
+
def environment(self, home: Path) -> dict[str, str]:
|
|
91
|
+
codex_home = home / "codex_home"
|
|
92
|
+
codex_home.mkdir(parents=True)
|
|
93
|
+
env = dict(os.environ)
|
|
94
|
+
key = os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
95
|
+
if key:
|
|
96
|
+
env["CODEX_API_KEY"] = key # codex exec reads CODEX_API_KEY, not OPENAI_API_KEY
|
|
97
|
+
elif (user_codex_home() / "auth.json").is_file():
|
|
98
|
+
shutil.copy2(user_codex_home() / "auth.json", codex_home / "auth.json")
|
|
99
|
+
env["CODEX_HOME"] = str(codex_home)
|
|
100
|
+
env["HOME"] = str(home) # ~/.agents/skills is looked up under $HOME
|
|
101
|
+
return env
|
|
102
|
+
|
|
103
|
+
async def run(self, request: RunRequest) -> AgentRun:
|
|
104
|
+
skill_name = request.skill.name if request.skill else None
|
|
105
|
+
if request.skill is not None:
|
|
106
|
+
dest = request.workdir / ".agents" / "skills" / request.skill.name
|
|
107
|
+
copy_skill(request.skill, dest, exclude=request.skill_exclude)
|
|
108
|
+
|
|
109
|
+
home = Path(tempfile.mkdtemp(prefix="skillproof-codex-home-"))
|
|
110
|
+
proc = None
|
|
111
|
+
try:
|
|
112
|
+
proc = await asyncio.create_subprocess_exec(
|
|
113
|
+
*self.command(request),
|
|
114
|
+
cwd=request.workdir,
|
|
115
|
+
env=self.environment(home),
|
|
116
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
117
|
+
stdout=asyncio.subprocess.PIPE,
|
|
118
|
+
stderr=asyncio.subprocess.PIPE,
|
|
119
|
+
limit=STREAM_LIMIT,
|
|
120
|
+
)
|
|
121
|
+
stdout, stderr = await proc.communicate()
|
|
122
|
+
finally:
|
|
123
|
+
if proc is not None and proc.returncode is None: # cancelled by the timeout
|
|
124
|
+
proc.kill()
|
|
125
|
+
await proc.wait()
|
|
126
|
+
shutil.rmtree(home, ignore_errors=True)
|
|
127
|
+
|
|
128
|
+
run = parse_events(parse_jsonl(stdout.decode(errors="replace")), skill_name)
|
|
129
|
+
run.model = request.model
|
|
130
|
+
if run.error and proc.returncode != 0:
|
|
131
|
+
tail = stderr.decode(errors="replace").strip().splitlines()[-3:]
|
|
132
|
+
run.error = "\n".join([run.error, *tail]).strip()
|
|
133
|
+
return run
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""A runner that returns scripted results. Used by our tests; never calls an API.
|
|
2
|
+
|
|
3
|
+
runner: mock
|
|
4
|
+
runner_options:
|
|
5
|
+
default: # used for cases without their own script
|
|
6
|
+
with: {text: "...", triggered: true, output_tokens: 300}
|
|
7
|
+
without: {text: "..."}
|
|
8
|
+
cases:
|
|
9
|
+
"summarizes stack trace":
|
|
10
|
+
with: # a list cycles through trials
|
|
11
|
+
- {text: "NullPointerException at line 3", files: {fix.patch: "..."}}
|
|
12
|
+
- {text: "not sure"}
|
|
13
|
+
without: {text: "not sure"}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from skillproof.runners.base import AgentRun, Runner, RunRequest, ToolCall, Usage
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MockResponse(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
text: str = ""
|
|
29
|
+
triggered: bool | None = None # default: True with the skill, False without
|
|
30
|
+
files: dict[str, str] = Field(default_factory=dict) # written into the sandbox
|
|
31
|
+
tool_calls: list[ToolCall] = Field(default_factory=list)
|
|
32
|
+
input_tokens: int = 1000
|
|
33
|
+
output_tokens: int = 200
|
|
34
|
+
duration_s: float = 1.0
|
|
35
|
+
timed_out: bool = False
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MockScript(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
41
|
+
|
|
42
|
+
with_: MockResponse | list[MockResponse] = Field(default_factory=MockResponse, alias="with")
|
|
43
|
+
without: MockResponse | list[MockResponse] = Field(default_factory=MockResponse)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MockRunner(Runner):
|
|
47
|
+
name = "mock"
|
|
48
|
+
vendor = "mock"
|
|
49
|
+
|
|
50
|
+
class Options(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
default: MockScript = Field(default_factory=MockScript)
|
|
54
|
+
cases: dict[str, MockScript] = Field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
options: Options
|
|
57
|
+
|
|
58
|
+
def __init__(self, options: dict[str, Any] | None = None) -> None:
|
|
59
|
+
super().__init__(options)
|
|
60
|
+
self.requests: list[RunRequest] = [] # for tests to inspect
|
|
61
|
+
|
|
62
|
+
async def run(self, request: RunRequest) -> AgentRun:
|
|
63
|
+
self.requests.append(request)
|
|
64
|
+
script = self.options.cases.get(request.case_name, self.options.default)
|
|
65
|
+
scripted = script.with_ if request.skill is not None else script.without
|
|
66
|
+
if isinstance(scripted, list):
|
|
67
|
+
scripted = scripted[request.trial % len(scripted)]
|
|
68
|
+
|
|
69
|
+
for rel_path, content in scripted.files.items():
|
|
70
|
+
target = request.workdir / rel_path
|
|
71
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
target.write_text(content, encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
triggered = scripted.triggered
|
|
75
|
+
if triggered is None:
|
|
76
|
+
triggered = request.skill is not None
|
|
77
|
+
|
|
78
|
+
return AgentRun(
|
|
79
|
+
final_text=scripted.text,
|
|
80
|
+
transcript=[
|
|
81
|
+
{"role": "user", "content": request.prompt},
|
|
82
|
+
{"role": "assistant", "content": scripted.text},
|
|
83
|
+
],
|
|
84
|
+
tool_calls=scripted.tool_calls,
|
|
85
|
+
usage=Usage(input_tokens=scripted.input_tokens, output_tokens=scripted.output_tokens),
|
|
86
|
+
duration_s=scripted.duration_s,
|
|
87
|
+
num_turns=1,
|
|
88
|
+
triggered=triggered,
|
|
89
|
+
timed_out=scripted.timed_out,
|
|
90
|
+
error=scripted.error,
|
|
91
|
+
)
|