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.
Files changed (43) hide show
  1. skillproof/__init__.py +3 -0
  2. skillproof/__main__.py +3 -0
  3. skillproof/assertions/__init__.py +62 -0
  4. skillproof/assertions/base.py +44 -0
  5. skillproof/assertions/builtin.py +176 -0
  6. skillproof/assertions/llm_judge.py +24 -0
  7. skillproof/cli.py +313 -0
  8. skillproof/config.py +188 -0
  9. skillproof/engine.py +268 -0
  10. skillproof/errors.py +72 -0
  11. skillproof/estimate.py +83 -0
  12. skillproof/judges/__init__.py +43 -0
  13. skillproof/judges/anthropic_judge.py +59 -0
  14. skillproof/judges/base.py +69 -0
  15. skillproof/judges/mock.py +21 -0
  16. skillproof/judges/openai_judge.py +58 -0
  17. skillproof/parsers/__init__.py +6 -0
  18. skillproof/parsers/claude_code.py +144 -0
  19. skillproof/parsers/codex.py +112 -0
  20. skillproof/report/__init__.py +34 -0
  21. skillproof/report/badge.py +60 -0
  22. skillproof/report/format.py +75 -0
  23. skillproof/report/html_report.py +296 -0
  24. skillproof/report/json_report.py +9 -0
  25. skillproof/report/markdown.py +78 -0
  26. skillproof/report/summary.py +216 -0
  27. skillproof/report/terminal.py +89 -0
  28. skillproof/results.py +88 -0
  29. skillproof/runners/__init__.py +33 -0
  30. skillproof/runners/base.py +102 -0
  31. skillproof/runners/claude_code.py +117 -0
  32. skillproof/runners/codex.py +133 -0
  33. skillproof/runners/mock.py +91 -0
  34. skillproof/runners/openai_compat.py +238 -0
  35. skillproof/sandbox.py +85 -0
  36. skillproof/scaffold.py +153 -0
  37. skillproof/skill.py +97 -0
  38. skillproof/stats.py +228 -0
  39. skillproof-0.1.0.dist-info/METADATA +262 -0
  40. skillproof-0.1.0.dist-info/RECORD +43 -0
  41. skillproof-0.1.0.dist-info/WHEEL +4 -0
  42. skillproof-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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
+ )