marcus-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ """
2
+ Read what a run's agents spent, from the rows they wrote, while the run is going.
3
+
4
+ Two layouts are read, because two kinds of agent exist today:
5
+
6
+ * ``<state>/<agent>/<agent>/pi-sessions/**/*.jsonl``: the pi coding agent's
7
+ own transcripts, as the Minecraft harness writes them; every usage mapping
8
+ nested anywhere in an entry counts.
9
+ * ``<state>/<agent>/usage.jsonl``: one row per model call, ``{"at": ..,
10
+ "model": .., "usage": {input, output, cacheRead, cacheWrite}}``, which any
11
+ runtime can write (ADR-0013 K8: a non-Claude-Code runtime has no transcript
12
+ to parse).
13
+
14
+ A file may be mid-write when it is read during a live run: a trailing partial
15
+ line is skipped, so this is safe to call repeatedly.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Iterable, Iterator
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from . import pricing
26
+
27
+ _ANTHROPIC_NAMES = {
28
+ "input_tokens": "input",
29
+ "output_tokens": "output",
30
+ "cache_read_input_tokens": "cacheRead",
31
+ "cache_creation_input_tokens": "cacheWrite",
32
+ }
33
+
34
+
35
+ def normalize_usage(raw: Any) -> dict[str, int] | None:
36
+ """
37
+ Read a usage mapping in either pi's or Anthropic's field names.
38
+
39
+ Parameters
40
+ ----------
41
+ raw : Any
42
+ A mapping with ``input``/``output`` or ``input_tokens``/``output_tokens``.
43
+
44
+ Returns
45
+ -------
46
+ Optional[Dict[str, int]]
47
+ ``{input, output, cacheRead, cacheWrite}`` with every key present, or
48
+ ``None`` if ``raw`` is not a usage mapping.
49
+ """
50
+ if not isinstance(raw, dict):
51
+ return None
52
+ mapped = {pricing.USAGE_KEYS[i]: 0 for i in range(4)}
53
+ seen = False
54
+ for key, value in raw.items():
55
+ name = _ANTHROPIC_NAMES.get(key, key)
56
+ if name in mapped and isinstance(value, int):
57
+ mapped[name] = value
58
+ if name in ("input", "output"):
59
+ seen = True
60
+ return mapped if seen else None
61
+
62
+
63
+ def _usages(entry: Any) -> Iterable[dict[str, Any]]:
64
+ """Yield every usage mapping nested anywhere in a transcript entry."""
65
+ if isinstance(entry, dict):
66
+ if isinstance(entry.get("input"), int) and isinstance(entry.get("output"), int):
67
+ yield entry
68
+ for value in entry.values():
69
+ yield from _usages(value)
70
+ elif isinstance(entry, list):
71
+ for value in entry:
72
+ yield from _usages(value)
73
+
74
+
75
+ def _lines(file: Path) -> Iterator[Any]:
76
+ for line in file.read_text(errors="replace").splitlines():
77
+ try:
78
+ yield json.loads(line)
79
+ except json.JSONDecodeError:
80
+ continue
81
+
82
+
83
+ def rows(state_dir: Path) -> Iterator[tuple[str, dict[str, int], str | None]]:
84
+ """
85
+ Yield ``(agent, usage, model)`` for every usage row under ``state_dir``.
86
+
87
+ Parameters
88
+ ----------
89
+ state_dir : Path
90
+ The run's state directory, one subdirectory per agent.
91
+
92
+ Yields
93
+ ------
94
+ Tuple[str, Dict[str, int], Optional[str]]
95
+ The agent's name, its normalised usage, and the model the row names
96
+ (``None`` for pi transcripts, which do not say).
97
+ """
98
+ for session_dir in sorted(state_dir.glob("*/*/pi-sessions")):
99
+ agent = session_dir.parent.name
100
+ for file in sorted(session_dir.rglob("*.jsonl")):
101
+ for entry in _lines(file):
102
+ for usage in _usages(entry):
103
+ normalized = normalize_usage(usage)
104
+ if normalized is not None:
105
+ yield agent, normalized, None
106
+ for file in sorted(state_dir.glob("*/usage.jsonl")):
107
+ agent = file.parent.name
108
+ for entry in _lines(file):
109
+ if not isinstance(entry, dict):
110
+ continue
111
+ normalized = normalize_usage(entry.get("usage"))
112
+ if normalized is not None:
113
+ model = entry.get("model")
114
+ yield agent, normalized, str(model) if model else None
115
+
116
+
117
+ def costs(state_dir: Path, model_hint: str = "sonnet") -> dict[str, dict[str, Any]]:
118
+ """
119
+ Price each agent's usage.
120
+
121
+ Parameters
122
+ ----------
123
+ state_dir : Path
124
+ The run's state directory.
125
+ model_hint : str
126
+ The price list for rows that do not name their model.
127
+
128
+ Returns
129
+ -------
130
+ Dict[str, Dict[str, Any]]
131
+ Per agent: ``model_calls``, ``billed_calls`` (rows with any tokens;
132
+ pi records a zero row for an aborted continuation), token totals,
133
+ ``dollars`` and the models seen.
134
+ """
135
+ per_agent: dict[str, dict[str, Any]] = {}
136
+ for agent, usage, model in rows(state_dir):
137
+ row = per_agent.setdefault(
138
+ agent,
139
+ {
140
+ "model_calls": 0,
141
+ "billed_calls": 0,
142
+ "input": 0,
143
+ "output": 0,
144
+ "cache_read": 0,
145
+ "cache_write": 0,
146
+ "dollars": 0.0,
147
+ "models": [],
148
+ },
149
+ )
150
+ row["model_calls"] += 1
151
+ tokens = sum(usage.values())
152
+ if tokens > 0:
153
+ row["billed_calls"] += 1
154
+ row["input"] += usage["input"]
155
+ row["output"] += usage["output"]
156
+ row["cache_read"] += usage["cacheRead"]
157
+ row["cache_write"] += usage["cacheWrite"]
158
+ name = model or model_hint
159
+ row["dollars"] += pricing.price(usage, name)
160
+ key = pricing.resolve(name)
161
+ if key not in row["models"]:
162
+ row["models"].append(key)
163
+ for row in per_agent.values():
164
+ row["dollars"] = round(row["dollars"], 4)
165
+ return per_agent
166
+
167
+
168
+ def total_dollars(state_dir: Path, model_hint: str = "sonnet") -> float:
169
+ """
170
+ Sum every agent's spend: the one number a stop-line check needs.
171
+
172
+ Parameters
173
+ ----------
174
+ state_dir : Path
175
+ The run's state directory.
176
+ model_hint : str
177
+ The price list for rows that do not name their model.
178
+
179
+ Returns
180
+ -------
181
+ float
182
+ Dollars so far, rounded to four places.
183
+ """
184
+ return float(
185
+ round(
186
+ sum(
187
+ float(agent["dollars"])
188
+ for agent in costs(state_dir, model_hint).values()
189
+ ),
190
+ 4,
191
+ )
192
+ )
193
+
194
+
195
+ def write_usage_row(
196
+ path: Path,
197
+ usage: dict[str, Any],
198
+ model: str,
199
+ at: str,
200
+ extra: dict[str, Any] | None = None,
201
+ ) -> None:
202
+ """
203
+ Append one usage row in the simple layout, for runtimes that write their own.
204
+
205
+ Parameters
206
+ ----------
207
+ path : Path
208
+ ``<state>/<agent>/usage.jsonl``; parents are created.
209
+ usage : Dict[str, Any]
210
+ Token counts in either naming.
211
+ model : str
212
+ The model billed.
213
+ at : str
214
+ An ISO timestamp.
215
+ extra : Optional[Dict[str, Any]]
216
+ Any other fields to keep beside the row (a turn number, a task id).
217
+ """
218
+ normalized = normalize_usage(usage) or {k: 0 for k in pricing.USAGE_KEYS}
219
+ path.parent.mkdir(parents=True, exist_ok=True)
220
+ row = {"at": at, "model": model, "usage": normalized, **(extra or {})}
221
+ with path.open("a") as handle:
222
+ handle.write(json.dumps(row) + "\n")
@@ -0,0 +1,150 @@
1
+ """
2
+ Who was shown a candidate, and who declined it.
3
+
4
+ Evaporation counts views rather than seconds (Larry, 2026-11-06, "views,
5
+ k=3"), and a view alone is not a decline: a decline requires an opportunity.
6
+ An agent counts against a candidate only when, after the reply that showed
7
+ it, that agent had at least one further turn in which support was possible
8
+ and did not give it. The four cases:
9
+
10
+ * shown, then supported: support, never a decline;
11
+ * shown, then a turn without naming the subject: one decline;
12
+ * shown, then no further turn (died, or the run ended): not a decline;
13
+ * shown twice, then one turn: still one decline; agents are counted, not views.
14
+
15
+ The data: the agent's runtime writes a ``candidate_view`` record into its
16
+ ``board-calls.jsonl`` for every reply that carried candidates, beside the one
17
+ record per board call. Views are never reported to the board, where they
18
+ could be mistaken for support.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterable
24
+ from typing import Any
25
+
26
+ from .candidates import ABSTAIN_MARK, OBJECT_MARK
27
+
28
+ #: Calls only a deciding agent makes; an absorbed poll is not a turn.
29
+ OPPORTUNITY_TOOLS: set[str] = {
30
+ "report_task_progress",
31
+ "give_opinion",
32
+ "log_artifact",
33
+ "get_task_context",
34
+ "query_project_history",
35
+ "log_decision",
36
+ }
37
+ #: Calls through which support can be given.
38
+ SUPPORT_TOOLS: set[str] = {"report_task_progress", "give_opinion"}
39
+ NOT_SUPPORT_MARKS = (OBJECT_MARK, ABSTAIN_MARK)
40
+
41
+
42
+ def _is_support(what: Any) -> bool:
43
+ said = str(what or "").strip().upper()
44
+ return not any(said.startswith(mark) for mark in NOT_SUPPORT_MARKS)
45
+
46
+
47
+ def tally(
48
+ calls: Iterable[dict[str, Any]], subjects: Iterable[str] | None = None
49
+ ) -> dict[str, dict[str, Any]]:
50
+ """
51
+ Compute, per candidate subject, who was shown it and who declined it.
52
+
53
+ Parameters
54
+ ----------
55
+ calls : Iterable[Dict[str, Any]]
56
+ Board-call records in time order (``marcus_toolkit.measure.read_calls``).
57
+ subjects : Optional[Iterable[str]]
58
+ Only these subjects; ``None`` tallies every subject seen.
59
+
60
+ Returns
61
+ -------
62
+ Dict[str, Dict[str, Any]]
63
+ ``{subject: {"shown_to", "declined_by", "supported_by", "awaiting_turn"}}``,
64
+ each a sorted list of agents.
65
+ """
66
+ wanted = set(subjects) if subjects is not None else None
67
+ first_view: dict[str, dict[str, str]] = {}
68
+ supported: dict[str, set[str]] = {}
69
+ turns: dict[str, list[str]] = {}
70
+
71
+ for call in calls:
72
+ agent = str(call.get("agent") or "")
73
+ tool = str(call.get("tool") or "")
74
+ at = str(call.get("at") or "")
75
+ args = call.get("args") or {}
76
+ if not agent or not at:
77
+ continue
78
+ if tool in OPPORTUNITY_TOOLS:
79
+ turns.setdefault(agent, []).append(at)
80
+ if tool == "candidate_view":
81
+ for subject in args.get("subjects") or []:
82
+ subject = str(subject)
83
+ if wanted is not None and subject not in wanted:
84
+ continue
85
+ first_view.setdefault(subject, {}).setdefault(agent, at)
86
+ elif tool in SUPPORT_TOOLS:
87
+ subject = str(args.get("subject") or "")
88
+ if (
89
+ subject
90
+ and (wanted is None or subject in wanted)
91
+ and _is_support(args.get("what"))
92
+ ):
93
+ supported.setdefault(subject, set()).add(agent)
94
+
95
+ out: dict[str, dict[str, Any]] = {}
96
+ for subject in sorted(set(first_view) | set(wanted or ())):
97
+ seen = first_view.get(subject, {})
98
+ supporters = supported.get(subject, set())
99
+ declined: set[str] = set()
100
+ awaiting: set[str] = set()
101
+ for agent, view_at in seen.items():
102
+ if agent in supporters:
103
+ continue
104
+ if any(t > view_at for t in turns.get(agent, [])):
105
+ declined.add(agent)
106
+ else:
107
+ awaiting.add(agent)
108
+ out[subject] = {
109
+ "shown_to": sorted(seen),
110
+ "declined_by": sorted(declined),
111
+ "supported_by": sorted(supporters & set(seen)),
112
+ "awaiting_turn": sorted(awaiting),
113
+ }
114
+ return out
115
+
116
+
117
+ def candidate_views(
118
+ calls: Iterable[dict[str, Any]], before: dict[str, str] | None = None
119
+ ) -> dict[str, list[str]]:
120
+ """
121
+ Count, per candidate subject, the agents that were shown it while it was open.
122
+
123
+ Parameters
124
+ ----------
125
+ calls : Iterable[Dict[str, Any]]
126
+ Board-call records.
127
+ before : Optional[Dict[str, str]]
128
+ Subject to its close time; a view at or after the close is a view of
129
+ a stale marker and does not count. A subject absent here is open.
130
+
131
+ Returns
132
+ -------
133
+ Dict[str, List[str]]
134
+ Subject to the sorted agents that saw it in time; subjects nobody saw
135
+ in time are absent.
136
+ """
137
+ seen: dict[str, set[str]] = {}
138
+ for call in calls:
139
+ if call.get("tool") != "candidate_view":
140
+ continue
141
+ agent = str(call.get("agent") or "")
142
+ at = str(call.get("at") or "")
143
+ for subject in (call.get("args") or {}).get("subjects") or []:
144
+ subject = str(subject)
145
+ closed = (before or {}).get(subject)
146
+ if closed and at >= closed:
147
+ continue
148
+ if agent:
149
+ seen.setdefault(subject, set()).add(agent)
150
+ return {s: sorted(a) for s, a in seen.items() if a}
@@ -0,0 +1,264 @@
1
+ """
2
+ The cost watchdog: stop a run the moment its spend reaches the approved line.
3
+
4
+ Lifted from the Minecraft plumbing's ``cost_watchdog.py`` (issue #815 there),
5
+ with the defect the numerical verifier's run found fixed: the stop line is
6
+ read fresh on every poll when it comes from a file, so a raised cap takes
7
+ effect without a restart (``haiku-run-v2/REPORT.md``, section 8, item 4).
8
+
9
+ The comparison is ``>=``: a stop line is the ceiling the human approved, and
10
+ a run that has reached it has reached it. The watchdog returns on the poll
11
+ that breaches and never polls again; the run it watched is expected to be
12
+ gone.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import subprocess # nosec B404 - stopping our own run's processes, fixed argv, no shell
19
+ import sys
20
+ import time
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from .usage import total_dollars
26
+
27
+ StopLine = float | Callable[[], float]
28
+
29
+
30
+ @dataclass
31
+ class PollResult:
32
+ """What one poll found and did."""
33
+
34
+ dollars: float
35
+ stop_line: float
36
+ killed: bool
37
+
38
+
39
+ class StopLineFile:
40
+ """
41
+ A stop line read from a file on every poll.
42
+
43
+ Parameters
44
+ ----------
45
+ path : Path
46
+ A file holding one number. A missing or unreadable file keeps the
47
+ last value read, and the first read must succeed.
48
+ """
49
+
50
+ def __init__(self, path: Path) -> None:
51
+ self.path = path
52
+ self._last = float(path.read_text().strip())
53
+
54
+ def __call__(self) -> float:
55
+ """Return the stop line, re-read from the file."""
56
+ try:
57
+ self._last = float(self.path.read_text().strip())
58
+ except (OSError, ValueError):
59
+ pass
60
+ return self._last
61
+
62
+
63
+ def _line(stop_line: StopLine) -> float:
64
+ return float(stop_line()) if callable(stop_line) else float(stop_line)
65
+
66
+
67
+ def watch_once(
68
+ state_dir: Path,
69
+ stop_line: StopLine,
70
+ kill: Callable[[], None],
71
+ model_hint: str = "sonnet",
72
+ ) -> PollResult:
73
+ """
74
+ Read accumulated cost once and kill the run if it has reached the stop line.
75
+
76
+ Parameters
77
+ ----------
78
+ state_dir : Path
79
+ The run's state directory.
80
+ stop_line : float or callable
81
+ The approved cap, or a callable that returns it fresh.
82
+ kill : Callable[[], None]
83
+ Called exactly once, synchronously, on breach.
84
+ model_hint : str
85
+ The price list for rows that do not name their model.
86
+
87
+ Returns
88
+ -------
89
+ PollResult
90
+ """
91
+ line = _line(stop_line)
92
+ dollars = total_dollars(state_dir, model_hint)
93
+ breached = dollars >= line
94
+ if breached:
95
+ kill()
96
+ return PollResult(dollars=dollars, stop_line=line, killed=breached)
97
+
98
+
99
+ def watch(
100
+ state_dir: Path,
101
+ stop_line: StopLine,
102
+ kill: Callable[[], None],
103
+ poll_seconds: float = 30.0,
104
+ max_polls: int | None = None,
105
+ model_hint: str = "sonnet",
106
+ on_poll: Callable[[PollResult], None] | None = None,
107
+ sleep: Callable[[float], None] = time.sleep,
108
+ ) -> PollResult | None:
109
+ """
110
+ Poll until the run is killed or ``max_polls`` is exhausted.
111
+
112
+ Parameters
113
+ ----------
114
+ state_dir : Path
115
+ The run's state directory.
116
+ stop_line : float or callable
117
+ The approved cap, or a callable that returns it fresh each poll.
118
+ kill : Callable[[], None]
119
+ The stop action.
120
+ poll_seconds : float
121
+ Sleep between polls.
122
+ max_polls : Optional[int]
123
+ Stop after this many polls without a breach; ``None`` is unbounded.
124
+ model_hint : str
125
+ The price list for rows that do not name their model.
126
+ on_poll : Optional[Callable[[PollResult], None]]
127
+ Called after every poll, for logging.
128
+ sleep : Callable[[float], None]
129
+ The sleep function; tests pass a stub.
130
+
131
+ Returns
132
+ -------
133
+ Optional[PollResult]
134
+ The last poll, or ``None`` if none ran.
135
+ """
136
+ polls = 0
137
+ last: PollResult | None = None
138
+ while max_polls is None or polls < max_polls:
139
+ last = watch_once(state_dir, stop_line, kill, model_hint)
140
+ if on_poll:
141
+ on_poll(last)
142
+ polls += 1
143
+ if last.killed:
144
+ return last
145
+ if max_polls is not None and polls >= max_polls:
146
+ return last
147
+ sleep(poll_seconds)
148
+ return last
149
+
150
+
151
+ def kill_action(
152
+ pid_files: Sequence[Path] = (), docker_name_pattern: str | None = None
153
+ ) -> Callable[[], None]:
154
+ """
155
+ Build the stop action: stop matching containers, then kill each pid in the files.
156
+
157
+ Each step is wrapped on its own, so one failing half does not block the
158
+ other: a half-stopped run still beats an unstopped one. Containers go
159
+ first, because they are what spends.
160
+
161
+ Parameters
162
+ ----------
163
+ pid_files : Sequence[Path]
164
+ Files holding one pid each (the server under test, the agents).
165
+ docker_name_pattern : Optional[str]
166
+ A ``docker ps --filter name=`` substring for the run's containers.
167
+
168
+ Returns
169
+ -------
170
+ Callable[[], None]
171
+ """
172
+
173
+ def _kill() -> None:
174
+ if docker_name_pattern:
175
+ try:
176
+ names = subprocess.run( # nosec B603 B607
177
+ [
178
+ "docker",
179
+ "ps",
180
+ "--filter",
181
+ f"name={docker_name_pattern}",
182
+ "--format",
183
+ "{{.Names}}",
184
+ ],
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=10,
188
+ ).stdout.split()
189
+ for name in names:
190
+ subprocess.run(
191
+ ["docker", "stop", "-t", "5", name],
192
+ capture_output=True,
193
+ timeout=15,
194
+ ) # nosec B603 B607
195
+ except Exception as error: # noqa: BLE001
196
+ print(f"[watchdog] docker stop failed: {error}", file=sys.stderr)
197
+ for pid_file in pid_files:
198
+ try:
199
+ pid = int(Path(pid_file).read_text().strip())
200
+ subprocess.run(
201
+ ["kill", str(pid)], capture_output=True, timeout=5
202
+ ) # nosec B603 B607
203
+ except Exception as error: # noqa: BLE001
204
+ print(
205
+ f"[watchdog] kill from {pid_file} failed: {error}", file=sys.stderr
206
+ )
207
+
208
+ return _kill
209
+
210
+
211
+ def main(argv: list[str] | None = None) -> int:
212
+ """CLI entry point: the background watchdog a run script starts."""
213
+ parser = argparse.ArgumentParser(description=__doc__)
214
+ parser.add_argument("--state", required=True, help="the run's state directory")
215
+ group = parser.add_mutually_exclusive_group(required=True)
216
+ group.add_argument("--stop-line", type=float, help="approved hard dollar cap")
217
+ group.add_argument(
218
+ "--stop-line-file", help="a file holding the cap, re-read every poll"
219
+ )
220
+ parser.add_argument("--poll-seconds", type=float, default=30.0)
221
+ parser.add_argument(
222
+ "--model", default="sonnet", help="price list for rows that do not name a model"
223
+ )
224
+ parser.add_argument(
225
+ "--pid-file",
226
+ action="append",
227
+ default=[],
228
+ help="a file holding a pid to kill on breach (repeatable)",
229
+ )
230
+ parser.add_argument("--docker-name-pattern", default=None)
231
+ parser.add_argument("--max-minutes", type=float, default=None)
232
+ args = parser.parse_args(argv)
233
+
234
+ stop_line: StopLine = (
235
+ StopLineFile(Path(args.stop_line_file))
236
+ if args.stop_line_file
237
+ else float(args.stop_line)
238
+ )
239
+ max_polls = (
240
+ int(args.max_minutes * 60 / args.poll_seconds) if args.max_minutes else None
241
+ )
242
+
243
+ def log(result: PollResult) -> None:
244
+ tag = "BREACH -- stopping the run" if result.killed else "ok"
245
+ print(
246
+ f"[watchdog] ${result.dollars:.2f} of ${result.stop_line:.2f}"
247
+ f" stop line -- {tag}",
248
+ flush=True,
249
+ )
250
+
251
+ watch(
252
+ Path(args.state),
253
+ stop_line,
254
+ kill_action([Path(p) for p in args.pid_file], args.docker_name_pattern),
255
+ poll_seconds=args.poll_seconds,
256
+ max_polls=max_polls,
257
+ model_hint=args.model,
258
+ on_poll=log,
259
+ )
260
+ return 0
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())