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/__init__.py
ADDED
doesitfire/agents.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""How to run each agent CLI headless and how to tell, from its JSON events,
|
|
2
|
+
whether it activated a skill.
|
|
3
|
+
|
|
4
|
+
- Claude Code: a ``Skill`` tool_use in ``claude -p --output-format stream-json``.
|
|
5
|
+
- OpenCode: its native ``skill`` tool in ``opencode run --format json``.
|
|
6
|
+
- Codex: no activation event exists, so we look for the agent reading
|
|
7
|
+
``<skill>/SKILL.md`` in the ``command_execution`` items of ``codex exec --json``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .config import AgentConfig
|
|
17
|
+
from .providers import Provider, container_url, resolve_key
|
|
18
|
+
|
|
19
|
+
CLI_VERSIONS = {"claude-code": "2.1.317", "codex": "0.166.0", "opencode": "1.19.5"}
|
|
20
|
+
SKILLS_DIRS = {"claude-code": ".claude/skills", "codex": ".agents/skills", "opencode": ".opencode/skills"}
|
|
21
|
+
EXPERIMENTAL = {"codex"} # activation is inferred from a file read, not reported by the CLI
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Invocation:
|
|
26
|
+
argv: list[str]
|
|
27
|
+
env: dict[str, str] = field(default_factory=dict) # plain values (no secrets)
|
|
28
|
+
secret_env: dict[str, str] = field(default_factory=dict) # passed to docker by name only
|
|
29
|
+
allow: list[str] = field(default_factory=list) # base URLs the egress proxy must allow
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Observation:
|
|
34
|
+
activated: list[str] = field(default_factory=list) # skill names, in order, deduplicated
|
|
35
|
+
input_tokens: int = 0
|
|
36
|
+
output_tokens: int = 0
|
|
37
|
+
reported_cost: float | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
tool_calls: int = 0
|
|
40
|
+
usage_seen: bool = False
|
|
41
|
+
|
|
42
|
+
def add(self, skill: str) -> None:
|
|
43
|
+
if skill not in self.activated:
|
|
44
|
+
self.activated.append(skill)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Agent:
|
|
48
|
+
name = ""
|
|
49
|
+
|
|
50
|
+
def __init__(self, cfg: AgentConfig, provider: Provider, max_turns: int):
|
|
51
|
+
self.cfg = cfg
|
|
52
|
+
self.provider = provider
|
|
53
|
+
self.max_turns = max_turns
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def skills_dir(self) -> str:
|
|
57
|
+
return SKILLS_DIRS[self.name]
|
|
58
|
+
|
|
59
|
+
def build(self, prompt: str) -> Invocation: # pragma: no cover - abstract
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def feed(self, line: str, obs: Observation, skills: set[str]) -> None:
|
|
63
|
+
"""Update the observation with one line of the agent's stdout."""
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line.startswith("{"):
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
event = json.loads(line)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return
|
|
71
|
+
if isinstance(event, dict):
|
|
72
|
+
self.on_event(event, obs, skills)
|
|
73
|
+
|
|
74
|
+
def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None: # pragma: no cover
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def parse(self, stdout: str, skills: set[str]) -> Observation:
|
|
78
|
+
obs = Observation()
|
|
79
|
+
for line in stdout.splitlines():
|
|
80
|
+
self.feed(line, obs, skills)
|
|
81
|
+
return obs
|
|
82
|
+
|
|
83
|
+
def should_stop(self, obs: Observation, skills: set[str]) -> bool:
|
|
84
|
+
"""Stop early once the outcome is known: a tested skill fired, or the turn cap was hit.
|
|
85
|
+
|
|
86
|
+
Only for local models; with paid providers we let the CLI finish so it reports usage.
|
|
87
|
+
"""
|
|
88
|
+
if not self.provider.local:
|
|
89
|
+
return False
|
|
90
|
+
return any(s in skills for s in obs.activated) or obs.tool_calls > self.max_turns
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _norm_skill(value) -> str:
|
|
94
|
+
name = str(value or "").strip().lstrip("/").lower()
|
|
95
|
+
return name.split(":")[-1].split()[0] if name else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ClaudeCode(Agent):
|
|
99
|
+
name = "claude-code"
|
|
100
|
+
|
|
101
|
+
def build(self, prompt: str) -> Invocation:
|
|
102
|
+
p = self.provider
|
|
103
|
+
base = container_url(p.anthropic_base or "")
|
|
104
|
+
env = {
|
|
105
|
+
"ANTHROPIC_BASE_URL": base,
|
|
106
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
107
|
+
"DISABLE_AUTOUPDATER": "1",
|
|
108
|
+
"DISABLE_TELEMETRY": "1",
|
|
109
|
+
"DISABLE_ERROR_REPORTING": "1",
|
|
110
|
+
"API_TIMEOUT_MS": "900000",
|
|
111
|
+
}
|
|
112
|
+
secret: dict[str, str] = {}
|
|
113
|
+
key = resolve_key(self.name, p)
|
|
114
|
+
if p.name == "ollama":
|
|
115
|
+
env["ANTHROPIC_AUTH_TOKEN"] = "ollama"
|
|
116
|
+
elif p.name == "openrouter" and key:
|
|
117
|
+
secret["ANTHROPIC_AUTH_TOKEN"] = key[1]
|
|
118
|
+
env["ANTHROPIC_API_KEY"] = ""
|
|
119
|
+
elif key:
|
|
120
|
+
secret[key[0]] = key[1]
|
|
121
|
+
if "claude" not in self.cfg.model.lower():
|
|
122
|
+
# Non-Claude models: route every internal model slot to the same model, and give
|
|
123
|
+
# the skill listing the budget a 200k-context Claude model would get. Without it
|
|
124
|
+
# Claude Code lists skills by name only, which no real user would see.
|
|
125
|
+
for slot in (
|
|
126
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
127
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
128
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
129
|
+
"CLAUDE_CODE_SUBAGENT_MODEL",
|
|
130
|
+
):
|
|
131
|
+
env[slot] = self.cfg.model
|
|
132
|
+
env["SLASH_COMMAND_TOOL_CHAR_BUDGET"] = "8000"
|
|
133
|
+
tools = "Skill,Read,Glob,Grep"
|
|
134
|
+
argv = [
|
|
135
|
+
"claude",
|
|
136
|
+
"-p",
|
|
137
|
+
prompt,
|
|
138
|
+
"--output-format",
|
|
139
|
+
"stream-json",
|
|
140
|
+
"--verbose",
|
|
141
|
+
"--model",
|
|
142
|
+
self.cfg.model,
|
|
143
|
+
"--max-turns",
|
|
144
|
+
str(self.max_turns),
|
|
145
|
+
"--tools",
|
|
146
|
+
tools,
|
|
147
|
+
"--allowedTools",
|
|
148
|
+
tools,
|
|
149
|
+
"--disallowedTools",
|
|
150
|
+
"Bash,Write,Edit,NotebookEdit,WebFetch,WebSearch,Task",
|
|
151
|
+
"--strict-mcp-config",
|
|
152
|
+
]
|
|
153
|
+
return Invocation(argv, env, secret, [base])
|
|
154
|
+
|
|
155
|
+
def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
|
|
156
|
+
etype = event.get("type")
|
|
157
|
+
if etype == "assistant":
|
|
158
|
+
for block in (event.get("message") or {}).get("content") or []:
|
|
159
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
160
|
+
obs.tool_calls += 1
|
|
161
|
+
if block.get("name") == "Skill":
|
|
162
|
+
args = block.get("input") or {}
|
|
163
|
+
name = _norm_skill(args.get("skill") or args.get("command") or args.get("name"))
|
|
164
|
+
if name:
|
|
165
|
+
obs.add(name)
|
|
166
|
+
elif etype == "result":
|
|
167
|
+
usage = event.get("usage") or {}
|
|
168
|
+
obs.usage_seen = True
|
|
169
|
+
obs.input_tokens += (
|
|
170
|
+
int(usage.get("input_tokens") or 0)
|
|
171
|
+
+ int(usage.get("cache_read_input_tokens") or 0)
|
|
172
|
+
+ int(usage.get("cache_creation_input_tokens") or 0)
|
|
173
|
+
)
|
|
174
|
+
obs.output_tokens += int(usage.get("output_tokens") or 0)
|
|
175
|
+
if event.get("total_cost_usd") is not None:
|
|
176
|
+
obs.reported_cost = float(event["total_cost_usd"])
|
|
177
|
+
if event.get("is_error") and event.get("subtype") not in ("error_max_turns",):
|
|
178
|
+
obs.error = str(event.get("result") or event.get("subtype") or "error")[:300]
|
|
179
|
+
elif etype == "system" and event.get("subtype") == "api_retry" and event.get("attempt", 0) >= 3:
|
|
180
|
+
obs.error = f"model API retrying (status {event.get('error_status')}, {event.get('error')})"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Codex(Agent):
|
|
184
|
+
name = "codex"
|
|
185
|
+
|
|
186
|
+
def build(self, prompt: str) -> Invocation:
|
|
187
|
+
p = self.provider
|
|
188
|
+
base = container_url(p.openai_base)
|
|
189
|
+
provider_toml = f'{{name="doesitfire",base_url="{base}",wire_api="responses"'
|
|
190
|
+
secret: dict[str, str] = {}
|
|
191
|
+
key = resolve_key(self.name, p)
|
|
192
|
+
if key:
|
|
193
|
+
secret[key[0]] = key[1]
|
|
194
|
+
provider_toml += f',env_key="{key[0]}"'
|
|
195
|
+
provider_toml += "}"
|
|
196
|
+
argv = [
|
|
197
|
+
"codex",
|
|
198
|
+
"exec",
|
|
199
|
+
"--json",
|
|
200
|
+
"--skip-git-repo-check",
|
|
201
|
+
"--ephemeral",
|
|
202
|
+
# Codex's own sandbox (bubblewrap) cannot start in an unprivileged container. The
|
|
203
|
+
# container is the sandbox: read-only root, no capabilities, no network but the model.
|
|
204
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
205
|
+
"-c",
|
|
206
|
+
"analytics.enabled=false",
|
|
207
|
+
"-c",
|
|
208
|
+
"model_provider=doesitfire",
|
|
209
|
+
"-c",
|
|
210
|
+
f"model_providers.doesitfire={provider_toml}",
|
|
211
|
+
"-m",
|
|
212
|
+
self.cfg.model,
|
|
213
|
+
]
|
|
214
|
+
argv.append(prompt)
|
|
215
|
+
return Invocation(argv, {"NO_COLOR": "1"}, secret, [base])
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def skills_in_command(command: str, skills: set[str]) -> list[str]:
|
|
219
|
+
found: list[str] = []
|
|
220
|
+
for match in re.finditer(r"([A-Za-z0-9_.-]+)/SKILL\.md", command):
|
|
221
|
+
name = match.group(1).lower()
|
|
222
|
+
if name not in found:
|
|
223
|
+
found.append(name)
|
|
224
|
+
return found
|
|
225
|
+
|
|
226
|
+
def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
|
|
227
|
+
etype = event.get("type")
|
|
228
|
+
item = event.get("item") or {}
|
|
229
|
+
if etype == "item.started" and item.get("type") in ("command_execution", "mcp_tool_call"):
|
|
230
|
+
obs.tool_calls += 1
|
|
231
|
+
text = item.get("command") or json.dumps(item.get("arguments") or "")
|
|
232
|
+
for name in self.skills_in_command(str(text), skills):
|
|
233
|
+
obs.add(name)
|
|
234
|
+
elif etype == "turn.completed":
|
|
235
|
+
usage = event.get("usage") or {}
|
|
236
|
+
obs.usage_seen = True
|
|
237
|
+
obs.input_tokens += int(usage.get("input_tokens") or 0)
|
|
238
|
+
obs.output_tokens += int(usage.get("output_tokens") or 0)
|
|
239
|
+
elif etype in ("turn.failed", "error") and not str(event.get("message", "")).startswith("Reconnecting"):
|
|
240
|
+
msg = (event.get("error") or {}).get("message") if etype == "turn.failed" else event.get("message")
|
|
241
|
+
obs.error = str(msg or etype)[:300]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class OpenCode(Agent):
|
|
245
|
+
name = "opencode"
|
|
246
|
+
|
|
247
|
+
def build(self, prompt: str) -> Invocation:
|
|
248
|
+
p = self.provider
|
|
249
|
+
secret: dict[str, str] = {}
|
|
250
|
+
key = resolve_key(self.name, p)
|
|
251
|
+
if p.name == "anthropic":
|
|
252
|
+
npm, base = "@ai-sdk/anthropic", container_url((p.anthropic_base or "") + "/v1")
|
|
253
|
+
else:
|
|
254
|
+
npm, base = "@ai-sdk/openai-compatible", container_url(p.openai_base)
|
|
255
|
+
options = {"baseURL": base, "apiKey": "ollama"}
|
|
256
|
+
if key:
|
|
257
|
+
secret[key[0]] = key[1]
|
|
258
|
+
options["apiKey"] = "{env:" + key[0] + "}"
|
|
259
|
+
model_cfg: dict = {"name": self.cfg.model}
|
|
260
|
+
config = {
|
|
261
|
+
"$schema": "https://opencode.ai/config.json",
|
|
262
|
+
"autoupdate": False,
|
|
263
|
+
"share": "disabled",
|
|
264
|
+
"provider": {
|
|
265
|
+
"doesitfire": {
|
|
266
|
+
"npm": npm,
|
|
267
|
+
"name": "doesitfire",
|
|
268
|
+
"options": options,
|
|
269
|
+
"models": {self.cfg.model: model_cfg},
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
"permission": {"edit": "deny", "bash": "deny", "webfetch": "deny"},
|
|
273
|
+
"agent": {"build": {"steps": self.max_turns}, "title": {"disable": True}},
|
|
274
|
+
}
|
|
275
|
+
env = {
|
|
276
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(config),
|
|
277
|
+
"OPENCODE_DISABLE_MODELS_FETCH": "1",
|
|
278
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
|
279
|
+
"OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
|
|
280
|
+
}
|
|
281
|
+
argv = ["opencode", "run", "--format", "json", "-m", f"doesitfire/{self.cfg.model}", prompt]
|
|
282
|
+
return Invocation(argv, env, secret, [base])
|
|
283
|
+
|
|
284
|
+
def on_event(self, event: dict, obs: Observation, skills: set[str]) -> None:
|
|
285
|
+
etype = event.get("type")
|
|
286
|
+
part = event.get("part") or {}
|
|
287
|
+
if etype == "tool_use":
|
|
288
|
+
obs.tool_calls += 1
|
|
289
|
+
if part.get("tool") == "skill":
|
|
290
|
+
name = _norm_skill(((part.get("state") or {}).get("input") or {}).get("name"))
|
|
291
|
+
if name:
|
|
292
|
+
obs.add(name)
|
|
293
|
+
elif etype == "step_finish":
|
|
294
|
+
tokens = part.get("tokens") or {}
|
|
295
|
+
cache = tokens.get("cache") or {}
|
|
296
|
+
obs.usage_seen = True
|
|
297
|
+
obs.input_tokens += int(tokens.get("input") or 0) + int(cache.get("read") or 0)
|
|
298
|
+
obs.output_tokens += int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0)
|
|
299
|
+
if part.get("cost") is not None:
|
|
300
|
+
obs.reported_cost = (obs.reported_cost or 0.0) + float(part["cost"])
|
|
301
|
+
elif etype == "error":
|
|
302
|
+
err = event.get("error") or {}
|
|
303
|
+
obs.error = str((err.get("data") or {}).get("message") or err.get("name") or "error")[:300]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
AGENT_CLASSES: dict[str, type[Agent]] = {"claude-code": ClaudeCode, "codex": Codex, "opencode": OpenCode}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def make_agent(cfg: AgentConfig, provider: Provider, max_turns: int) -> Agent:
|
|
310
|
+
return AGENT_CLASSES[cfg.name](cfg, provider, max_turns)
|
doesitfire/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""doesitfire command line: init, run, report, demo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import webbrowser
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import CONFIG_NAME, find_skill_dirs, load_config, render_template
|
|
21
|
+
from .errors import BudgetExceeded, DoesItFireError
|
|
22
|
+
from .metrics import check_thresholds, summarize
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
add_completion=False,
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
rich_markup_mode="rich",
|
|
28
|
+
help="Does your SKILL.md actually fire? Test skill activation in Claude Code, Codex and OpenCode.",
|
|
29
|
+
)
|
|
30
|
+
console = Console()
|
|
31
|
+
err = Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version(value: bool) -> None:
|
|
35
|
+
if value:
|
|
36
|
+
console.print(f"doesitfire {__version__}")
|
|
37
|
+
raise typer.Exit()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback()
|
|
41
|
+
def _root(
|
|
42
|
+
version: Annotated[
|
|
43
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
|
|
44
|
+
] = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_outputs(results: dict, out: Path) -> dict[str, Path]:
|
|
50
|
+
from .html import write_html
|
|
51
|
+
from .report import write_badges, write_junit
|
|
52
|
+
|
|
53
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
paths = {
|
|
55
|
+
"json": out / "results.json",
|
|
56
|
+
"html": out / "report.html",
|
|
57
|
+
"junit": out / "junit.xml",
|
|
58
|
+
"badge": out / "badge.svg",
|
|
59
|
+
"badge_json": out / "badge.json",
|
|
60
|
+
}
|
|
61
|
+
paths["json"].write_text(json.dumps(results, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
62
|
+
write_html(results, paths["html"])
|
|
63
|
+
write_junit(results, paths["junit"])
|
|
64
|
+
write_badges(results, out)
|
|
65
|
+
return paths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _open(path: Path) -> None:
|
|
69
|
+
if os.environ.get("CI") or not sys.stdout.isatty():
|
|
70
|
+
return
|
|
71
|
+
with contextlib.suppress(Exception): # opening a browser is best effort
|
|
72
|
+
webbrowser.open(path.resolve().as_uri())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def init(
|
|
77
|
+
directory: Annotated[Path, typer.Argument(help="Project root to scan for skills.")] = Path("."),
|
|
78
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing doesitfire.yaml.")] = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create a doesitfire.yaml for the skills found under DIRECTORY."""
|
|
81
|
+
directory = directory.resolve()
|
|
82
|
+
target = directory / CONFIG_NAME
|
|
83
|
+
if target.exists() and not force:
|
|
84
|
+
raise DoesItFireError(f"{target} already exists. Use --force to overwrite it.")
|
|
85
|
+
skills = find_skill_dirs(directory)
|
|
86
|
+
target.write_text(render_template(skills, directory), encoding="utf-8")
|
|
87
|
+
console.print(f"Wrote [bold]{target}[/bold] with {len(skills) or 'a placeholder for'} skill(s).")
|
|
88
|
+
console.print("Next: replace the TODO prompts, then run [bold]doesitfire run[/bold].")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def run(
|
|
93
|
+
config: Annotated[Path, typer.Option("--config", "-c", help="Path to doesitfire.yaml.")] = Path(CONFIG_NAME),
|
|
94
|
+
out: Annotated[Path, typer.Option("--out", "-o", help="Output directory.")] = Path("doesitfire-results"),
|
|
95
|
+
agents: Annotated[str | None, typer.Option(help="Comma-separated subset of agents to run.")] = None,
|
|
96
|
+
skills: Annotated[str | None, typer.Option(help="Comma-separated subset of skills to run.")] = None,
|
|
97
|
+
repeats: Annotated[int | None, typer.Option(help="Runs per prompt and agent (overrides the config).")] = None,
|
|
98
|
+
max_cost: Annotated[float, typer.Option("--max-cost", help="Stop before spending more than this many USD.")] = 1.0,
|
|
99
|
+
min_recall: Annotated[
|
|
100
|
+
float | None, typer.Option(help="Exit 1 if any agent x skill recall is below this (0-1).")
|
|
101
|
+
] = None,
|
|
102
|
+
max_false_fire: Annotated[
|
|
103
|
+
float | None, typer.Option(help="Exit 1 if any false-fire rate is above this (0-1).")
|
|
104
|
+
] = None,
|
|
105
|
+
rebuild: Annotated[bool, typer.Option(help="Rebuild the agent Docker image.")] = False,
|
|
106
|
+
resume: Annotated[bool, typer.Option(help="Continue an interrupted run from OUT/results.partial.json.")] = False,
|
|
107
|
+
open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report when done.")] = True,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Run every agent on every prompt and report how often each skill fires."""
|
|
110
|
+
from .runner import run_matrix
|
|
111
|
+
|
|
112
|
+
cfg = load_config(config)
|
|
113
|
+
if agents:
|
|
114
|
+
wanted = {a.strip() for a in agents.split(",") if a.strip()}
|
|
115
|
+
unknown = wanted - {a.name for a in cfg.agents}
|
|
116
|
+
if unknown:
|
|
117
|
+
raise DoesItFireError(f"Not configured in {config}: {', '.join(sorted(unknown))}.")
|
|
118
|
+
cfg.agents = [a for a in cfg.agents if a.name in wanted]
|
|
119
|
+
if skills:
|
|
120
|
+
wanted = {s.strip() for s in skills.split(",") if s.strip()}
|
|
121
|
+
unknown = wanted - {s.name for s in cfg.skills}
|
|
122
|
+
if unknown:
|
|
123
|
+
raise DoesItFireError(f"Not in {config}: {', '.join(sorted(unknown))}.")
|
|
124
|
+
cfg.skills = [s for s in cfg.skills if s.name in wanted]
|
|
125
|
+
if repeats is not None:
|
|
126
|
+
if repeats < 1:
|
|
127
|
+
raise DoesItFireError("--repeats must be at least 1.")
|
|
128
|
+
cfg.repeats = repeats
|
|
129
|
+
if max_cost < 0:
|
|
130
|
+
raise DoesItFireError("--max-cost cannot be negative.")
|
|
131
|
+
|
|
132
|
+
n = cfg.repeats * len(cfg.agents) * sum(len(s.should_fire) + len(s.should_not_fire) for s in cfg.skills)
|
|
133
|
+
console.print(
|
|
134
|
+
f"doesitfire {__version__}: {len(cfg.skills)} skill(s) x {len(cfg.agents)} agent(s), "
|
|
135
|
+
f"{cfg.repeats} repeat(s) = {n} sandboxed runs. Budget {max_cost:.2f} USD."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def progress(p: dict) -> None:
|
|
139
|
+
t = p["trial"]
|
|
140
|
+
mark = (
|
|
141
|
+
"[red]error[/red]" if t["error"] else ("[magenta]fired[/magenta]" if t["fired"] else "[dim]no fire[/dim]")
|
|
142
|
+
)
|
|
143
|
+
good = (t["fired"] == (t["expect"] == "fire")) and not t["error"]
|
|
144
|
+
tick = "[green]ok[/green]" if good else "[yellow]!![/yellow]"
|
|
145
|
+
console.print(
|
|
146
|
+
f"[dim]{p['done']:>4}/{p['total']}[/dim] {tick} {t['agent']:<11} {t['skill']:<20} {mark:<22} "
|
|
147
|
+
f"[dim]{t['duration_s']:>5.0f}s {escape(' '.join(t['prompt'].split())[:60])}[/dim]"
|
|
148
|
+
+ (f"\n [red]{escape(t['error'])}[/red]" if t["error"] else "")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
partial_file = out / "results.partial.json"
|
|
153
|
+
|
|
154
|
+
def checkpoint(results: dict) -> None:
|
|
155
|
+
partial_file.write_text(json.dumps(results, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
previous = None
|
|
158
|
+
if resume:
|
|
159
|
+
if not partial_file.is_file():
|
|
160
|
+
raise DoesItFireError(f"Nothing to resume: {partial_file} does not exist.")
|
|
161
|
+
previous = _load_results(partial_file)
|
|
162
|
+
partial = None
|
|
163
|
+
try:
|
|
164
|
+
results = run_matrix(
|
|
165
|
+
cfg,
|
|
166
|
+
max_cost=max_cost,
|
|
167
|
+
log=console.print,
|
|
168
|
+
progress=progress,
|
|
169
|
+
checkpoint=checkpoint,
|
|
170
|
+
logs_dir=out / "logs",
|
|
171
|
+
resume=previous,
|
|
172
|
+
rebuild=rebuild,
|
|
173
|
+
)
|
|
174
|
+
except BudgetExceeded as exc:
|
|
175
|
+
partial = exc
|
|
176
|
+
results = exc.results or {}
|
|
177
|
+
if not results:
|
|
178
|
+
raise DoesItFireError("No results.")
|
|
179
|
+
from .report import print_matrix
|
|
180
|
+
|
|
181
|
+
paths = write_outputs(results, out)
|
|
182
|
+
partial_file.unlink(missing_ok=True)
|
|
183
|
+
summary = print_matrix(results, console)
|
|
184
|
+
console.print(f"Report: {paths['html']} JSON: {paths['json']} JUnit: {paths['junit']} Badge: {paths['badge']}")
|
|
185
|
+
if open_report:
|
|
186
|
+
_open(paths["html"])
|
|
187
|
+
if partial is not None:
|
|
188
|
+
err.print(f"[bold red]Budget limit reached, results are partial:[/bold red] {partial}")
|
|
189
|
+
raise typer.Exit(partial.exit_code)
|
|
190
|
+
failures = check_thresholds(summary, min_recall, max_false_fire)
|
|
191
|
+
if failures:
|
|
192
|
+
err.print("[bold red]Thresholds not met:[/bold red]\n " + "\n ".join(failures))
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.command()
|
|
197
|
+
def report(
|
|
198
|
+
results_file: Annotated[Path, typer.Argument(help="A results.json written by 'doesitfire run'.")],
|
|
199
|
+
out: Annotated[
|
|
200
|
+
Path | None, typer.Option("--out", "-o", help="Output directory (default: next to the JSON).")
|
|
201
|
+
] = None,
|
|
202
|
+
open_report: Annotated[bool, typer.Option("--open/--no-open")] = False,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Re-render the matrix, HTML, JUnit and badge from a results.json."""
|
|
205
|
+
from .report import print_matrix
|
|
206
|
+
|
|
207
|
+
results = _load_results(results_file)
|
|
208
|
+
paths = write_outputs(results, out or results_file.parent)
|
|
209
|
+
print_matrix(results, console)
|
|
210
|
+
console.print(f"Report: {paths['html']}")
|
|
211
|
+
if open_report:
|
|
212
|
+
_open(paths["html"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _load_results(path: Path) -> dict:
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
raise DoesItFireError(f"{path} not found.") from None
|
|
220
|
+
except json.JSONDecodeError as exc:
|
|
221
|
+
raise DoesItFireError(f"{path} is not valid JSON: {exc}") from None
|
|
222
|
+
if not isinstance(data, dict) or data.get("tool") != "doesitfire":
|
|
223
|
+
raise DoesItFireError(f"{path} is not a doesitfire results file.")
|
|
224
|
+
summarize(data) # validates the structure early
|
|
225
|
+
return data
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
def demo(
|
|
230
|
+
out: Annotated[
|
|
231
|
+
Path | None, typer.Option("--out", "-o", help="Where to write the report (default: a temp dir).")
|
|
232
|
+
] = None,
|
|
233
|
+
open_report: Annotated[bool, typer.Option("--open/--no-open", help="Open the HTML report.")] = True,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Show a RECORDED real run of the three example skills. No keys, no Docker, no model."""
|
|
236
|
+
from .report import print_matrix
|
|
237
|
+
|
|
238
|
+
data = resources.files("doesitfire") / "data" / "demo-results.json"
|
|
239
|
+
results = json.loads(data.read_text(encoding="utf-8"))
|
|
240
|
+
target = out or Path(tempfile.mkdtemp(prefix="doesitfire-demo-"))
|
|
241
|
+
paths = write_outputs(results, target)
|
|
242
|
+
print_matrix(results, console)
|
|
243
|
+
console.print(f"Report: {paths['html']}")
|
|
244
|
+
console.print("Run it yourself: [bold]doesitfire run -c examples/doesitfire.yaml[/bold] (needs Docker + Ollama).")
|
|
245
|
+
if open_report:
|
|
246
|
+
_open(paths["html"])
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main() -> None:
|
|
250
|
+
try:
|
|
251
|
+
app()
|
|
252
|
+
except DoesItFireError as exc:
|
|
253
|
+
err.print(f"[bold red]Error:[/bold red] {escape(str(exc))}")
|
|
254
|
+
sys.exit(exc.exit_code)
|
|
255
|
+
except KeyboardInterrupt:
|
|
256
|
+
err.print("[yellow]Interrupted.[/yellow]")
|
|
257
|
+
sys.exit(130)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__": # pragma: no cover
|
|
261
|
+
main()
|
|
262
|
+
|
|
263
|
+
__all__ = ["app", "main"]
|