doesitfire 0.1.2__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.
- doesitfire/__init__.py +3 -0
- doesitfire/agents.py +310 -0
- doesitfire/cli.py +263 -0
- doesitfire/config.py +251 -0
- doesitfire/data/demo-results.json +1393 -0
- doesitfire/docker/Dockerfile +24 -0
- doesitfire/docker/egress-proxy.mjs +113 -0
- doesitfire/docker/entrypoint.sh +14 -0
- doesitfire/errors.py +17 -0
- doesitfire/html.py +26 -0
- doesitfire/metrics.py +117 -0
- doesitfire/providers.py +121 -0
- doesitfire/report.py +175 -0
- doesitfire/runner.py +283 -0
- doesitfire/sandbox.py +276 -0
- doesitfire/templates/report.html +341 -0
- doesitfire-0.1.2.dist-info/METADATA +306 -0
- doesitfire-0.1.2.dist-info/RECORD +21 -0
- doesitfire-0.1.2.dist-info/WHEEL +4 -0
- doesitfire-0.1.2.dist-info/entry_points.txt +2 -0
- doesitfire-0.1.2.dist-info/licenses/LICENSE +21 -0
doesitfire/config.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""doesitfire.yaml: which skills to test, with which prompts, on which agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .errors import DoesItFireError
|
|
12
|
+
from .providers import PROVIDER_NAMES, SUPPORTED, check_supported
|
|
13
|
+
|
|
14
|
+
AGENTS = ("claude-code", "codex", "opencode")
|
|
15
|
+
CONFIG_NAME = "doesitfire.yaml"
|
|
16
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Skill:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
path: Path
|
|
24
|
+
should_fire: list[str]
|
|
25
|
+
should_not_fire: list[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AgentConfig:
|
|
30
|
+
name: str
|
|
31
|
+
provider: str
|
|
32
|
+
model: str
|
|
33
|
+
price_input: float | None = None # USD per million input tokens
|
|
34
|
+
price_output: float | None = None # USD per million output tokens
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Config:
|
|
39
|
+
skills: list[Skill]
|
|
40
|
+
agents: list[AgentConfig]
|
|
41
|
+
repeats: int = 3
|
|
42
|
+
timeout: int = 300
|
|
43
|
+
max_turns: int = 2
|
|
44
|
+
cpus: float = 1.0
|
|
45
|
+
memory: str = "1g"
|
|
46
|
+
base_dir: Path = field(default_factory=Path.cwd)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_frontmatter(text: str) -> dict:
|
|
50
|
+
"""Return the YAML frontmatter of a SKILL.md as a dict (empty if none).
|
|
51
|
+
|
|
52
|
+
Raises ValueError when the frontmatter exists but is not valid YAML.
|
|
53
|
+
"""
|
|
54
|
+
if not text.startswith("---"):
|
|
55
|
+
return {}
|
|
56
|
+
end = text.find("\n---", 3)
|
|
57
|
+
if end == -1:
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = yaml.safe_load(text[3:end])
|
|
61
|
+
except yaml.YAMLError as exc:
|
|
62
|
+
raise ValueError(str(exc).splitlines()[0]) from None
|
|
63
|
+
return data if isinstance(data, dict) else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_skill_dir(path: Path) -> tuple[str, str]:
|
|
67
|
+
skill_md = path / "SKILL.md"
|
|
68
|
+
if not skill_md.is_file():
|
|
69
|
+
raise DoesItFireError(f"No SKILL.md in {path}. Each skill entry must point to a skill directory.")
|
|
70
|
+
try:
|
|
71
|
+
meta = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise DoesItFireError(
|
|
74
|
+
f"The frontmatter of {skill_md} is not valid YAML ({exc}). Quote values that contain "
|
|
75
|
+
"': ', for example description: \"...\"."
|
|
76
|
+
) from None
|
|
77
|
+
name = str(meta.get("name") or path.name).strip()
|
|
78
|
+
description = str(meta.get("description") or "").strip()
|
|
79
|
+
if not description:
|
|
80
|
+
raise DoesItFireError(
|
|
81
|
+
f"{skill_md} has no 'description' in its frontmatter. Agents decide whether to use a "
|
|
82
|
+
"skill from that description, so it is required."
|
|
83
|
+
)
|
|
84
|
+
if not _NAME_RE.match(name):
|
|
85
|
+
raise DoesItFireError(f"Skill name '{name}' in {skill_md} must be lowercase letters, digits and hyphens.")
|
|
86
|
+
return name, description
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _str_list(value, where: str) -> list[str]:
|
|
90
|
+
if value is None:
|
|
91
|
+
return []
|
|
92
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
|
|
93
|
+
raise DoesItFireError(f"{where} must be a list of non-empty strings.")
|
|
94
|
+
return [v.strip() for v in value]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _positive(data: dict, key: str, default, kind=int):
|
|
98
|
+
value = data.get(key, default)
|
|
99
|
+
try:
|
|
100
|
+
value = kind(value)
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
raise DoesItFireError(f"'{key}' must be a number.") from None
|
|
103
|
+
if value <= 0:
|
|
104
|
+
raise DoesItFireError(f"'{key}' must be greater than 0.")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_config(data: dict, base_dir: Path) -> Config:
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise DoesItFireError(f"{CONFIG_NAME} must be a YAML mapping.")
|
|
111
|
+
|
|
112
|
+
raw_agents = data.get("agents") or {}
|
|
113
|
+
if not isinstance(raw_agents, dict) or not raw_agents:
|
|
114
|
+
raise DoesItFireError(f"{CONFIG_NAME}: 'agents' must map agent names ({', '.join(AGENTS)}) to settings.")
|
|
115
|
+
agents: list[AgentConfig] = []
|
|
116
|
+
for name, spec in raw_agents.items():
|
|
117
|
+
if name not in AGENTS:
|
|
118
|
+
raise DoesItFireError(f"Unknown agent '{name}'. Supported in v0.1: {', '.join(AGENTS)}.")
|
|
119
|
+
if spec is False or (isinstance(spec, dict) and spec.get("enabled") is False):
|
|
120
|
+
continue
|
|
121
|
+
if not isinstance(spec, dict) or not spec.get("provider") or not spec.get("model"):
|
|
122
|
+
raise DoesItFireError(f"Agent '{name}' needs 'provider' and 'model'.")
|
|
123
|
+
provider = str(spec["provider"])
|
|
124
|
+
if provider not in PROVIDER_NAMES:
|
|
125
|
+
raise DoesItFireError(
|
|
126
|
+
f"Agent '{name}': unknown provider '{provider}'. Use one of: {', '.join(PROVIDER_NAMES)}."
|
|
127
|
+
)
|
|
128
|
+
check_supported(name, provider)
|
|
129
|
+
price = spec.get("price") or {}
|
|
130
|
+
agents.append(
|
|
131
|
+
AgentConfig(
|
|
132
|
+
name=name,
|
|
133
|
+
provider=provider,
|
|
134
|
+
model=str(spec["model"]),
|
|
135
|
+
price_input=float(price["input"]) if "input" in price else None,
|
|
136
|
+
price_output=float(price["output"]) if "output" in price else None,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
if not agents:
|
|
140
|
+
raise DoesItFireError("All agents are disabled. Enable at least one.")
|
|
141
|
+
|
|
142
|
+
raw_skills = data.get("skills")
|
|
143
|
+
if not isinstance(raw_skills, list) or not raw_skills:
|
|
144
|
+
raise DoesItFireError(f"{CONFIG_NAME}: 'skills' must be a non-empty list.")
|
|
145
|
+
skills: list[Skill] = []
|
|
146
|
+
seen: set[str] = set()
|
|
147
|
+
for i, entry in enumerate(raw_skills):
|
|
148
|
+
if not isinstance(entry, dict) or "path" not in entry:
|
|
149
|
+
raise DoesItFireError(f"skills[{i}] needs a 'path' to a skill directory.")
|
|
150
|
+
path = (base_dir / str(entry["path"])).resolve()
|
|
151
|
+
name, description = load_skill_dir(path)
|
|
152
|
+
if name in seen:
|
|
153
|
+
raise DoesItFireError(f"Skill '{name}' is listed twice.")
|
|
154
|
+
seen.add(name)
|
|
155
|
+
should = _str_list(entry.get("should_fire"), f"skills[{i}].should_fire")
|
|
156
|
+
should_not = _str_list(entry.get("should_not_fire"), f"skills[{i}].should_not_fire")
|
|
157
|
+
if not should and not should_not:
|
|
158
|
+
raise DoesItFireError(f"Skill '{name}' has no prompts. Add should_fire and/or should_not_fire.")
|
|
159
|
+
skills.append(Skill(name, description, path, should, should_not))
|
|
160
|
+
|
|
161
|
+
return Config(
|
|
162
|
+
skills=skills,
|
|
163
|
+
agents=agents,
|
|
164
|
+
repeats=_positive(data, "repeats", 3),
|
|
165
|
+
timeout=_positive(data, "timeout", 300),
|
|
166
|
+
max_turns=_positive(data, "max_turns", 2),
|
|
167
|
+
cpus=_positive(data, "cpus", 1.0, float),
|
|
168
|
+
memory=str(data.get("memory", "1g")),
|
|
169
|
+
base_dir=base_dir,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_config(path: Path) -> Config:
|
|
174
|
+
if not path.is_file():
|
|
175
|
+
raise DoesItFireError(f"{path} not found. Run 'doesitfire init' to create one.")
|
|
176
|
+
try:
|
|
177
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
178
|
+
except yaml.YAMLError as exc:
|
|
179
|
+
raise DoesItFireError(f"{path} is not valid YAML: {exc}") from None
|
|
180
|
+
return parse_config(data, path.parent.resolve())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
TEMPLATE = """\
|
|
184
|
+
# doesitfire.yaml - which skills to test, and on which agents.
|
|
185
|
+
# Docs: https://github.com/diegoperezg7/doesitfire#configuration
|
|
186
|
+
|
|
187
|
+
repeats: 3 # runs per prompt and agent; activation is not deterministic
|
|
188
|
+
timeout: 300 # seconds per agent run
|
|
189
|
+
max_turns: 2 # agent turns per run; activation happens in the first one
|
|
190
|
+
|
|
191
|
+
agents:
|
|
192
|
+
# Local and free: needs Ollama (https://ollama.com) with a 16k context model,
|
|
193
|
+
# see the README. Paid providers read keys from env vars only.
|
|
194
|
+
claude-code: {{provider: ollama, model: qwen3:4b-16k}} # or provider: anthropic, model: claude-haiku-5-5
|
|
195
|
+
codex: {{provider: ollama, model: qwen3:4b-16k}} # or provider: openai
|
|
196
|
+
opencode: {{provider: ollama, model: qwen3:4b-16k}} # or provider: mistral / openrouter
|
|
197
|
+
|
|
198
|
+
skills:
|
|
199
|
+
{skills}
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
SKILL_TEMPLATE = """\
|
|
203
|
+
- path: {path}
|
|
204
|
+
should_fire: # prompts that SHOULD load this skill
|
|
205
|
+
- "TODO: a request this skill is made for"
|
|
206
|
+
- "TODO: the same need, phrased differently"
|
|
207
|
+
should_not_fire: # near misses that should NOT load it
|
|
208
|
+
- "TODO: a related request the skill does not cover"
|
|
209
|
+
- "TODO: an unrelated coding question"
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def render_template(skill_dirs: list[Path], base_dir: Path) -> str:
|
|
214
|
+
if skill_dirs:
|
|
215
|
+
parts = []
|
|
216
|
+
for d in skill_dirs:
|
|
217
|
+
try:
|
|
218
|
+
rel = d.relative_to(base_dir)
|
|
219
|
+
except ValueError:
|
|
220
|
+
rel = d
|
|
221
|
+
parts.append(SKILL_TEMPLATE.format(path=rel.as_posix()))
|
|
222
|
+
skills = "".join(parts).rstrip("\n")
|
|
223
|
+
else:
|
|
224
|
+
skills = SKILL_TEMPLATE.format(path="path/to/my-skill").rstrip("\n")
|
|
225
|
+
return TEMPLATE.format(skills=skills)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def find_skill_dirs(root: Path) -> list[Path]:
|
|
229
|
+
"""Skill directories under root (anything containing a SKILL.md), skipping hidden and venv dirs."""
|
|
230
|
+
found = []
|
|
231
|
+
for skill_md in sorted(root.rglob("SKILL.md")):
|
|
232
|
+
rel_parts = skill_md.relative_to(root).parts
|
|
233
|
+
if any(p in ("node_modules", ".venv", "venv", ".git") for p in rel_parts):
|
|
234
|
+
continue
|
|
235
|
+
found.append(skill_md.parent)
|
|
236
|
+
return found
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"AGENTS",
|
|
241
|
+
"AgentConfig",
|
|
242
|
+
"CONFIG_NAME",
|
|
243
|
+
"Config",
|
|
244
|
+
"SUPPORTED",
|
|
245
|
+
"Skill",
|
|
246
|
+
"find_skill_dirs",
|
|
247
|
+
"load_config",
|
|
248
|
+
"parse_config",
|
|
249
|
+
"parse_frontmatter",
|
|
250
|
+
"render_template",
|
|
251
|
+
]
|