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/dashboard.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""The live dashboard: a local page that shows a Cliff or Swap run as it happens (epic Dashboard).
|
|
2
|
+
|
|
3
|
+
Everything comes from the run directory: the recipe, the journal, and the live output each
|
|
4
|
+
original run and tail writes. A finished run looks the same as a live one, so `tare watch`
|
|
5
|
+
can show any run directory. Given several, it serves an overview with one row per run, and each
|
|
6
|
+
run's own dashboard under run/<i>/.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import journal as journals
|
|
17
|
+
from . import usage as usages
|
|
18
|
+
from .agents import AGENTS
|
|
19
|
+
from .cliff import wilson
|
|
20
|
+
from .swap import Cut
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _activity(agent_name: str | None, stdout: Path) -> str | None:
|
|
24
|
+
"""The latest thing the agent did, from the tail of its live output."""
|
|
25
|
+
agent = AGENTS.get(agent_name or "")
|
|
26
|
+
if not agent or not stdout.exists():
|
|
27
|
+
return None
|
|
28
|
+
with stdout.open("rb") as f:
|
|
29
|
+
f.seek(max(0, stdout.stat().st_size - 64 * 1024))
|
|
30
|
+
lines = f.read().decode(errors="replace").splitlines()
|
|
31
|
+
for line in reversed(lines):
|
|
32
|
+
try:
|
|
33
|
+
described = agent.activity(json.loads(line))
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
if described:
|
|
37
|
+
return described
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _capsule_count(store: Path) -> int:
|
|
42
|
+
return max(0, len(list((store / "capsules").glob("*.tar"))) - 1) if store.exists() else 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state(out: Path) -> dict:
|
|
46
|
+
events = journals.read(out)
|
|
47
|
+
recipe = out / "recipe.json"
|
|
48
|
+
s = {"recipe": json.loads(recipe.read_text()) if recipe.exists() else None, "kind": None, "task": None,
|
|
49
|
+
"check": None, "project": None, "sides": {}, "params": {}, "phase": "starting", "probes": [], "originals": {},
|
|
50
|
+
"tails": [], "plan": [], "report": None, "started": events[0]["t"] if events else None,
|
|
51
|
+
"updated": events[-1]["t"] if events else None, "now": time.time()}
|
|
52
|
+
tails: dict[str, dict] = {}
|
|
53
|
+
for e in events:
|
|
54
|
+
kind = e["event"]
|
|
55
|
+
if kind == "start":
|
|
56
|
+
s.update({k: e.get(k) for k in ("kind", "task", "check", "project", "sides")})
|
|
57
|
+
s["params"] = {k: e.get(k) for k in ("cuts", "tails", "budget")}
|
|
58
|
+
if s["kind"] == "calibrate":
|
|
59
|
+
s["originals"] = {k: {"passed": None, "detail": "", "steps": []} for k in s["sides"]} # no original runs
|
|
60
|
+
elif kind == "phase":
|
|
61
|
+
s["phase"] = e["phase"]
|
|
62
|
+
elif kind == "probe":
|
|
63
|
+
s["probes"].append({k: e.get(k) for k in ("agent", "zero", "text")})
|
|
64
|
+
elif kind == "original":
|
|
65
|
+
s["originals"][e["side"]] = {"passed": e["passed"], "detail": e["detail"], "steps": e["steps"]}
|
|
66
|
+
elif kind == "plan":
|
|
67
|
+
s["plan"] = e["cuts"]
|
|
68
|
+
elif kind == "tail":
|
|
69
|
+
tail = tails.setdefault(e["id"], {"id": e["id"], "started": e["t"]})
|
|
70
|
+
tail.update({k: v for k, v in e.items() if k not in ("t", "event")})
|
|
71
|
+
if e["status"] != "running":
|
|
72
|
+
tail["ended"] = e["t"]
|
|
73
|
+
elif kind == "report":
|
|
74
|
+
s["report"] = e["text"]
|
|
75
|
+
|
|
76
|
+
# originals still running: their capsules so far and what the agent does right now
|
|
77
|
+
for key, side in (s["sides"] or {}).items():
|
|
78
|
+
if key not in s["originals"]:
|
|
79
|
+
base = out / side.get("dir", ".")
|
|
80
|
+
s["originals"][key] = {"running": True, "count": _capsule_count(base / "store"),
|
|
81
|
+
"activity": _activity(side.get("agent"), base / "original" / "stdout.jsonl")}
|
|
82
|
+
for tail in tails.values():
|
|
83
|
+
if tail["status"] == "running":
|
|
84
|
+
tail["activity"] = _activity(tail.get("agent_name"), out / tail["dir"] / "stdout.jsonl")
|
|
85
|
+
s["tails"] = sorted(tails.values(), key=lambda t: (t["status"] != "running", -t["started"]))
|
|
86
|
+
|
|
87
|
+
if s["kind"] == "cliff":
|
|
88
|
+
s["steps"] = _cliff_steps(tails.values())
|
|
89
|
+
elif s["kind"] == "swap":
|
|
90
|
+
s["cells"] = _swap_cells(tails.values(), s["plan"])
|
|
91
|
+
elif s["kind"] == "calibrate":
|
|
92
|
+
s["rates"] = _rates(tails.values(), s["sides"])
|
|
93
|
+
s["usage"] = usages.of_run(events)
|
|
94
|
+
return s
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cliff_steps(tails) -> list[dict]:
|
|
98
|
+
steps: dict[int, list] = {}
|
|
99
|
+
for t in tails:
|
|
100
|
+
steps.setdefault(t["step"], []).append(t)
|
|
101
|
+
rows = []
|
|
102
|
+
for step, ts in sorted(steps.items()):
|
|
103
|
+
done = [t["status"] == "passed" for t in ts if t["status"] != "running"]
|
|
104
|
+
lo, hi = wilson(sum(done), len(done))
|
|
105
|
+
rows.append({"step": step, "passes": sum(done), "n": len(done), "running": sum(t["status"] == "running" for t in ts),
|
|
106
|
+
"rate": sum(done) / len(done) if done else None, "lo": lo, "hi": hi})
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rates(tails, sides) -> dict:
|
|
111
|
+
rates = {}
|
|
112
|
+
for key in sides:
|
|
113
|
+
ts = [t for t in tails if t.get("side") == key]
|
|
114
|
+
done = [t["status"] == "passed" for t in ts if t["status"] != "running"]
|
|
115
|
+
lo, hi = wilson(sum(done), len(done))
|
|
116
|
+
# a judge check reports "score N" in its detail line
|
|
117
|
+
scores = [int(m.group(1)) for t in ts if t["status"] != "running"
|
|
118
|
+
and (m := re.search(r"score (\d+)", t.get("detail", "")))]
|
|
119
|
+
rates[key] = {"passes": sum(done), "n": len(done), "running": sum(t["status"] == "running" for t in ts),
|
|
120
|
+
"rate": sum(done) / len(done) if done else None, "lo": lo, "hi": hi, "scores": scores,
|
|
121
|
+
"mean": sum(scores) / len(scores) if scores else None,
|
|
122
|
+
"times": [t["ended"] - t["started"] for t in ts if "ended" in t]}
|
|
123
|
+
return rates
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _swap_cells(tails, plan) -> list[dict]:
|
|
127
|
+
cuts = []
|
|
128
|
+
for i, p in enumerate(plan):
|
|
129
|
+
cut = Cut(p["fraction"], p["steps"])
|
|
130
|
+
running: dict[tuple[str, str], int] = {}
|
|
131
|
+
for t in tails:
|
|
132
|
+
if t.get("cut") != i:
|
|
133
|
+
continue
|
|
134
|
+
native = t["kind"] == "native"
|
|
135
|
+
key = ("native" if native else t["room"], t["room"] if native else t["agent"])
|
|
136
|
+
target = cut.native.setdefault(t["room"], []) if native else cut.cells.setdefault(key, [])
|
|
137
|
+
if t["status"] == "running":
|
|
138
|
+
running[key] = running.get(key, 0) + 1
|
|
139
|
+
else:
|
|
140
|
+
target.append(t["status"] == "passed")
|
|
141
|
+
complete = all(cut.cells.get((r, m)) for r in "ab" for m in "ab")
|
|
142
|
+
cuts.append({
|
|
143
|
+
"fraction": p["fraction"], "steps": p["steps"],
|
|
144
|
+
"cells": {f"{r}{m}": {"passes": sum(cut.cells.get((r, m), [])), "n": len(cut.cells.get((r, m), [])),
|
|
145
|
+
"running": running.get((r, m), 0)} for r in "ab" for m in "ab"},
|
|
146
|
+
"native": {r: {"passes": sum(v), "n": len(v), "running": running.get(("native", r), 0)}
|
|
147
|
+
for r, v in cut.native.items()},
|
|
148
|
+
"state": cut.state() if complete else None, "model": cut.model() if complete else None,
|
|
149
|
+
})
|
|
150
|
+
return cuts
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def overview(out: Path) -> dict:
|
|
154
|
+
"""One row of the overview: what a run is and how far it got."""
|
|
155
|
+
s = state(out)
|
|
156
|
+
verdict = next((line.strip()[len("verdict"):].strip() for line in (s["report"] or "").splitlines()
|
|
157
|
+
if line.strip().startswith("verdict")), None)
|
|
158
|
+
running = sum(t["status"] == "running" for t in s["tails"])
|
|
159
|
+
return {"name": out.name, "kind": s["kind"], "task": s["task"], "check": s["check"], "sides": s["sides"],
|
|
160
|
+
"phase": s["phase"], "running": running, "done": len(s["tails"]) - running, "rates": s.get("rates"),
|
|
161
|
+
"verdict": verdict}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def serve(out: Path | list[Path], port: int = 0) -> tuple[ThreadingHTTPServer, str]:
|
|
165
|
+
"""Serve the dashboard for a run directory, or an overview of several, on 127.0.0.1;
|
|
166
|
+
returns the server and its URL."""
|
|
167
|
+
runs = [out] if isinstance(out, Path) else list(out)
|
|
168
|
+
page = resources.files("tare").joinpath("dashboard.html").read_bytes()
|
|
169
|
+
index = resources.files("tare").joinpath("overview.html").read_bytes()
|
|
170
|
+
html = "text/html; charset=utf-8"
|
|
171
|
+
|
|
172
|
+
def route(path: str) -> tuple[bytes, str] | str | None:
|
|
173
|
+
"""The body and its type, a path to redirect to, or None."""
|
|
174
|
+
if len(runs) == 1:
|
|
175
|
+
run, rest = runs[0], path
|
|
176
|
+
else:
|
|
177
|
+
if path in ("/", "/index.html"):
|
|
178
|
+
return index, html
|
|
179
|
+
if path == "/runs":
|
|
180
|
+
return json.dumps([{"i": i, **overview(r)} for i, r in enumerate(runs)]).encode(), "application/json"
|
|
181
|
+
m = re.fullmatch(r"/run/(\d+)(/.*)?", path)
|
|
182
|
+
if not m or int(m.group(1)) >= len(runs):
|
|
183
|
+
return None
|
|
184
|
+
if not m.group(2):
|
|
185
|
+
return f"{path}/"
|
|
186
|
+
run, rest = runs[int(m.group(1))], m.group(2)
|
|
187
|
+
if rest == "/state":
|
|
188
|
+
return json.dumps(state(run)).encode(), "application/json"
|
|
189
|
+
if rest in ("/", "/index.html"):
|
|
190
|
+
return page, html
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
class Handler(BaseHTTPRequestHandler):
|
|
194
|
+
def log_message(self, *args):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
def do_GET(self):
|
|
198
|
+
found = route(self.path.split("?")[0])
|
|
199
|
+
if found is None or isinstance(found, str):
|
|
200
|
+
self.send_response(404 if found is None else 301)
|
|
201
|
+
if found:
|
|
202
|
+
self.send_header("location", found)
|
|
203
|
+
self.send_header("content-length", "0")
|
|
204
|
+
self.end_headers()
|
|
205
|
+
return
|
|
206
|
+
body, kind = found
|
|
207
|
+
self.send_response(200)
|
|
208
|
+
self.send_header("content-type", kind)
|
|
209
|
+
self.send_header("cache-control", "no-store")
|
|
210
|
+
self.send_header("content-length", str(len(body)))
|
|
211
|
+
self.end_headers()
|
|
212
|
+
self.wfile.write(body)
|
|
213
|
+
|
|
214
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
215
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
216
|
+
return server, f"http://localhost:{server.server_address[1]}/"
|
tare/fake.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""A fake model endpoint that keeps what the CLI sends.
|
|
2
|
+
|
|
3
|
+
The CLI under test is pointed at it: Claude Code with ANTHROPIC_BASE_URL (Anthropic
|
|
4
|
+
Messages API), Codex with `-c openai_base_url=...` (OpenAI Responses API), the Antigravity
|
|
5
|
+
CLI with CLOUD_CODE_URL (Google's Cloud Code `v1internal` API). Every request
|
|
6
|
+
body is kept in memory; headers are never stored, so the login token never leaves the
|
|
7
|
+
request. By default each turn is answered with the text "ok", so the CLI ends its loop at
|
|
8
|
+
once; a `respond` policy can script tool calls instead (Anthropic Messages only).
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
OK = [{"type": "text", "text": "ok"}]
|
|
17
|
+
|
|
18
|
+
# Cloud Code (Antigravity CLI): the model the fake offers, and the answers to the calls
|
|
19
|
+
# around a turn. The eligibility check needs a tier and a project; a model must be listed
|
|
20
|
+
# to be selectable. Everything else is answered with {}.
|
|
21
|
+
FAKE_MODEL = "tare-fake"
|
|
22
|
+
_GEMINI_OK = {"candidates": [{"content": {"role": "model", "parts": [{"text": "ok"}]}, "finishReason": "STOP"}],
|
|
23
|
+
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}}
|
|
24
|
+
_CLOUD_CODE = {
|
|
25
|
+
"loadCodeAssist": {"currentTier": {"id": "free-tier", "name": "Free"}, "cloudaicompanionProject": "tare-fake",
|
|
26
|
+
"allowedTiers": [{"id": "free-tier", "name": "Free", "isDefault": True}]},
|
|
27
|
+
"fetchAvailableModels": {
|
|
28
|
+
"defaultAgentModelId": FAKE_MODEL,
|
|
29
|
+
"agentModelSorts": [{"displayName": "Recommended", "groups": [{"modelIds": [FAKE_MODEL]}]}],
|
|
30
|
+
"models": {FAKE_MODEL: {"displayName": "tare fake", "recommended": True, "model": "MODEL_PLACEHOLDER_M318",
|
|
31
|
+
"apiProvider": "API_PROVIDER_GOOGLE_GEMINI", "modelProvider": "MODEL_PROVIDER_GOOGLE",
|
|
32
|
+
"maxTokens": 1048576, "maxOutputTokens": 65536}}},
|
|
33
|
+
"countTokens": {"totalTokens": 1},
|
|
34
|
+
"generateContent": {"response": _GEMINI_OK},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stop_reason(blocks: list[dict]) -> str:
|
|
39
|
+
return "tool_use" if any(b["type"] == "tool_use" for b in blocks) else "end_turn"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _messages_events(model: str, blocks: list[dict]) -> list[tuple[str, dict]]:
|
|
43
|
+
message = {"id": "msg_tare", "type": "message", "role": "assistant", "model": model, "content": [],
|
|
44
|
+
"stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}}
|
|
45
|
+
events = [("message_start", {"type": "message_start", "message": message})]
|
|
46
|
+
for i, block in enumerate(blocks):
|
|
47
|
+
if block["type"] == "text":
|
|
48
|
+
start, delta = {"type": "text", "text": ""}, {"type": "text_delta", "text": block["text"]}
|
|
49
|
+
else:
|
|
50
|
+
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
|
|
51
|
+
delta = {"type": "input_json_delta", "partial_json": json.dumps(block["input"])}
|
|
52
|
+
events += [("content_block_start", {"type": "content_block_start", "index": i, "content_block": start}),
|
|
53
|
+
("content_block_delta", {"type": "content_block_delta", "index": i, "delta": delta}),
|
|
54
|
+
("content_block_stop", {"type": "content_block_stop", "index": i})]
|
|
55
|
+
return events + [
|
|
56
|
+
("message_delta", {"type": "message_delta", "delta": {"stop_reason": _stop_reason(blocks), "stop_sequence": None},
|
|
57
|
+
"usage": {"output_tokens": 1}}),
|
|
58
|
+
("message_stop", {"type": "message_stop"}),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _responses_events() -> list[tuple[str, dict]]:
|
|
63
|
+
item = {"type": "message", "role": "assistant", "id": "msg_tare",
|
|
64
|
+
"content": [{"type": "output_text", "text": "ok", "annotations": []}]}
|
|
65
|
+
usage = {"input_tokens": 1, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 1,
|
|
66
|
+
"output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 2}
|
|
67
|
+
return [
|
|
68
|
+
("response.created", {"type": "response.created", "response": {"id": "resp_tare"}}),
|
|
69
|
+
("response.output_item.added", {"type": "response.output_item.added", "output_index": 0, "item": item}),
|
|
70
|
+
("response.output_text.delta", {"type": "response.output_text.delta", "item_id": "msg_tare",
|
|
71
|
+
"output_index": 0, "content_index": 0, "delta": "ok"}),
|
|
72
|
+
("response.output_item.done", {"type": "response.output_item.done", "output_index": 0, "item": item}),
|
|
73
|
+
("response.completed", {"type": "response.completed", "response": {"id": "resp_tare", "usage": usage}}),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _QuietServer(ThreadingHTTPServer):
|
|
78
|
+
def handle_error(self, request, client_address):
|
|
79
|
+
# The CLIs drop connections at will (a refused websocket, the end of a run);
|
|
80
|
+
# that is not worth a traceback in the user's terminal.
|
|
81
|
+
if not isinstance(sys.exc_info()[1], ConnectionError):
|
|
82
|
+
super().handle_error(request, client_address)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Fake:
|
|
86
|
+
"""Context manager: serves on a free port of 127.0.0.1 and collects request bodies."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, respond=None):
|
|
89
|
+
# respond(body) -> content blocks of the answer (text and tool_use), for scripted runs
|
|
90
|
+
self.respond = respond or (lambda body: OK)
|
|
91
|
+
self.requests: list[dict] = []
|
|
92
|
+
self.other_paths: list[str] = []
|
|
93
|
+
fake = self
|
|
94
|
+
|
|
95
|
+
class Handler(BaseHTTPRequestHandler):
|
|
96
|
+
# HTTP/1.1, so a refused websocket upgrade reads as a plain 404 and Codex
|
|
97
|
+
# falls back to HTTPS instead of retrying.
|
|
98
|
+
protocol_version = "HTTP/1.1"
|
|
99
|
+
|
|
100
|
+
def log_message(self, *args):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def _json(self, status, payload):
|
|
104
|
+
data = json.dumps(payload).encode()
|
|
105
|
+
self.send_response(status)
|
|
106
|
+
self.send_header("content-type", "application/json")
|
|
107
|
+
self.send_header("content-length", str(len(data)))
|
|
108
|
+
self.end_headers()
|
|
109
|
+
self.wfile.write(data)
|
|
110
|
+
|
|
111
|
+
def _not_found(self):
|
|
112
|
+
fake.other_paths.append(f"{self.command} {self.path.split('?')[0]}")
|
|
113
|
+
self._json(404, {"type": "error", "error": {"type": "not_found_error", "message": "tare fake"}})
|
|
114
|
+
|
|
115
|
+
def _stream(self, events):
|
|
116
|
+
self.send_response(200)
|
|
117
|
+
self.send_header("content-type", "text/event-stream")
|
|
118
|
+
self.send_header("cache-control", "no-cache")
|
|
119
|
+
self.send_header("connection", "close")
|
|
120
|
+
self.end_headers()
|
|
121
|
+
for name, data in events:
|
|
122
|
+
self.wfile.write(f"event: {name}\ndata: {json.dumps(data)}\n\n".encode())
|
|
123
|
+
self.wfile.flush()
|
|
124
|
+
self.close_connection = True
|
|
125
|
+
|
|
126
|
+
def do_GET(self):
|
|
127
|
+
self._not_found()
|
|
128
|
+
|
|
129
|
+
def _body(self) -> bytes:
|
|
130
|
+
if "chunked" not in (self.headers.get("transfer-encoding") or ""):
|
|
131
|
+
return self.rfile.read(int(self.headers.get("content-length") or 0))
|
|
132
|
+
parts = [] # the Antigravity CLI sends its model requests chunked
|
|
133
|
+
while size := int(self.rfile.readline().split(b";")[0].strip() or b"0", 16):
|
|
134
|
+
parts.append(self.rfile.read(size))
|
|
135
|
+
self.rfile.readline()
|
|
136
|
+
self.rfile.readline()
|
|
137
|
+
return b"".join(parts)
|
|
138
|
+
|
|
139
|
+
def _cloud_code(self, method: str, raw: bytes):
|
|
140
|
+
if method != "streamGenerateContent":
|
|
141
|
+
return self._json(200, _CLOUD_CODE.get(method, {}))
|
|
142
|
+
fake.requests.append(json.loads(raw))
|
|
143
|
+
data = f"data: {json.dumps({'response': _GEMINI_OK})}\r\n\r\n".encode()
|
|
144
|
+
self.send_response(200)
|
|
145
|
+
self.send_header("content-type", "text/event-stream")
|
|
146
|
+
self.send_header("content-length", str(len(data)))
|
|
147
|
+
self.end_headers()
|
|
148
|
+
self.wfile.write(data)
|
|
149
|
+
|
|
150
|
+
def do_POST(self):
|
|
151
|
+
raw = self._body()
|
|
152
|
+
path = self.path.split("?")[0]
|
|
153
|
+
if path.startswith("/v1internal:"):
|
|
154
|
+
return self._cloud_code(path.split(":", 1)[1], raw)
|
|
155
|
+
if path.endswith("/messages/count_tokens"):
|
|
156
|
+
return self._json(200, {"input_tokens": 1})
|
|
157
|
+
if not path.endswith(("/v1/messages", "/responses")):
|
|
158
|
+
return self._not_found()
|
|
159
|
+
if self.headers.get("content-encoding"):
|
|
160
|
+
# Codex compresses unless told not to (--disable enable_request_compression)
|
|
161
|
+
return self._json(415, {"error": {"message": "tare fake: send uncompressed bodies"}})
|
|
162
|
+
body = json.loads(raw)
|
|
163
|
+
fake.requests.append(body)
|
|
164
|
+
if path.endswith("/responses"):
|
|
165
|
+
return self._stream(_responses_events())
|
|
166
|
+
model = body.get("model", "fake")
|
|
167
|
+
blocks = fake.respond(body) if body.get("tools") else OK
|
|
168
|
+
if body.get("stream"):
|
|
169
|
+
return self._stream(_messages_events(model, blocks))
|
|
170
|
+
self._json(200, {"id": "msg_tare", "type": "message", "role": "assistant", "model": model,
|
|
171
|
+
"content": blocks, "stop_reason": _stop_reason(blocks),
|
|
172
|
+
"stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}})
|
|
173
|
+
|
|
174
|
+
self._server = _QuietServer(("127.0.0.1", 0), Handler)
|
|
175
|
+
self.url = f"http://127.0.0.1:{self._server.server_address[1]}"
|
|
176
|
+
|
|
177
|
+
def __enter__(self):
|
|
178
|
+
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, *exc):
|
|
182
|
+
self._server.shutdown()
|
|
183
|
+
self._server.server_close()
|
tare/journal.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The run journal: everything a Cliff or Swap run did, one JSON line per event.
|
|
2
|
+
|
|
3
|
+
The dashboard derives its whole state from the run directory, and the journal is its spine:
|
|
4
|
+
phases, probe readings, original runs, tails started and finished, search steps, the report.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Journal:
|
|
13
|
+
def __init__(self, out: Path | None):
|
|
14
|
+
self.path = out / "journal.jsonl" if out else None
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def __call__(self, event: str, **data):
|
|
18
|
+
if not self.path:
|
|
19
|
+
return
|
|
20
|
+
line = json.dumps({"t": round(time.time(), 3), "event": event, **data}, ensure_ascii=False)
|
|
21
|
+
with self._lock, self.path.open("a") as f:
|
|
22
|
+
f.write(line + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read(out: Path) -> list[dict]:
|
|
26
|
+
path = out / "journal.jsonl"
|
|
27
|
+
if not path.exists():
|
|
28
|
+
return []
|
|
29
|
+
events = []
|
|
30
|
+
for line in path.read_text().splitlines():
|
|
31
|
+
try:
|
|
32
|
+
events.append(json.loads(line))
|
|
33
|
+
except ValueError:
|
|
34
|
+
continue # a line being written
|
|
35
|
+
return events
|
tare/judge.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Judge: a check for work that tests cannot settle, such as the quality of a page (epic Judge).
|
|
2
|
+
|
|
3
|
+
The finished page is rendered headless in a room of its own (no home directory, the
|
|
4
|
+
workspace read-only) and photographed. A judge agent then gets the screenshot, the source
|
|
5
|
+
and the rubric in a fresh tare room, without any agent or model names, and answers with a
|
|
6
|
+
score. The check passes at or above a threshold. Before a threshold is used, `noise` scores
|
|
7
|
+
the same pages repeatedly: a threshold inside a page's spread would measure the judge's
|
|
8
|
+
dice, not the agent.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import statistics
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import room as rooms
|
|
20
|
+
from .agents import AGENTS, events, strings
|
|
21
|
+
|
|
22
|
+
VIEWPORT = "1280,900"
|
|
23
|
+
PROMPT = ("You are a strict, careful judge. Read rubric.md. Look at page.png, a screenshot of the page, and at the "
|
|
24
|
+
"source in the source/ directory. Score the page from 0 to 100 exactly as the rubric says. Reply with "
|
|
25
|
+
'nothing but one line of JSON: {"score": <0-100>, "reason": "<one sentence>"}')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render(work: Path, page: str, target: Path, wait_ms: int = 2000):
|
|
29
|
+
"""Screenshot of work/page, rendered headless in a room: the workspace read-only, no home directory."""
|
|
30
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
shot = Path(tempfile.mkdtemp(prefix="tare-render-"))
|
|
32
|
+
try:
|
|
33
|
+
argv = ["bwrap", "--ro-bind", "/usr", "/usr", "--ro-bind", "/etc", "/etc", "--ro-bind", "/opt", "/opt",
|
|
34
|
+
"--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64",
|
|
35
|
+
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/home",
|
|
36
|
+
"--ro-bind", str(work), "/work", "--bind", str(shot), "/shot",
|
|
37
|
+
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent",
|
|
38
|
+
"--clearenv", "--setenv", "HOME", "/tmp", "--setenv", "PATH", "/usr/bin:/bin", "--",
|
|
39
|
+
"google-chrome", "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
|
40
|
+
f"--window-size={VIEWPORT}", f"--virtual-time-budget={wait_ms}", "--screenshot=/shot/page.png",
|
|
41
|
+
f"file:///work/{page}"]
|
|
42
|
+
subprocess.run(argv, capture_output=True, timeout=120, stdin=subprocess.DEVNULL)
|
|
43
|
+
if not (shot / "page.png").exists():
|
|
44
|
+
raise RuntimeError(f"the page {page} did not render")
|
|
45
|
+
shutil.copyfile(shot / "page.png", target)
|
|
46
|
+
finally:
|
|
47
|
+
shutil.rmtree(shot, ignore_errors=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _score(stdout: str) -> tuple[int, str]:
|
|
51
|
+
"""The last {"score": ...} the judge wrote, in any of its event stream's strings or in plain text."""
|
|
52
|
+
texts = [s for e in events(stdout) for s in strings(e)] or [stdout]
|
|
53
|
+
for text in reversed(texts):
|
|
54
|
+
for match in reversed(list(re.finditer(r'\{[^{}]*"score"\s*:\s*\d+[^{}]*\}', text))):
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(match.group(0))
|
|
57
|
+
return int(data["score"]), str(data.get("reason", ""))
|
|
58
|
+
except (ValueError, KeyError):
|
|
59
|
+
continue
|
|
60
|
+
raise RuntimeError("the judge gave no score")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def judge(work: Path, rubric: Path, page: str = "index.html", agent_name: str = "claude",
|
|
64
|
+
agent_args: list[str] | None = None, timeout: float = 600) -> tuple[int, str]:
|
|
65
|
+
"""Score the page in work/ by the rubric; the judge agent runs in a fresh, blind room."""
|
|
66
|
+
agent = AGENTS[agent_name]
|
|
67
|
+
real = agent.discover()
|
|
68
|
+
with tempfile.TemporaryDirectory(prefix="tare-judge-") as tmp:
|
|
69
|
+
bench = Path(tmp)
|
|
70
|
+
render(work, page, bench / "page.png")
|
|
71
|
+
shutil.copyfile(rubric, bench / "rubric.md")
|
|
72
|
+
# the source the agent wrote, without anything that names an agent or its setup
|
|
73
|
+
shutil.copytree(work, bench / "source", ignore=shutil.ignore_patterns(
|
|
74
|
+
".git", ".claude", ".codex", ".pi", "AGENTS.md", "CLAUDE.md", "node_modules"))
|
|
75
|
+
with rooms.room_home(agent, real) as home:
|
|
76
|
+
argv = rooms.bwrap(agent, real, home, bench, [agent.name, *agent.room_flags,
|
|
77
|
+
*agent.run_args(PROMPT, agent_args or [])])
|
|
78
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
79
|
+
_log_usage(agent, proc.stdout)
|
|
80
|
+
return _score(proc.stdout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _log_usage(agent, stdout: str):
|
|
84
|
+
"""When tare runs this judge as a check, it names a file for the judge agent's usage."""
|
|
85
|
+
log = os.environ.get("TARE_USAGE_LOG")
|
|
86
|
+
if log:
|
|
87
|
+
with open(log, "a") as f:
|
|
88
|
+
f.write(json.dumps(agent.usage(stdout).to_dict()) + "\n")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def noise(pages: list[Path], rubric: Path, times: int, threshold: float | None, page: str = "index.html",
|
|
92
|
+
agent_name: str = "claude", agent_args: list[str] | None = None) -> tuple[str, bool]:
|
|
93
|
+
"""Score each page `times` times. Returns the report and whether the threshold sits outside every spread."""
|
|
94
|
+
lines = [f"tare judge-noise · {agent_name} {' '.join(agent_args or [])}".rstrip(), f" rubric {rubric}",
|
|
95
|
+
"", " page scores mean sd min-max"]
|
|
96
|
+
clear = True
|
|
97
|
+
for p in pages:
|
|
98
|
+
scores = [judge(p, rubric, page, agent_name, agent_args)[0] for _ in range(times)]
|
|
99
|
+
mean, sd = statistics.mean(scores), statistics.pstdev(scores)
|
|
100
|
+
inside = threshold is not None and min(scores) <= threshold <= max(scores)
|
|
101
|
+
clear = clear and not inside
|
|
102
|
+
lines.append(f" {str(p)[-32:]:<32} {' '.join(f'{x:>3}' for x in scores):<30} {mean:5.1f} {sd:4.1f} "
|
|
103
|
+
f"{min(scores)}-{max(scores)}" + (" <- threshold inside this page's range" if inside else ""))
|
|
104
|
+
if threshold is not None:
|
|
105
|
+
lines += ["", f" threshold {threshold}: " + ("outside every page's range of scores" if clear else
|
|
106
|
+
"INSIDE a page's range of scores. There, chance decides pass or "
|
|
107
|
+
"fail.")]
|
|
108
|
+
return "\n".join(lines) + "\n", clear
|
tare/overview.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>tare runs</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0e1116; --panel: #161b22; --panel-2: #1c222b; --line: #2a313c; --text: #e6edf3; --muted: #8b96a5;
|
|
10
|
+
--pass: #3fb950; --fail: #f85149; --run: #d29922; --accent: #79c0ff;
|
|
11
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: light) {
|
|
14
|
+
:root { --bg: #f6f8fa; --panel: #ffffff; --panel-2: #f0f3f6; --line: #d0d7de; --text: #1f2328; --muted: #59636e;
|
|
15
|
+
--pass: #1a7f37; --fail: #cf222e; --run: #9a6700; --accent: #0969da; }
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
19
|
+
main { max-width: 1180px; margin: 0 auto; padding: 20px 16px 60px; }
|
|
20
|
+
h1 { font: 600 22px/1 var(--mono); margin: 0 0 16px; letter-spacing: .5px; }
|
|
21
|
+
section.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; overflow-x: auto; }
|
|
22
|
+
table { width: 100%; border-collapse: collapse; }
|
|
23
|
+
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
24
|
+
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
|
25
|
+
a { color: var(--accent); text-decoration: none; }
|
|
26
|
+
a:hover { text-decoration: underline; }
|
|
27
|
+
.mono { font-family: var(--mono); font-size: 12.5px; }
|
|
28
|
+
.muted { color: var(--muted); }
|
|
29
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; margin-right: 6px; }
|
|
30
|
+
.dot.pass { background: var(--pass); } .dot.fail { background: var(--fail); } .dot.run { background: var(--run); animation: pulse 1.2s infinite; }
|
|
31
|
+
@keyframes pulse { 50% { opacity: .35; } }
|
|
32
|
+
.task { max-width: 340px; overflow-wrap: anywhere; }
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<main>
|
|
37
|
+
<h1>tare runs</h1>
|
|
38
|
+
<section class="panel"><table>
|
|
39
|
+
<thead><tr><th>run</th><th>phase</th><th>task</th><th>sides</th><th>progress</th><th>result</th></tr></thead>
|
|
40
|
+
<tbody id="rows"><tr><td colspan="6" class="muted">loading…</td></tr></tbody>
|
|
41
|
+
</table></section>
|
|
42
|
+
</main>
|
|
43
|
+
<script>
|
|
44
|
+
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
45
|
+
const short = (t, n) => (t && t.length > n ? t.slice(0, n - 1) + "…" : t || "");
|
|
46
|
+
|
|
47
|
+
function result(r) {
|
|
48
|
+
if (r.verdict) return esc(r.verdict);
|
|
49
|
+
if (!r.rates) return `<span class="muted">–</span>`;
|
|
50
|
+
return Object.entries(r.rates).map(([k, x]) => {
|
|
51
|
+
const score = x.mean != null ? ` · mean ${x.mean.toFixed(1)}` : "";
|
|
52
|
+
return `<div class="mono">${esc(k)} ${x.passes}/${x.n} ${r.check ? "passed" : "finished"}${score}</div>`;
|
|
53
|
+
}).join("");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function row(r) {
|
|
57
|
+
const phase = r.phase === "done" ? "pass" : "run";
|
|
58
|
+
const sides = Object.entries(r.sides || {}).map(([k, s]) =>
|
|
59
|
+
`<div class="mono">${esc(k)} ${esc(s.agent)} ${esc((s.args || []).join(" "))}${s.prompt ? `<div class="muted">${esc(short(s.prompt, 60))}</div>` : ""}</div>`).join("");
|
|
60
|
+
return `<tr><td><a href="run/${r.i}/"><b>${esc(r.kind || "run")}</b></a><div class="muted mono">${esc(r.name)}</div></td>
|
|
61
|
+
<td><span class="dot ${phase}"></span>${esc(r.phase)}</td>
|
|
62
|
+
<td class="task">${esc(short(r.task, 140)) || `<span class="muted">a prompt per side</span>`}</td>
|
|
63
|
+
<td>${sides}</td><td class="mono">${r.done} ${r.kind === "calibrate" ? "runs" : "tails"} done${r.running ? ` · ${r.running} running` : ""}</td><td>${result(r)}</td></tr>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function tick() {
|
|
67
|
+
try {
|
|
68
|
+
const runs = await (await fetch("runs", { cache: "no-store" })).json();
|
|
69
|
+
document.getElementById("rows").innerHTML = runs.map(row).join("");
|
|
70
|
+
} catch (e) {
|
|
71
|
+
document.getElementById("rows").innerHTML = `<tr><td colspan="6" class="muted">disconnected</td></tr>`;
|
|
72
|
+
}
|
|
73
|
+
setTimeout(tick, 2000);
|
|
74
|
+
}
|
|
75
|
+
tick();
|
|
76
|
+
</script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|