tare-cli 0.2.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.
- tare/__init__.py +1 -0
- tare/agents.py +672 -0
- tare/calibrate.py +98 -0
- tare/capsule.py +246 -0
- tare/cli.py +312 -0
- tare/cliff.py +183 -0
- tare/dashboard.html +269 -0
- tare/dashboard.py +216 -0
- tare/fake.py +183 -0
- tare/journal.py +35 -0
- tare/judge.py +108 -0
- tare/overview.html +78 -0
- tare/probe.py +273 -0
- tare/recipe.py +102 -0
- tare/room.py +67 -0
- tare/swap.py +219 -0
- tare/trail.py +203 -0
- tare/usage.py +237 -0
- tare_cli-0.2.0.dist-info/METADATA +505 -0
- tare_cli-0.2.0.dist-info/RECORD +23 -0
- tare_cli-0.2.0.dist-info/WHEEL +4 -0
- tare_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tare_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
tare/calibrate.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Calibrate: an agent's pass rate on a task, before Cliff or Swap spend tails on it (epic Calibrate).
|
|
2
|
+
|
|
3
|
+
Each side (an agent with its arguments, e.g. `claude --model haiku`) gets N fresh starts of
|
|
4
|
+
the task in rooms, scored by the check. The report gives each rate with its interval, so a
|
|
5
|
+
task can be chosen where one side mostly fails and the other mostly passes.
|
|
6
|
+
|
|
7
|
+
A side may have its own prompt, so two skills invoked differently compare in one call. Without
|
|
8
|
+
a check every run that ends counts as finished, and the workspaces are kept for scoring
|
|
9
|
+
elsewhere.
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import statistics
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import capsule as caps
|
|
20
|
+
from . import usage as usages
|
|
21
|
+
from .cliff import wilson
|
|
22
|
+
from .journal import Journal
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Side:
|
|
27
|
+
key: str
|
|
28
|
+
agent: object
|
|
29
|
+
real: object
|
|
30
|
+
args: list[str]
|
|
31
|
+
prompt: str | None = None # the side's own prompt; None: the shared one
|
|
32
|
+
results: list[bool] = field(default_factory=list)
|
|
33
|
+
scores: list[int] = field(default_factory=list) # judge scores, where the check is a judge
|
|
34
|
+
times: list[float] = field(default_factory=list) # seconds per run
|
|
35
|
+
|
|
36
|
+
def label(self) -> str:
|
|
37
|
+
return " ".join([self.agent.name, *self.args])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _minutes(seconds: float) -> str:
|
|
41
|
+
return f"{int(seconds // 60)}:{int(seconds % 60):02d}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calibrate(sides: list[Side], project: Path, prompt: str | None, check: str | None, out: Path, *, runs: int = 10,
|
|
45
|
+
jobs: int = 3, env: dict[str, str] | None = None, journal: Journal | None = None,
|
|
46
|
+
keep: bool = False) -> str:
|
|
47
|
+
journal = journal or Journal(None)
|
|
48
|
+
keep = keep or check is None # without a check the workspaces are what is scored
|
|
49
|
+
journal("start", kind="calibrate", task=prompt, check=check, project=str(project), tails=runs,
|
|
50
|
+
sides={s.key: {"agent": s.agent.name, "args": s.args, "dir": ".", "prompt": s.prompt,
|
|
51
|
+
"billing": usages.billing(s.agent, s.real)} for s in sides})
|
|
52
|
+
work = out / "project"
|
|
53
|
+
shutil.copytree(project, work, symlinks=True)
|
|
54
|
+
caps.archive(work, out / "start.tar")
|
|
55
|
+
shutil.rmtree(work)
|
|
56
|
+
start = caps.Capsule(0, None, out / "start.tar", 0, 0, "start")
|
|
57
|
+
journal("phase", phase="runs")
|
|
58
|
+
|
|
59
|
+
def run(job: tuple[Side, int]) -> tuple[Side, caps.Tail]:
|
|
60
|
+
side, i = job
|
|
61
|
+
name = f"{side.key}-{i}"
|
|
62
|
+
journal("tail", id=name, status="running", side=side.key, agent=side.key, agent_name=side.agent.name,
|
|
63
|
+
kind="fresh", dir=f"calibrate/{name}")
|
|
64
|
+
began = time.monotonic()
|
|
65
|
+
tail = caps.run_tail(side.agent, side.real, out, start, side.prompt or prompt, side.args, check, i, env,
|
|
66
|
+
tail_dir=out / "calibrate" / name, keep=keep)
|
|
67
|
+
journal("tail", id=name, status="passed" if tail.passed else "failed", detail=tail.detail,
|
|
68
|
+
usage=tail.usage, check_usage=tail.check_usage)
|
|
69
|
+
return side, tail, time.monotonic() - began
|
|
70
|
+
|
|
71
|
+
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
|
72
|
+
for side, tail, seconds in pool.map(run, [(s, i) for i in range(runs) for s in sides]):
|
|
73
|
+
side.results.append(tail.passed)
|
|
74
|
+
side.times.append(seconds)
|
|
75
|
+
if score := re.search(r"score (\d+)", tail.detail):
|
|
76
|
+
side.scores.append(int(score.group(1)))
|
|
77
|
+
|
|
78
|
+
width = max(len(s.label()) for s in sides) + 2
|
|
79
|
+
lines = [f"tare calibrate · {project}"]
|
|
80
|
+
if prompt and not all(s.prompt for s in sides):
|
|
81
|
+
lines.append(f" task {prompt}")
|
|
82
|
+
lines += [f" task {s.key:<4} {s.prompt}" for s in sides if s.prompt]
|
|
83
|
+
lines += [f" check {check or 'none. A run counts as finished when its agent exits 0. Its workspace is kept for scoring elsewhere.'}",
|
|
84
|
+
"", f" side {'agent':<{width}}" + ("pass rate 95% interval" if check else "finished time (median, range)")]
|
|
85
|
+
for s in sides:
|
|
86
|
+
x, n = sum(s.results), len(s.results)
|
|
87
|
+
if check is None:
|
|
88
|
+
lines.append(f" {s.key:<4} {s.label():<{width}}{x:>4}/{n:<4} {_minutes(statistics.median(s.times))}"
|
|
89
|
+
f" ({_minutes(min(s.times))}-{_minutes(max(s.times))})")
|
|
90
|
+
continue
|
|
91
|
+
lo, hi = wilson(x, n)
|
|
92
|
+
lines.append(f" {s.key:<4} {s.label():<{width}}{x:>2}/{n:<2} {x / n:.2f} {lo:.2f}-{hi:.2f}"
|
|
93
|
+
+ (f" scores {' '.join(map(str, sorted(s.scores)))}" if s.scores else ""))
|
|
94
|
+
text = "\n".join(lines + usages.report(out)) + "\n"
|
|
95
|
+
(out / "report.txt").write_text(text)
|
|
96
|
+
journal("report", text=text)
|
|
97
|
+
journal("phase", phase="done")
|
|
98
|
+
return text
|
tare/capsule.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Capsules: a run in the room, resumable at every step (epics Cliff and Swap).
|
|
2
|
+
|
|
3
|
+
During the original run a PostToolUse hook archives /work after every tool call into a
|
|
4
|
+
run directory outside the room. A step is one model tool call, or several issued together;
|
|
5
|
+
its capsule is the archive taken after its last tool plus the session up to its results.
|
|
6
|
+
Capsule 0 is the project before the run. A capsule is continued either natively (the
|
|
7
|
+
agent's own session, cut after the step; Claude Code) or by handoff (a fresh session
|
|
8
|
+
started with the trail rendered up to the step; every agent).
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tarfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import trail as trails
|
|
20
|
+
from . import usage as usages
|
|
21
|
+
from .room import ROOM_HOME, bwrap, room_home
|
|
22
|
+
|
|
23
|
+
RUN_MOUNT = "/tare-run"
|
|
24
|
+
HOOK_COMMAND = f"/bin/sh {RUN_MOUNT}/hook.sh"
|
|
25
|
+
# Archives /work after every tool call, numbered, named by the call's tool_use_id.
|
|
26
|
+
# The hook input is JSON on stdin; its top-level tool_use_id is the first one in it.
|
|
27
|
+
HOOK = """#!/bin/sh
|
|
28
|
+
id=$(grep -o '"tool_use_id": *"[^"]*"' | head -1 | sed 's/.*"\\([^"]*\\)"$/\\1/')
|
|
29
|
+
n=$(ls /tare-run/capsules | wc -l)
|
|
30
|
+
tar -C /work -cf "/tare-run/capsules/$(printf %04d "$n")-$id.tar" .
|
|
31
|
+
"""
|
|
32
|
+
CONTINUE = "Continue."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Capsule:
|
|
37
|
+
step: int
|
|
38
|
+
tool_use_id: str | None # None for step 0
|
|
39
|
+
archive: Path # tar of /work; identical workspaces share one archive
|
|
40
|
+
cut: int # native session lines kept (0 for step 0, or when there is no native resume)
|
|
41
|
+
trail_cut: int # trail events up to this step
|
|
42
|
+
tool: str # what the step did, for reports
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Tail:
|
|
47
|
+
step: int
|
|
48
|
+
passed: bool
|
|
49
|
+
detail: str
|
|
50
|
+
usage: dict | None = None # the agent's tokens and cost
|
|
51
|
+
check_usage: dict | None = None # what the check spent, for example a judge agent
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _contents(path: Path) -> dict[str, str]:
|
|
55
|
+
"""Path -> content digest of every file in a capsule archive (not its timestamps)."""
|
|
56
|
+
with tarfile.open(path) as tar:
|
|
57
|
+
return {m.name.removeprefix("./"): hashlib.sha256(tar.extractfile(m).read()).hexdigest()
|
|
58
|
+
for m in tar.getmembers() if m.isfile()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _digest(path: Path) -> str:
|
|
62
|
+
return hashlib.sha256(json.dumps(sorted(_contents(path).items())).encode()).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def archive(directory: Path, target: Path):
|
|
66
|
+
with tarfile.open(target, "w") as tar:
|
|
67
|
+
tar.add(directory, arcname=".")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unpack(source: Path, directory: Path):
|
|
71
|
+
with tarfile.open(source) as tar:
|
|
72
|
+
tar.extractall(directory, filter="tar")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _config(agent, home: Path) -> Path:
|
|
76
|
+
return home / Path(agent.room_config).relative_to(ROOM_HOME)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def record(agent, real, project: Path, out: Path, prompt: str, args: list[str],
|
|
80
|
+
env: dict[str, str] | None = None) -> int:
|
|
81
|
+
"""The original run, in a room, on a copy of the project, with a capsule per tool call.
|
|
82
|
+
Returns the agent's exit code."""
|
|
83
|
+
store = out / "store"
|
|
84
|
+
(store / "capsules").mkdir(parents=True)
|
|
85
|
+
(store / "hook.sh").write_text(HOOK)
|
|
86
|
+
work = out / "original" / "work"
|
|
87
|
+
shutil.copytree(project, work, symlinks=True)
|
|
88
|
+
archive(work, store / "capsules" / "0000-start.tar")
|
|
89
|
+
(out / "original" / "task.txt").write_text(prompt)
|
|
90
|
+
with room_home(agent, real) as home:
|
|
91
|
+
agent.prepare_hook(_config(agent, home), HOOK_COMMAND)
|
|
92
|
+
command = [agent.name, *agent.room_flags, *agent.run_args(prompt, args, hook=HOOK_COMMAND)]
|
|
93
|
+
code = _stream(bwrap(agent, real, home, work, command, env, ["--bind", str(store), RUN_MOUNT]),
|
|
94
|
+
out / "original", None)
|
|
95
|
+
session = agent.session_file(home)
|
|
96
|
+
if session:
|
|
97
|
+
shutil.copyfile(session, out / "original" / "session.jsonl")
|
|
98
|
+
return code
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _stream(argv: list[str], log_dir: Path, timeout: float | None) -> int:
|
|
102
|
+
"""Run a room with the agent's stdout and stderr written to log_dir as they come (the dashboard reads them)."""
|
|
103
|
+
with (log_dir / "stdout.jsonl").open("w") as out, (log_dir / "stderr.log").open("w") as err:
|
|
104
|
+
proc = subprocess.Popen(argv, stdout=out, stderr=err, stdin=subprocess.DEVNULL)
|
|
105
|
+
try:
|
|
106
|
+
return proc.wait(timeout=timeout)
|
|
107
|
+
except subprocess.TimeoutExpired:
|
|
108
|
+
proc.kill()
|
|
109
|
+
proc.wait()
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def session_lines(out: Path) -> list[str]:
|
|
114
|
+
path = out / "original" / "session.jsonl"
|
|
115
|
+
return path.read_text().splitlines() if path.exists() else []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def capsules(out: Path, agent) -> list[Capsule]:
|
|
119
|
+
"""The resumable steps of the original run, in order."""
|
|
120
|
+
archives = sorted((out / "store" / "capsules").glob("*.tar"))
|
|
121
|
+
by_id = {path.stem.split("-", 1)[1]: path for path in archives[1:]}
|
|
122
|
+
result = [Capsule(0, None, archives[0], 0, 0, "start")]
|
|
123
|
+
seen = {_digest(archives[0]): archives[0]}
|
|
124
|
+
previous = archives[0]
|
|
125
|
+
for step in agent.trail(session_lines(out)).steps:
|
|
126
|
+
taken = [by_id[i] for i in step.ids if i in by_id]
|
|
127
|
+
# the last snapshot of the step; a step whose tools the hook never saw left the workspace as it was
|
|
128
|
+
path = max(taken) if taken else previous
|
|
129
|
+
shared = seen.setdefault(_digest(path), path)
|
|
130
|
+
if shared != path:
|
|
131
|
+
path.unlink() # same workspace as an earlier capsule: keep one archive
|
|
132
|
+
result.append(Capsule(len(result), step.ids[-1], shared, step.native_cut or 0, step.trail_cut, step.tool))
|
|
133
|
+
previous = shared
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def session_id(out: Path) -> str:
|
|
138
|
+
for line in session_lines(out):
|
|
139
|
+
entry = json.loads(line)
|
|
140
|
+
# Claude Code: sessionId on every entry; Codex: the session_meta payload; Pi: the session header
|
|
141
|
+
sid = (entry.get("sessionId") or (entry.get("payload") or {}).get("id")
|
|
142
|
+
or (entry.get("id") if entry.get("type") == "session" else None))
|
|
143
|
+
if sid:
|
|
144
|
+
return sid
|
|
145
|
+
raise ValueError("no session id in the session file")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_in(agent, real, home: Path, work: Path, command: list[str], env, timeout: float, log_dir: Path) -> str:
|
|
149
|
+
try:
|
|
150
|
+
return f"agent exit {_stream(bwrap(agent, real, home, work, command, env), log_dir, timeout)}"
|
|
151
|
+
except subprocess.TimeoutExpired:
|
|
152
|
+
return f"agent stopped after {int(timeout)} s"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_usage(agent, log_dir: Path) -> dict | None:
|
|
156
|
+
"""The tokens and cost of the run whose stream is in log_dir (None for an agent that cannot tell)."""
|
|
157
|
+
stream = log_dir / "stdout.jsonl"
|
|
158
|
+
if not hasattr(agent, "usage"):
|
|
159
|
+
return None
|
|
160
|
+
return agent.usage(stream.read_text(errors="replace") if stream.exists() else "").to_dict()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def read_check_usage(path: Path) -> dict | None:
|
|
164
|
+
if not path.exists():
|
|
165
|
+
return None
|
|
166
|
+
found = usages.total(json.loads(line) for line in path.read_text().splitlines() if line.strip())
|
|
167
|
+
return found.to_dict() if found else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _finish(tail_dir: Path, work: Path, step: int, ran: str, check: str, extra: dict | None = None,
|
|
171
|
+
keep: bool = False, usage: dict | None = None) -> Tail:
|
|
172
|
+
# without a check (calibrate only) a run counts when its agent ended normally
|
|
173
|
+
log = tail_dir / "check-usage.jsonl"
|
|
174
|
+
passed, detail = run_check(check, work, usage_log=log) if check else (ran == "agent exit 0", "no check")
|
|
175
|
+
check_usage = read_check_usage(log)
|
|
176
|
+
(tail_dir / "result.json").write_text(json.dumps({"step": step, "passed": passed, "agent": ran, "check": detail,
|
|
177
|
+
"usage": usage, "check_usage": check_usage, **(extra or {})}))
|
|
178
|
+
if not keep:
|
|
179
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
180
|
+
return Tail(step, passed, f"{ran}; {detail}", usage, check_usage)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run_tail(agent, real, out: Path, capsule: Capsule, prompt: str, args: list[str], check: str,
|
|
184
|
+
index: int, env: dict[str, str] | None = None, timeout: float = 1800, tail_dir: Path | None = None,
|
|
185
|
+
keep: bool = False) -> Tail:
|
|
186
|
+
"""Continue a capsule natively in a fresh room (the agent's own session, cut after the step),
|
|
187
|
+
run it to the end, score the workspace with the check. Step 0 is a fresh start."""
|
|
188
|
+
tail_dir = tail_dir or out / "tails" / f"{capsule.step:04d}-{index}"
|
|
189
|
+
work = tail_dir / "work"
|
|
190
|
+
work.mkdir(parents=True)
|
|
191
|
+
unpack(capsule.archive, work)
|
|
192
|
+
with room_home(agent, real) as home:
|
|
193
|
+
if capsule.step == 0:
|
|
194
|
+
command = [agent.name, *agent.room_flags, *agent.run_args(prompt, args)]
|
|
195
|
+
else:
|
|
196
|
+
sid = session_id(out)
|
|
197
|
+
agent.place_session(home, sid, session_lines(out)[:capsule.cut])
|
|
198
|
+
command = [agent.name, *agent.room_flags, *agent.resume_args(sid, CONTINUE, args)]
|
|
199
|
+
ran = _run_in(agent, real, home, work, command, env, timeout, tail_dir)
|
|
200
|
+
return _finish(tail_dir, work, capsule.step, ran, check, keep=keep, usage=read_usage(agent, tail_dir))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_original(agent, out: Path, check: str) -> tuple[bool, str, dict]:
|
|
204
|
+
"""The check on the original run's workspace, and the usage of that run and of its check."""
|
|
205
|
+
log = out / "original" / "check-usage.jsonl"
|
|
206
|
+
passed, detail = run_check(check, out / "original" / "work", usage_log=log)
|
|
207
|
+
return passed, detail, {"usage": read_usage(agent, out / "original"), "check_usage": read_check_usage(log)}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_handoff(agent, real, tail_dir: Path, capsule: Capsule, events: list[trails.Event], task: str,
|
|
211
|
+
args: list[str], check: str, env: dict[str, str] | None = None, workspace_only: bool = False,
|
|
212
|
+
timeout: float = 1800) -> Tail:
|
|
213
|
+
"""Continue a capsule by handoff: a fresh session of any agent, in the capsule's workspace,
|
|
214
|
+
started with the trail rendered up to the step."""
|
|
215
|
+
work = tail_dir / "work"
|
|
216
|
+
work.mkdir(parents=True)
|
|
217
|
+
unpack(capsule.archive, work)
|
|
218
|
+
handoff = trails.render(task, events[:capsule.trail_cut], workspace_only)
|
|
219
|
+
(tail_dir / "handoff.txt").write_text(handoff)
|
|
220
|
+
with room_home(agent, real) as home:
|
|
221
|
+
ran = _run_in(agent, real, home, work, [agent.name, *agent.room_flags, *agent.run_args(handoff, args)],
|
|
222
|
+
env, timeout, tail_dir)
|
|
223
|
+
return _finish(tail_dir, work, capsule.step, ran, check, {"agent_name": agent.name},
|
|
224
|
+
usage=read_usage(agent, tail_dir))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# a check that runs an agent itself (tare judge) appends that agent's usage to this file
|
|
228
|
+
USAGE_LOG = "TARE_USAGE_LOG"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run_check(check: str, work: Path, timeout: float = 600, usage_log: Path | None = None) -> tuple[bool, str]:
|
|
232
|
+
"""The user's check, outside the room: exit code 0 passes."""
|
|
233
|
+
env = {**os.environ, USAGE_LOG: str(usage_log)} if usage_log else None
|
|
234
|
+
try:
|
|
235
|
+
proc = subprocess.run(check, shell=True, cwd=work, capture_output=True, text=True, timeout=timeout,
|
|
236
|
+
stdin=subprocess.DEVNULL, env=env)
|
|
237
|
+
except subprocess.TimeoutExpired:
|
|
238
|
+
return False, f"check timed out after {int(timeout)} s"
|
|
239
|
+
last = next((line.strip() for line in reversed(proc.stdout.splitlines()) if line.strip()), "")
|
|
240
|
+
return proc.returncode == 0, f"check exit {proc.returncode}" + (f": {last[:120]}" if last else "")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def changed_files(before: Path, after: Path) -> list[str]:
|
|
244
|
+
"""Paths whose content differs between two capsule archives."""
|
|
245
|
+
a, b = _contents(before), _contents(after)
|
|
246
|
+
return sorted(name for name in a.keys() | b.keys() if a.get(name) != b.get(name))
|
tare/cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""tare: zero the scale before you weigh.
|
|
2
|
+
|
|
3
|
+
tare probe {claude,codex,pi,agy} [--project DIR]
|
|
4
|
+
tare {claude,codex,pi,agy} [--project DIR] [--allow-dirty] [--yolo] [-- AGENT_ARGS...]
|
|
5
|
+
tare cliff {claude,codex,pi,agy} PROMPT --check CMD [--tails N] [--budget N] [--jobs N] [-- AGENT_ARGS...]
|
|
6
|
+
tare swap PROMPT --check CMD [--a claude] [--b codex] [--a-args ARGS] [--b-args ARGS] [--cuts 0,0.5,1]
|
|
7
|
+
tare calibrate [PROMPT] [--check CMD] --side "claude --model haiku" --side "codex" [--side-prompt b=TEXT] [--runs N]
|
|
8
|
+
tare judge [DIR] --rubric FILE --threshold N [--judge "claude --model sonnet"] a check: exit 0 at or above N
|
|
9
|
+
tare judge-noise DIR... --rubric FILE --times K [--threshold N] how far the judge's scores vary
|
|
10
|
+
tare watch DIR... [--port N] the dashboard of a run, live or finished (several runs on one page)
|
|
11
|
+
tare rerun DIR [--out DIR] repeat a run from its recipe
|
|
12
|
+
tare prices [update] the price tables for costs that an agent does not report
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import shlex
|
|
17
|
+
import threading
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import dashboard, recipe
|
|
25
|
+
from . import usage as usages
|
|
26
|
+
from .agents import AGENTS
|
|
27
|
+
from . import calibrate as calibration
|
|
28
|
+
from .cliff import cliff
|
|
29
|
+
from .journal import Journal
|
|
30
|
+
from .swap import Side, swap
|
|
31
|
+
from .probe import probe, render
|
|
32
|
+
from .room import TareError, bwrap, room_home
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _exit_on(signum, _frame):
|
|
36
|
+
# SystemExit unwinds through room_home, so the login copy is removed
|
|
37
|
+
raise SystemExit(128 + signum)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_attached(argv: list[str]) -> int:
|
|
41
|
+
"""Run the room in the foreground. Ctrl-C belongs to the agent (it interrupts a
|
|
42
|
+
turn there), so tare ignores it instead of dying and removing the room under it."""
|
|
43
|
+
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
44
|
+
try:
|
|
45
|
+
return subprocess.call(argv)
|
|
46
|
+
finally:
|
|
47
|
+
signal.signal(signal.SIGINT, previous)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _serve(out: Path | list[Path], port: int) -> str:
|
|
51
|
+
try:
|
|
52
|
+
return dashboard.serve(out, port)[1]
|
|
53
|
+
except OSError:
|
|
54
|
+
return dashboard.serve(out, 0)[1] # the usual port is taken
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str] | None = None) -> int:
|
|
58
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
59
|
+
original_argv = list(argv)
|
|
60
|
+
if argv[:1] == ["watch"]:
|
|
61
|
+
return _watch(argv[1:])
|
|
62
|
+
if argv[:1] == ["rerun"]:
|
|
63
|
+
return _rerun(argv[1:])
|
|
64
|
+
if argv[:1] == ["prices"]:
|
|
65
|
+
return _prices(argv[1:])
|
|
66
|
+
if argv[:1] in (["judge"], ["judge-noise"]):
|
|
67
|
+
return _judge(argv[0], argv[1:])
|
|
68
|
+
passthrough: list[str] = []
|
|
69
|
+
if "--" in argv:
|
|
70
|
+
at = argv.index("--")
|
|
71
|
+
argv, passthrough = argv[:at], argv[at + 1:]
|
|
72
|
+
|
|
73
|
+
ap = argparse.ArgumentParser(prog="tare", description="Start a coding agent in an isolated room without your "
|
|
74
|
+
"personal setup, and prove the room clean before the run.")
|
|
75
|
+
ap.add_argument("--version", action="version", version=f"tare {recipe.tare_version()}")
|
|
76
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
77
|
+
p = sub.add_parser("probe", help="probe the room: tare: 0.00, the leaks and their sources, or not proven (blind)")
|
|
78
|
+
p.add_argument("agent", choices=sorted(AGENTS))
|
|
79
|
+
p.add_argument("--project", type=Path, default=Path.cwd())
|
|
80
|
+
for name in sorted(AGENTS):
|
|
81
|
+
c = sub.add_parser(name, help=f"probe, then start {name} in the room (args after --)")
|
|
82
|
+
c.add_argument("--project", type=Path, default=Path.cwd())
|
|
83
|
+
c.add_argument("--allow-dirty", action="store_true", help="start even if the reading is not tare: 0.00")
|
|
84
|
+
c.add_argument("--yolo", action="store_true", help="skip the agent's permission prompts (for Codex also its own sandbox). The room stays.")
|
|
85
|
+
k = sub.add_parser("cliff", help="find where a failed run became lost (args after -- go to every agent run)")
|
|
86
|
+
k.add_argument("agent", choices=sorted(AGENTS))
|
|
87
|
+
k.add_argument("prompt", help="the task, as given to the agent")
|
|
88
|
+
k.add_argument("--check", required=True, help="shell command run in the finished workspace (exit 0 passes)")
|
|
89
|
+
k.add_argument("--project", type=Path, default=Path.cwd())
|
|
90
|
+
k.add_argument("--tails", type=int, default=3, help="tails (continuations from a saved step) per step tried (default 3)")
|
|
91
|
+
k.add_argument("--budget", type=int, default=30, help="tails in total, baseline included (default 30)")
|
|
92
|
+
k.add_argument("--jobs", type=int, default=3, help="tails run at the same time (default 3)")
|
|
93
|
+
k.add_argument("--gap-below", type=float, default=0.2,
|
|
94
|
+
help="report a model gap (the task is too hard for the model) only when the upper end of the "
|
|
95
|
+
"baseline's 95%% interval is below this (default 0.2)")
|
|
96
|
+
k.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/cliff/<project>-<time>)")
|
|
97
|
+
k.add_argument("--allow-dirty", action="store_true", help="run even if the reading is not tare: 0.00")
|
|
98
|
+
w = sub.add_parser("swap", help="each agent continues both workspaces: was it the workspace or the agent")
|
|
99
|
+
w.add_argument("prompt", help="the task, as given to both agents")
|
|
100
|
+
w.add_argument("--check", required=True, help="shell command run in the finished workspace (exit 0 passes)")
|
|
101
|
+
w.add_argument("--a", default="claude", choices=sorted(AGENTS), help="agent a (default claude)")
|
|
102
|
+
w.add_argument("--b", default="codex", choices=sorted(AGENTS), help="agent b (default codex)")
|
|
103
|
+
w.add_argument("--a-args", default="", help="arguments for every run of agent a, e.g. '--model sonnet'")
|
|
104
|
+
w.add_argument("--b-args", default="", help="arguments for every run of agent b, e.g. '-m MODEL'")
|
|
105
|
+
w.add_argument("--cuts", default="0,0.5,1", help="where to cut each run, as fractions of its steps (default 0,0.5,1)")
|
|
106
|
+
w.add_argument("--tails", type=int, default=3, help="tails (continuations) per cut, workspace and agent (default 3)")
|
|
107
|
+
w.add_argument("--jobs", type=int, default=3, help="tails run at the same time (default 3)")
|
|
108
|
+
w.add_argument("--handoff", choices=["trail", "workspace"], default="trail",
|
|
109
|
+
help="what a continuing agent gets besides the workspace: a written summary of the steps so far "
|
|
110
|
+
"(trail, the default) or nothing (workspace)")
|
|
111
|
+
w.add_argument("--project", type=Path, default=Path.cwd())
|
|
112
|
+
w.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/swap/<project>-<time>)")
|
|
113
|
+
w.add_argument("--allow-dirty", action="store_true", help="run even if a reading is not tare: 0.00")
|
|
114
|
+
c = sub.add_parser("calibrate", help="how often each side (an agent with its arguments) passes a task")
|
|
115
|
+
c.add_argument("prompt", nargs="?", help="the task, as given to every side without a prompt of its own")
|
|
116
|
+
c.add_argument("--check", help="shell command run in the finished workspace (exit 0 passes). Without it, a run "
|
|
117
|
+
"counts as finished when its agent exits 0, and its workspace is kept for scoring elsewhere")
|
|
118
|
+
c.add_argument("--side", action="append", required=True,
|
|
119
|
+
help="an agent and its arguments, e.g. 'claude --model haiku' (repeat for each side)")
|
|
120
|
+
c.add_argument("--side-prompt", action="append", default=[], metavar="KEY=PROMPT",
|
|
121
|
+
help="a side's own prompt. Sides are a, b, c... in --side order, e.g. 'b=/b:ideate a todo app'")
|
|
122
|
+
c.add_argument("--runs", type=int, default=10, help="fresh starts per side (default 10)")
|
|
123
|
+
c.add_argument("--jobs", type=int, default=3, help="runs at the same time (default 3)")
|
|
124
|
+
c.add_argument("--project", type=Path, default=Path.cwd())
|
|
125
|
+
c.add_argument("--out", type=Path, help="run directory (default ~/.local/state/tare/calibrate/<project>-<time>)")
|
|
126
|
+
c.add_argument("--allow-dirty", action="store_true", help="run even if a reading is not tare: 0.00")
|
|
127
|
+
c.add_argument("--keep", action="store_true", help="keep every run's finished workspace (e.g. for judge-noise)")
|
|
128
|
+
for parser in (k, w, c):
|
|
129
|
+
parser.add_argument("--port", type=int, default=8777, help="dashboard port on localhost (default 8777)")
|
|
130
|
+
sub.add_parser("watch", help="the live dashboard of one or more run directories (tare watch DIR...)")
|
|
131
|
+
sub.add_parser("judge", help="score a page by a rubric, in a room without agent or model names so the judge cannot tell who "
|
|
132
|
+
"built it (usable as --check)")
|
|
133
|
+
sub.add_parser("judge-noise", help="score the same pages repeatedly, to see how far the judge's scores vary")
|
|
134
|
+
sub.add_parser("rerun", help="repeat a run from its recipe (tare rerun DIR)")
|
|
135
|
+
sub.add_parser("prices", help="the price tables for costs that an agent does not report (tare prices update)")
|
|
136
|
+
args = ap.parse_args(argv)
|
|
137
|
+
if args.command == "swap":
|
|
138
|
+
names = [args.a, args.b]
|
|
139
|
+
elif args.command == "calibrate":
|
|
140
|
+
sides = [shlex.split(side) for side in args.side]
|
|
141
|
+
unknown = [side[0] for side in sides if side[0] not in AGENTS]
|
|
142
|
+
if unknown:
|
|
143
|
+
ap.error(f"unknown agent: {', '.join(unknown)} (known: {', '.join(sorted(AGENTS))})")
|
|
144
|
+
names = [side[0] for side in sides]
|
|
145
|
+
keys = [chr(97 + i) for i in range(len(sides))]
|
|
146
|
+
prompts = dict(item.split("=", 1) for item in args.side_prompt if "=" in item)
|
|
147
|
+
bad = [item for item in args.side_prompt if "=" not in item or item.split("=", 1)[0] not in keys]
|
|
148
|
+
if bad:
|
|
149
|
+
ap.error(f"--side-prompt wants KEY=PROMPT with KEY one of {', '.join(keys)}: {', '.join(bad)}")
|
|
150
|
+
if not args.prompt and len(prompts) < len(keys):
|
|
151
|
+
ap.error("give a prompt, or a --side-prompt for every side")
|
|
152
|
+
else:
|
|
153
|
+
names = [args.agent if args.command in ("probe", "cliff") else args.command]
|
|
154
|
+
project = args.project.resolve()
|
|
155
|
+
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
156
|
+
signal.signal(signum, _exit_on)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
reals = {name: AGENTS[name].discover() for name in dict.fromkeys(names)}
|
|
160
|
+
journal = Journal(None)
|
|
161
|
+
if args.command in ("cliff", "swap", "calibrate"):
|
|
162
|
+
out = args.out or (Path.home() / ".local/state/tare" / args.command
|
|
163
|
+
/ f"{project.name}-{time.strftime('%Y%m%d-%H%M%S')}")
|
|
164
|
+
out.mkdir(parents=True, exist_ok=False)
|
|
165
|
+
journal = Journal(out)
|
|
166
|
+
if args.command == "cliff":
|
|
167
|
+
agents = {"a": (AGENTS[args.agent], reals[args.agent], passthrough)}
|
|
168
|
+
params = {"tails": args.tails, "budget": args.budget, "jobs": args.jobs, "gap_below": args.gap_below}
|
|
169
|
+
elif args.command == "calibrate":
|
|
170
|
+
agents = {chr(97 + i): (AGENTS[side[0]], reals[side[0]], side[1:]) for i, side in enumerate(sides)}
|
|
171
|
+
params = {"runs": args.runs, "jobs": args.jobs, **({"prompts": prompts} if prompts else {})}
|
|
172
|
+
else:
|
|
173
|
+
agents = {"a": (AGENTS[args.a], reals[args.a], shlex.split(args.a_args)),
|
|
174
|
+
"b": (AGENTS[args.b], reals[args.b], shlex.split(args.b_args))}
|
|
175
|
+
params = {"cuts": args.cuts, "tails": args.tails, "jobs": args.jobs, "handoff": args.handoff}
|
|
176
|
+
recipe.write(out, original_argv, args.command, project, args.prompt, args.check, agents, params)
|
|
177
|
+
print(f"tare {args.command}: run directory {out}", file=sys.stderr)
|
|
178
|
+
print(f"tare {args.command}: live dashboard {_serve(out, args.port)}", file=sys.stderr)
|
|
179
|
+
for name in dict.fromkeys(names):
|
|
180
|
+
agent = AGENTS[name]
|
|
181
|
+
reading = probe(agent, reals[name], project)
|
|
182
|
+
print(render(reading, project), file=sys.stderr)
|
|
183
|
+
journal("probe", agent=name, zero=reading.zero, text=render(reading, project))
|
|
184
|
+
if args.command == "probe":
|
|
185
|
+
return 0 if reading.zero else 1
|
|
186
|
+
if not reading.zero:
|
|
187
|
+
if not args.allow_dirty:
|
|
188
|
+
print("tare: not starting, because the room is not proven clean. Fix what the reading names, "
|
|
189
|
+
"or pass --allow-dirty.", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
print(f"tare: --allow-dirty given, running {name} in a room whose reading is not tare: 0.00",
|
|
192
|
+
file=sys.stderr)
|
|
193
|
+
agent, real = AGENTS[names[0]], reals[names[0]]
|
|
194
|
+
if args.command == "cliff":
|
|
195
|
+
print(cliff(agent, real, project, args.prompt, args.check, out, tails=args.tails, budget=args.budget,
|
|
196
|
+
jobs=args.jobs, claude_args=passthrough, journal=journal, gap_below=args.gap_below), end="")
|
|
197
|
+
print(f"tare cliff: see it again with tare watch {out}", file=sys.stderr)
|
|
198
|
+
return 0
|
|
199
|
+
if args.command == "calibrate":
|
|
200
|
+
runs = [calibration.Side(key, agent_, real_, list(extra), prompts.get(key))
|
|
201
|
+
for key, (agent_, real_, extra) in agents.items()]
|
|
202
|
+
print(calibration.calibrate(runs, project, args.prompt, args.check, out, runs=args.runs, jobs=args.jobs,
|
|
203
|
+
journal=journal, keep=args.keep), end="")
|
|
204
|
+
print(f"tare calibrate: see it again with tare watch {out}", file=sys.stderr)
|
|
205
|
+
return 0
|
|
206
|
+
if args.command == "swap":
|
|
207
|
+
a = Side("a", AGENTS[args.a], reals[args.a], shlex.split(args.a_args), out / "a")
|
|
208
|
+
b = Side("b", AGENTS[args.b], reals[args.b], shlex.split(args.b_args), out / "b")
|
|
209
|
+
cuts = [float(c) for c in args.cuts.split(",")]
|
|
210
|
+
print(swap(a, b, project, args.prompt, args.check, out, cuts=cuts, tails=args.tails, jobs=args.jobs,
|
|
211
|
+
workspace_only=args.handoff == "workspace", journal=journal), end="")
|
|
212
|
+
print(f"tare swap: see it again with tare watch {out}", file=sys.stderr)
|
|
213
|
+
return 0
|
|
214
|
+
flags = agent.room_flags + (agent.yolo if args.yolo else [])
|
|
215
|
+
with room_home(agent, real) as home:
|
|
216
|
+
return _run_attached(bwrap(agent, real, home, project, [agent.name, *flags, *passthrough]))
|
|
217
|
+
except TareError as err:
|
|
218
|
+
print(f"tare: {err}", file=sys.stderr)
|
|
219
|
+
return 2
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _watch(argv: list[str]) -> int:
|
|
223
|
+
ap = argparse.ArgumentParser(prog="tare watch", description="the live dashboard of a run, or of several runs on one page")
|
|
224
|
+
ap.add_argument("dirs", type=Path, nargs="+")
|
|
225
|
+
ap.add_argument("--port", type=int, default=8777)
|
|
226
|
+
args = ap.parse_args(argv)
|
|
227
|
+
missing = [str(d) for d in args.dirs if not (d / "journal.jsonl").exists()]
|
|
228
|
+
if missing:
|
|
229
|
+
print(f"tare watch: not a tare run directory (no journal.jsonl): {', '.join(missing)}", file=sys.stderr)
|
|
230
|
+
return 2
|
|
231
|
+
dirs = [d.resolve() for d in args.dirs]
|
|
232
|
+
print(f"tare watch: {_serve(dirs[0] if len(dirs) == 1 else dirs, args.port)} (Ctrl-C to stop)", file=sys.stderr)
|
|
233
|
+
try:
|
|
234
|
+
threading.Event().wait()
|
|
235
|
+
except KeyboardInterrupt:
|
|
236
|
+
pass
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _judge(command: str, argv: list[str]) -> int:
|
|
241
|
+
from . import judge as judging
|
|
242
|
+
ap = argparse.ArgumentParser(prog=f"tare {command}")
|
|
243
|
+
ap.add_argument("dirs", type=Path, nargs="*" if command == "judge" else "+", default=[Path.cwd()])
|
|
244
|
+
ap.add_argument("--rubric", type=Path, required=True)
|
|
245
|
+
ap.add_argument("--threshold", type=float, required=command == "judge")
|
|
246
|
+
ap.add_argument("--times", type=int, default=10, help="scores per page (judge-noise, default 10)")
|
|
247
|
+
ap.add_argument("--page", default="index.html", help="the page to render (default index.html)")
|
|
248
|
+
ap.add_argument("--judge", default="claude --model sonnet", help="the judge agent and its arguments")
|
|
249
|
+
args = ap.parse_args(argv)
|
|
250
|
+
name, *extra = shlex.split(args.judge)
|
|
251
|
+
try:
|
|
252
|
+
if command == "judge":
|
|
253
|
+
score, reason = judging.judge(args.dirs[0].resolve(), args.rubric.resolve(), args.page, name, extra)
|
|
254
|
+
print(f"score {score} (threshold {args.threshold:g}): {reason}")
|
|
255
|
+
return 0 if score >= args.threshold else 1
|
|
256
|
+
text, clear = judging.noise([d.resolve() for d in args.dirs], args.rubric.resolve(), args.times,
|
|
257
|
+
args.threshold, args.page, name, extra)
|
|
258
|
+
print(text, end="")
|
|
259
|
+
return 0 if clear else 1
|
|
260
|
+
except (RuntimeError, TareError) as err:
|
|
261
|
+
print(f"tare {command}: {err}")
|
|
262
|
+
return 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _prices(argv: list[str]) -> int:
|
|
266
|
+
ap = argparse.ArgumentParser(prog="tare prices", description="the price tables for costs that an agent does not "
|
|
267
|
+
f"report. Your own file {usages.config_dir() / 'prices.json'} overrides single models.")
|
|
268
|
+
ap.add_argument("action", nargs="?", choices=["show", "update"], default="show")
|
|
269
|
+
ap.add_argument("--url", default=usages.LITELLM_URL, help="where to fetch the table from (default: LiteLLM)")
|
|
270
|
+
args = ap.parse_args(argv)
|
|
271
|
+
if args.action == "update":
|
|
272
|
+
try:
|
|
273
|
+
print(f"tare prices: {usages.update_prices(args.url)}")
|
|
274
|
+
except (OSError, ValueError) as err:
|
|
275
|
+
print(f"tare prices: could not fetch {args.url}: {err}", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
return 0
|
|
278
|
+
tables = usages.tables_summary()
|
|
279
|
+
print("\n".join(f"tare prices: {line}" for line in tables) if tables else
|
|
280
|
+
"tare prices: no price table yet. Run `tare prices update` to fetch LiteLLM's.")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _rerun(argv: list[str]) -> int:
|
|
285
|
+
ap = argparse.ArgumentParser(prog="tare rerun", description="repeat a Calibrate, Cliff or Swap run from its recipe")
|
|
286
|
+
ap.add_argument("dir", type=Path)
|
|
287
|
+
ap.add_argument("--out", type=Path, help="run directory for the repeat (default: a new one)")
|
|
288
|
+
args = ap.parse_args(argv)
|
|
289
|
+
try:
|
|
290
|
+
r = recipe.read(args.dir)
|
|
291
|
+
except OSError:
|
|
292
|
+
print(f"tare rerun: no recipe.json in {args.dir}", file=sys.stderr)
|
|
293
|
+
return 2
|
|
294
|
+
reals = {}
|
|
295
|
+
for agent in r["agents"].values():
|
|
296
|
+
try:
|
|
297
|
+
reals[agent["name"]] = AGENTS[agent["name"]].discover()
|
|
298
|
+
except TareError:
|
|
299
|
+
pass
|
|
300
|
+
for note in recipe.drift(r, reals):
|
|
301
|
+
print(f"tare rerun: note: {note}", file=sys.stderr)
|
|
302
|
+
spent = args.dir / "usage.json"
|
|
303
|
+
if spent.exists():
|
|
304
|
+
before = usages.Usage.from_dict(json.loads(spent.read_text()).get("total"))
|
|
305
|
+
print(f"tare rerun: the run being repeated used {usages.describe(before)}", file=sys.stderr)
|
|
306
|
+
command = recipe.with_out(r["argv"], args.out) if args.out else r["argv"]
|
|
307
|
+
print(f"tare rerun: tare {shlex.join(command)}", file=sys.stderr)
|
|
308
|
+
return main(command)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
sys.exit(main())
|