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,238 @@
|
|
|
1
|
+
"""OpenAI-compatible endpoint runner: a small tool-using agent loop over Chat Completions.
|
|
2
|
+
|
|
3
|
+
Works with OpenAI and anything that speaks the same API (vLLM, Ollama,
|
|
4
|
+
OpenRouter, LM Studio, ...).
|
|
5
|
+
|
|
6
|
+
APPROXIMATION: this is not a real skill-loading agent. With the skill, SKILL.md
|
|
7
|
+
is pasted into the system prompt and the skill folder is copied to
|
|
8
|
+
`.skills/<name>/` in the sandbox. Real agents only see the skill's description
|
|
9
|
+
and decide whether to load it, so trigger rates are not measured here and
|
|
10
|
+
results are labeled approximate in every report.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from skillproof.errors import RunnerNotReady
|
|
23
|
+
from skillproof.runners.base import AgentRun, Runner, RunRequest, ToolCall, Usage
|
|
24
|
+
from skillproof.sandbox import run_shell
|
|
25
|
+
from skillproof.skill import Skill, copy_skill
|
|
26
|
+
|
|
27
|
+
MAX_TOOL_OUTPUT = 20_000 # characters returned to the model per tool call
|
|
28
|
+
COMMAND_TIMEOUT = 120 # seconds per run_command call
|
|
29
|
+
|
|
30
|
+
BASE_PROMPT = """\
|
|
31
|
+
You are a capable assistant working in a project directory. Use the tools to
|
|
32
|
+
inspect files, write files and run shell commands when that helps. When you are
|
|
33
|
+
done, reply with your final answer as plain text.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SKILL_PROMPT = """
|
|
37
|
+
A skill is installed. Its files are in `.skills/{name}/`. Follow its
|
|
38
|
+
instructions when they are relevant to the request.
|
|
39
|
+
|
|
40
|
+
<skill name="{name}">
|
|
41
|
+
{content}
|
|
42
|
+
</skill>
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
TOOLS: list[dict[str, Any]] = [
|
|
46
|
+
{
|
|
47
|
+
"type": "function",
|
|
48
|
+
"function": {
|
|
49
|
+
"name": "run_command",
|
|
50
|
+
"description": "Run a shell command in the project directory.",
|
|
51
|
+
"parameters": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {"command": {"type": "string"}},
|
|
54
|
+
"required": ["command"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "function",
|
|
60
|
+
"function": {
|
|
61
|
+
"name": "read_file",
|
|
62
|
+
"description": "Read a text file (path relative to the project directory).",
|
|
63
|
+
"parameters": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {"path": {"type": "string"}},
|
|
66
|
+
"required": ["path"],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"type": "function",
|
|
72
|
+
"function": {
|
|
73
|
+
"name": "write_file",
|
|
74
|
+
"description": "Create or overwrite a text file (path relative to the project).",
|
|
75
|
+
"parameters": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
78
|
+
"required": ["path", "content"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "function",
|
|
84
|
+
"function": {
|
|
85
|
+
"name": "list_files",
|
|
86
|
+
"description": "List files under a directory (relative to the project).",
|
|
87
|
+
"parameters": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {"path": {"type": "string"}},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def system_prompt(skill: Skill | None) -> str:
|
|
97
|
+
if skill is None:
|
|
98
|
+
return BASE_PROMPT
|
|
99
|
+
return BASE_PROMPT + SKILL_PROMPT.format(name=skill.name, content=skill.skill_md.strip())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _inside(workdir: Path, rel: str) -> Path:
|
|
103
|
+
path = (workdir / rel).resolve()
|
|
104
|
+
if not path.is_relative_to(workdir.resolve()):
|
|
105
|
+
raise ValueError(f"{rel!r} is outside the project directory")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _truncate(text: str) -> str:
|
|
110
|
+
if len(text) <= MAX_TOOL_OUTPUT:
|
|
111
|
+
return text
|
|
112
|
+
return text[:MAX_TOOL_OUTPUT] + f"\n... [truncated {len(text) - MAX_TOOL_OUTPUT} chars]"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def run_tool(name: str, args: dict[str, Any], workdir: Path) -> str:
|
|
116
|
+
"""Execute one tool call inside the sandbox and return its text result."""
|
|
117
|
+
try:
|
|
118
|
+
if name == "run_command":
|
|
119
|
+
code, out = await run_shell(str(args["command"]), workdir, COMMAND_TIMEOUT)
|
|
120
|
+
if code is None:
|
|
121
|
+
return f"command timed out after {COMMAND_TIMEOUT}s"
|
|
122
|
+
return _truncate(f"exit code {code}\n{out}")
|
|
123
|
+
if name == "read_file":
|
|
124
|
+
return _truncate(_inside(workdir, args["path"]).read_text(errors="replace"))
|
|
125
|
+
if name == "write_file":
|
|
126
|
+
target = _inside(workdir, args["path"])
|
|
127
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
target.write_text(str(args["content"]))
|
|
129
|
+
return f"wrote {args['path']}"
|
|
130
|
+
if name == "list_files":
|
|
131
|
+
root = _inside(workdir, args.get("path") or ".")
|
|
132
|
+
files = sorted(str(p.relative_to(workdir)) for p in root.rglob("*") if p.is_file())
|
|
133
|
+
return _truncate("\n".join(files) or "(no files)")
|
|
134
|
+
return f"unknown tool {name!r}"
|
|
135
|
+
except Exception as exc: # tool errors go back to the model, like a real agent
|
|
136
|
+
return f"error: {exc}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class OpenAICompatibleRunner(Runner):
|
|
140
|
+
name = "openai-compatible"
|
|
141
|
+
vendor = "openai"
|
|
142
|
+
approximate = True
|
|
143
|
+
requires_model = True
|
|
144
|
+
|
|
145
|
+
class Options(BaseModel):
|
|
146
|
+
model_config = ConfigDict(extra="forbid")
|
|
147
|
+
|
|
148
|
+
base_url: str | None = None # None = api.openai.com
|
|
149
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
150
|
+
tools: bool = True # set false for endpoints without function calling
|
|
151
|
+
max_turns: int = Field(default=20, ge=1)
|
|
152
|
+
|
|
153
|
+
options: Options
|
|
154
|
+
|
|
155
|
+
def check_ready(self) -> None:
|
|
156
|
+
if self.options.base_url is None and not os.environ.get(self.options.api_key_env):
|
|
157
|
+
raise RunnerNotReady(
|
|
158
|
+
f"{self.options.api_key_env} is not set.",
|
|
159
|
+
hint=f"export {self.options.api_key_env}=..., or set runner_options.base_url "
|
|
160
|
+
"to a local endpoint (e.g. http://localhost:11434/v1 for Ollama).",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def client(self) -> Any:
|
|
164
|
+
from openai import AsyncOpenAI
|
|
165
|
+
|
|
166
|
+
# Local endpoints often need no key, but the client insists on one.
|
|
167
|
+
api_key = os.environ.get(self.options.api_key_env) or "not-needed"
|
|
168
|
+
return AsyncOpenAI(base_url=self.options.base_url, api_key=api_key)
|
|
169
|
+
|
|
170
|
+
async def run(self, request: RunRequest) -> AgentRun:
|
|
171
|
+
if request.skill is not None:
|
|
172
|
+
copy_skill(
|
|
173
|
+
request.skill,
|
|
174
|
+
request.workdir / ".skills" / request.skill.name,
|
|
175
|
+
exclude=request.skill_exclude,
|
|
176
|
+
)
|
|
177
|
+
client = self.client()
|
|
178
|
+
messages: list[dict[str, Any]] = [
|
|
179
|
+
{"role": "system", "content": system_prompt(request.skill)},
|
|
180
|
+
{"role": "user", "content": request.prompt},
|
|
181
|
+
]
|
|
182
|
+
usage = Usage()
|
|
183
|
+
calls: list[ToolCall] = []
|
|
184
|
+
warnings: list[str] = []
|
|
185
|
+
final = ""
|
|
186
|
+
turns = 0
|
|
187
|
+
try:
|
|
188
|
+
for turns in range(1, self.options.max_turns + 1): # noqa: B007 - used after loop
|
|
189
|
+
kwargs: dict[str, Any] = {"model": request.model, "messages": messages}
|
|
190
|
+
if self.options.tools:
|
|
191
|
+
kwargs["tools"] = TOOLS
|
|
192
|
+
response = await client.chat.completions.create(**kwargs)
|
|
193
|
+
add_usage(usage, response.usage)
|
|
194
|
+
message = response.choices[0].message
|
|
195
|
+
messages.append(message.model_dump(exclude_none=True))
|
|
196
|
+
if not message.tool_calls:
|
|
197
|
+
final = message.content or ""
|
|
198
|
+
break
|
|
199
|
+
for tool_call in message.tool_calls:
|
|
200
|
+
args = parse_arguments(tool_call.function.arguments)
|
|
201
|
+
output = await run_tool(tool_call.function.name, args, request.workdir)
|
|
202
|
+
calls.append(ToolCall(name=tool_call.function.name, input=args, output=output))
|
|
203
|
+
messages.append(
|
|
204
|
+
{"role": "tool", "tool_call_id": tool_call.id, "content": output}
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
warnings.append(f"stopped early: max_turns ({self.options.max_turns})")
|
|
208
|
+
finally:
|
|
209
|
+
await client.close()
|
|
210
|
+
|
|
211
|
+
return AgentRun(
|
|
212
|
+
final_text=final,
|
|
213
|
+
transcript=messages,
|
|
214
|
+
tool_calls=calls,
|
|
215
|
+
usage=usage,
|
|
216
|
+
num_turns=turns,
|
|
217
|
+
model=request.model,
|
|
218
|
+
triggered=None, # can't be measured: the skill is always in the prompt
|
|
219
|
+
warnings=warnings,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_arguments(raw: str | None) -> dict[str, Any]:
|
|
224
|
+
try:
|
|
225
|
+
args = json.loads(raw or "{}")
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
return {"_invalid_json": raw}
|
|
228
|
+
return args if isinstance(args, dict) else {"_value": args}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def add_usage(total: Usage, usage: Any) -> None:
|
|
232
|
+
if usage is None:
|
|
233
|
+
return
|
|
234
|
+
details = getattr(usage, "prompt_tokens_details", None)
|
|
235
|
+
cached = (getattr(details, "cached_tokens", None) or 0) if details else 0
|
|
236
|
+
total.input_tokens += (usage.prompt_tokens or 0) - cached
|
|
237
|
+
total.cache_read_tokens += cached
|
|
238
|
+
total.output_tokens += usage.completion_tokens or 0
|
skillproof/sandbox.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Fresh temp directories for each run, so runs never touch the user's project.
|
|
2
|
+
|
|
3
|
+
Sandboxes live in the system temp dir (not inside the user's repo) so agents
|
|
4
|
+
that walk up the directory tree looking for config (CLAUDE.md, AGENTS.md, .git)
|
|
5
|
+
don't pick up the user's real project.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Env vars whose values must never reach logs, saved transcripts, or `command`
|
|
20
|
+
# assertions. Matched against the variable *name*.
|
|
21
|
+
SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)", re.IGNORECASE)
|
|
22
|
+
MIN_SECRET_LENGTH = 8 # shorter values are too likely to collide with normal text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_sandbox(fixtures: Path | None = None, prefix: str = "skillproof-") -> Path:
|
|
26
|
+
workdir = Path(tempfile.mkdtemp(prefix=prefix)).resolve()
|
|
27
|
+
if fixtures is not None:
|
|
28
|
+
shutil.copytree(fixtures, workdir, dirs_exist_ok=True)
|
|
29
|
+
return workdir
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def remove_sandbox(workdir: Path) -> None:
|
|
33
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scrubbed_env() -> dict[str, str]:
|
|
37
|
+
"""A copy of os.environ without anything that looks like a credential."""
|
|
38
|
+
return {k: v for k, v in os.environ.items() if not SECRET_NAME.search(k)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def secret_values() -> list[str]:
|
|
42
|
+
values = [
|
|
43
|
+
v for k, v in os.environ.items() if SECRET_NAME.search(k) and len(v) >= MIN_SECRET_LENGTH
|
|
44
|
+
]
|
|
45
|
+
return sorted(set(values), key=len, reverse=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact(text: str) -> str:
|
|
49
|
+
"""Replace any credential values from the environment with ***."""
|
|
50
|
+
for value in secret_values():
|
|
51
|
+
text = text.replace(value, "***")
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_shell(command: str, cwd: Path, timeout: float) -> tuple[int | None, str]:
|
|
56
|
+
proc = subprocess.Popen(
|
|
57
|
+
command,
|
|
58
|
+
shell=True,
|
|
59
|
+
cwd=cwd,
|
|
60
|
+
env=scrubbed_env(),
|
|
61
|
+
stdout=subprocess.PIPE,
|
|
62
|
+
stderr=subprocess.STDOUT,
|
|
63
|
+
start_new_session=True, # so a timeout can kill the whole process group
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
out, _ = proc.communicate(timeout=timeout)
|
|
67
|
+
except subprocess.TimeoutExpired:
|
|
68
|
+
if hasattr(os, "killpg"):
|
|
69
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
70
|
+
else:
|
|
71
|
+
proc.kill()
|
|
72
|
+
proc.communicate()
|
|
73
|
+
return None, ""
|
|
74
|
+
return proc.returncode, out.decode(errors="replace")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def run_shell(command: str, cwd: Path, timeout: float) -> tuple[int | None, str]:
|
|
78
|
+
"""Run a shell command in the sandbox without credentials in its environment.
|
|
79
|
+
|
|
80
|
+
Returns (exit code, combined output); exit code None means it timed out.
|
|
81
|
+
Runs in a worker thread with plain `subprocess` rather than asyncio
|
|
82
|
+
subprocesses: on Python 3.10/3.11, asyncio's child watcher can collide with
|
|
83
|
+
the Agent SDK's own subprocesses and misreport exit codes as 255.
|
|
84
|
+
"""
|
|
85
|
+
return await asyncio.to_thread(_run_shell, command, cwd, timeout)
|
skillproof/scaffold.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""`skillproof init`: write a starter evals.yaml and fixtures folder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from skillproof.errors import SkillproofError
|
|
9
|
+
from skillproof.skill import SKILL_FILE, SkillError, load_skill
|
|
10
|
+
|
|
11
|
+
EVALS_TEMPLATE = """\
|
|
12
|
+
# skillproof evals for the "{name}" skill.
|
|
13
|
+
#
|
|
14
|
+
# Every case runs `trials` times WITH the skill installed and `trials` times
|
|
15
|
+
# WITHOUT it (the baseline). skillproof then reports whether the skill was
|
|
16
|
+
# triggered, pass rate with vs. without (the "lift"), and token/latency deltas.
|
|
17
|
+
#
|
|
18
|
+
# Run it: skillproof run {display_path}
|
|
19
|
+
|
|
20
|
+
skill: ./ # folder containing SKILL.md, relative to this file
|
|
21
|
+
runner: {runner}{runner_pad}# claude-code | codex | openai-compatible
|
|
22
|
+
{model_line}
|
|
23
|
+
trials: 5 # runs per case, per variant. More trials = tighter numbers.
|
|
24
|
+
timeout: 300 # seconds per run
|
|
25
|
+
|
|
26
|
+
# Only needed for llm_judge assertions. `auto` picks a judge from a different
|
|
27
|
+
# vendor than the agent under test, so a model never grades itself.
|
|
28
|
+
# judge:
|
|
29
|
+
# provider: auto # auto | anthropic | openai
|
|
30
|
+
# model: ...
|
|
31
|
+
|
|
32
|
+
cases:
|
|
33
|
+
# A task the skill SHOULD help with. Write it the way a real user would:
|
|
34
|
+
# don't name the skill, that's what the trigger rate measures.
|
|
35
|
+
- name: "example task"
|
|
36
|
+
prompt: |
|
|
37
|
+
TODO: replace with a realistic request that needs this skill:
|
|
38
|
+
{description}
|
|
39
|
+
fixtures: ./fixtures/example/ # copied into a fresh temp dir for every run
|
|
40
|
+
assert:
|
|
41
|
+
# Deterministic checks first: cheap, fast and reproducible.
|
|
42
|
+
- type: contains # final answer contains this text (case-insensitive)
|
|
43
|
+
value: "TODO"
|
|
44
|
+
# - type: not_contains
|
|
45
|
+
# value: "I can't"
|
|
46
|
+
# - type: regex
|
|
47
|
+
# pattern: "line \\\\d+"
|
|
48
|
+
# - type: file_exists # path is relative to the sandbox
|
|
49
|
+
# path: "output.md"
|
|
50
|
+
# - type: file_contains
|
|
51
|
+
# path: "output.md"
|
|
52
|
+
# value: "## Summary"
|
|
53
|
+
# - type: command # runs in the sandbox, passes on exit code 0
|
|
54
|
+
# run: "python -m pytest -q"
|
|
55
|
+
# - type: max_tokens # input + output + cache tokens for the run
|
|
56
|
+
# value: 50000
|
|
57
|
+
# - type: llm_judge # optional, graded by a different-vendor model
|
|
58
|
+
# rubric: "States the root cause in the first sentence."
|
|
59
|
+
|
|
60
|
+
# A request the skill should NOT activate for. Catches over-eager
|
|
61
|
+
# descriptions that load the skill (and burn tokens) when it isn't needed.
|
|
62
|
+
- name: "unrelated request"
|
|
63
|
+
prompt: "What is the capital of France? Answer in one word."
|
|
64
|
+
should_trigger: false
|
|
65
|
+
assert:
|
|
66
|
+
- type: contains
|
|
67
|
+
value: "Paris"
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
MODEL_LINES = {
|
|
71
|
+
"openai-compatible": "model: gpt-6.1-sol # required: any model your endpoint serves\n"
|
|
72
|
+
"runner_options:\n"
|
|
73
|
+
" # base_url: http://localhost:11434/v1 # e.g. Ollama; default is api.openai.com",
|
|
74
|
+
"default": "# model: ... # optional; defaults to the agent's own default model",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
FIXTURE_README = """\
|
|
78
|
+
Files in this folder are copied into a fresh temporary directory before every
|
|
79
|
+
run, so the agent can read and edit them without touching your real project.
|
|
80
|
+
|
|
81
|
+
Replace this file with whatever the "example task" case needs: a repo snippet,
|
|
82
|
+
a log file, a CSV, a failing test...
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
SKILL_TEMPLATE = """\
|
|
86
|
+
---
|
|
87
|
+
name: {name}
|
|
88
|
+
description: TODO - what this skill does and when the agent should use it.
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
# {name}
|
|
92
|
+
|
|
93
|
+
TODO: instructions for the agent.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class InitResult:
|
|
99
|
+
created: list[Path] = field(default_factory=list)
|
|
100
|
+
warnings: list[str] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def init_skill(path: Path, runner: str = "claude-code", force: bool = False) -> InitResult:
|
|
104
|
+
result = InitResult()
|
|
105
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
evals_path = path / "evals.yaml"
|
|
107
|
+
if evals_path.exists() and not force:
|
|
108
|
+
raise SkillproofError(
|
|
109
|
+
f"{evals_path} already exists.",
|
|
110
|
+
hint="Use --force to overwrite it, or edit it by hand.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
name = path.resolve().name
|
|
114
|
+
description = "TODO: describe the task."
|
|
115
|
+
if (path / SKILL_FILE).is_file():
|
|
116
|
+
try:
|
|
117
|
+
skill = load_skill(path)
|
|
118
|
+
name, description = skill.name, skill.description
|
|
119
|
+
except SkillError as exc:
|
|
120
|
+
result.warnings.append(f"{exc.message} {exc.hint or ''}".strip())
|
|
121
|
+
else:
|
|
122
|
+
(path / SKILL_FILE).write_text(SKILL_TEMPLATE.format(name=name), encoding="utf-8")
|
|
123
|
+
result.created.append(path / SKILL_FILE)
|
|
124
|
+
result.warnings.append(f"No {SKILL_FILE} found, so a placeholder was created. Fill it in.")
|
|
125
|
+
|
|
126
|
+
evals_path.write_text(
|
|
127
|
+
EVALS_TEMPLATE.format(
|
|
128
|
+
name=name,
|
|
129
|
+
display_path=_display(path),
|
|
130
|
+
runner=runner,
|
|
131
|
+
runner_pad=" " * max(1, 17 - len(runner)),
|
|
132
|
+
model_line=MODEL_LINES.get(runner, MODEL_LINES["default"]),
|
|
133
|
+
description=" ".join(description.split()),
|
|
134
|
+
),
|
|
135
|
+
encoding="utf-8",
|
|
136
|
+
)
|
|
137
|
+
result.created.append(evals_path)
|
|
138
|
+
|
|
139
|
+
fixtures = path / "fixtures" / "example"
|
|
140
|
+
fixtures.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
readme = fixtures / "README.txt"
|
|
142
|
+
if not readme.exists():
|
|
143
|
+
readme.write_text(FIXTURE_README, encoding="utf-8")
|
|
144
|
+
result.created.append(readme)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _display(path: Path) -> str:
|
|
149
|
+
try:
|
|
150
|
+
rel = path.resolve().relative_to(Path.cwd())
|
|
151
|
+
return f"./{rel}" if str(rel) != "." else "."
|
|
152
|
+
except ValueError:
|
|
153
|
+
return str(path)
|
skillproof/skill.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Loading a skill folder (a directory with a SKILL.md) and copying it into sandboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from skillproof.errors import SkillError
|
|
12
|
+
|
|
13
|
+
SKILL_FILE = "SKILL.md"
|
|
14
|
+
|
|
15
|
+
# Never copied into the sandbox along with the skill. The agent must not be able
|
|
16
|
+
# to read the eval cases (and their assertions) while it is being evaluated.
|
|
17
|
+
ALWAYS_EXCLUDED = {".git", ".skillproof", "evals.yaml", "evals.yml", "__pycache__", ".venv"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Skill(BaseModel):
|
|
21
|
+
path: Path
|
|
22
|
+
name: str
|
|
23
|
+
description: str
|
|
24
|
+
body: str # SKILL.md content without the frontmatter
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def skill_md(self) -> str:
|
|
28
|
+
return (self.path / SKILL_FILE).read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
32
|
+
"""Split a markdown file into (frontmatter dict, body). Missing frontmatter -> {}."""
|
|
33
|
+
if not text.startswith("---"):
|
|
34
|
+
return {}, text
|
|
35
|
+
lines = text.splitlines(keepends=True)
|
|
36
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
37
|
+
if line.strip() == "---":
|
|
38
|
+
raw = "".join(lines[1:i])
|
|
39
|
+
body = "".join(lines[i + 1 :])
|
|
40
|
+
try:
|
|
41
|
+
data = yaml.safe_load(raw) or {}
|
|
42
|
+
except yaml.YAMLError as exc:
|
|
43
|
+
raise SkillError(
|
|
44
|
+
f"SKILL.md frontmatter is not valid YAML: {exc}",
|
|
45
|
+
hint="Fix the YAML between the two '---' lines at the top of SKILL.md.",
|
|
46
|
+
) from exc
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
return {}, body
|
|
49
|
+
return data, body
|
|
50
|
+
return {}, text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_skill(path: Path) -> Skill:
|
|
54
|
+
path = path.resolve()
|
|
55
|
+
skill_md = path / SKILL_FILE
|
|
56
|
+
if not path.is_dir():
|
|
57
|
+
raise SkillError(
|
|
58
|
+
f"Skill folder not found: {path}",
|
|
59
|
+
hint="Set `skill:` in evals.yaml to the folder that contains SKILL.md.",
|
|
60
|
+
)
|
|
61
|
+
if not skill_md.is_file():
|
|
62
|
+
raise SkillError(
|
|
63
|
+
f"No {SKILL_FILE} in {path}",
|
|
64
|
+
hint="A skill is a folder with a SKILL.md file. Point `skill:` at that folder.",
|
|
65
|
+
)
|
|
66
|
+
meta, body = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
|
67
|
+
name = str(meta.get("name") or "").strip()
|
|
68
|
+
description = str(meta.get("description") or "").strip()
|
|
69
|
+
if not name or not description:
|
|
70
|
+
raise SkillError(
|
|
71
|
+
f"{skill_md} must have `name` and `description` in its frontmatter.",
|
|
72
|
+
hint=(
|
|
73
|
+
"Start SKILL.md with:\n---\nname: my-skill\n"
|
|
74
|
+
"description: What it does and when to use it.\n---"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
return Skill(path=path, name=name, description=description, body=body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def copy_skill(skill: Skill, dest: Path, exclude: list[Path] | None = None) -> Path:
|
|
81
|
+
"""Copy the skill folder to `dest`, leaving out eval files and fixtures.
|
|
82
|
+
|
|
83
|
+
`exclude` holds absolute paths (e.g. fixture folders that live inside the
|
|
84
|
+
skill folder) that must not be visible to the agent.
|
|
85
|
+
"""
|
|
86
|
+
excluded = {p.resolve() for p in (exclude or [])}
|
|
87
|
+
|
|
88
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
89
|
+
skipped = set()
|
|
90
|
+
for name in names:
|
|
91
|
+
full = (Path(directory) / name).resolve()
|
|
92
|
+
if name in ALWAYS_EXCLUDED or full in excluded:
|
|
93
|
+
skipped.add(name)
|
|
94
|
+
return skipped
|
|
95
|
+
|
|
96
|
+
shutil.copytree(skill.path, dest, ignore=ignore, dirs_exist_ok=True)
|
|
97
|
+
return dest
|