leos-agent 10.6.0 → 10.7.0
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.
- package/README.md +72 -7
- package/hooks/README.md +93 -0
- package/hooks/hooks-cursor.json +11 -0
- package/hooks/hooks.json +16 -0
- package/index.js +112 -6
- package/package.json +2 -1
- package/rules/preferences.md +7 -8
- package/scripts/check.py +45 -0
- package/scripts/dispatch_guard.py +317 -0
- package/scripts/dispatch_log.py +240 -0
- package/scripts/usage_scan.py +455 -0
- package/skills/review-usage/SKILL.md +97 -0
- package/skills/review-usage/agents/openai.yaml +5 -0
- package/skills/review-usage/reference/sources.md +80 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatch_guard: refuse a subagent dispatch that names no model.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS IN CODE RATHER THAN PROSE. The policy already said every dispatch
|
|
5
|
+
must name a model, and the policy was forgotten. Prose enforcement also costs
|
|
6
|
+
always-loaded bytes on every turn of every session, and the budget in
|
|
7
|
+
measure_context.py is nearly spent -- so the rule that a machine can check moved
|
|
8
|
+
into a hook, which costs nothing, and the payload kept only the judgment a hook
|
|
9
|
+
cannot make. Moving it out of the payload was what paid for the move.
|
|
10
|
+
|
|
11
|
+
WHAT IT REFUSES, AND WHAT IT DELIBERATELY DOES NOT. A dispatch that selects an
|
|
12
|
+
agent, carries a brief, names no model, and runs on a harness that can express
|
|
13
|
+
one per spawn is refused -- because on that path the harness silently inherits
|
|
14
|
+
the parent's expensive model, which is the failure this file exists to prevent.
|
|
15
|
+
Everything else is allowed. The guard NEVER picks a model: it cannot force a
|
|
16
|
+
cheap tier onto work that needed an expensive one, so it cannot cause a quality
|
|
17
|
+
regression, only an explicit choice. That is also why the block stays narrow. A
|
|
18
|
+
false block costs one re-dispatch turn; a caught inherited fan-out saves the cold
|
|
19
|
+
prefix of every child it would have spawned. The margin is wide precisely because
|
|
20
|
+
the rule refuses to make judgment calls, and every widening spends it.
|
|
21
|
+
|
|
22
|
+
SHAPE-BASED, NOT NAME-BASED. Only Claude Code's dispatch tool is verified; the
|
|
23
|
+
argument shapes on Codex, Cursor, Hermes and OpenCode are not. So the decision
|
|
24
|
+
turns on the arguments -- an agent-selection field plus a brief -- and an
|
|
25
|
+
unanticipated tool degrades to a no-op rather than a broken harness. The hook
|
|
26
|
+
manifests still carry a name matcher, but only as a cheap prefilter: without one,
|
|
27
|
+
every Read and Grep would pay a python3 spawn.
|
|
28
|
+
|
|
29
|
+
FAILS OPEN, LOUDLY. The harm here is money, not data loss. A guard that fails
|
|
30
|
+
closed on a broken interpreter wedges every dispatch on every harness, which is
|
|
31
|
+
far worse than the miss it prevents -- and on Claude Code a timed-out hook is
|
|
32
|
+
non-blocking anyway, so fail-closed is not even expressible there. Every internal
|
|
33
|
+
error allows the call and leaves a breadcrumb with decision "error", kept
|
|
34
|
+
rigorously distinct from a decision to allow.
|
|
35
|
+
|
|
36
|
+
LEOS_AGENT_DISPATCH_GUARD on (default) | warn (log, never block) | off | verbose
|
|
37
|
+
LEOS_AGENT_HARNESS overrides harness detection
|
|
38
|
+
|
|
39
|
+
Exit codes: 0 allow, 2 block (reason on stderr).
|
|
40
|
+
"""
|
|
41
|
+
import collections
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
49
|
+
|
|
50
|
+
Dispatch = collections.namedtuple(
|
|
51
|
+
"Dispatch",
|
|
52
|
+
"tool agent model prompt_bytes prompt_lines path_count prompt_hash prompt_head",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# The agent-selection field is mandatory, and that is the whole trick. A rule
|
|
56
|
+
# keyed on "has a prompt" would refuse unrelated tools -- spawn_task takes
|
|
57
|
+
# {prompt, title, tldr} and nothing else -- and "has a model" is weaker still.
|
|
58
|
+
# Only naming an agent means "I am choosing who runs this".
|
|
59
|
+
AGENT_KEYS = ("subagent_type", "subagentType", "agent_type", "agentType", "agent", "subagent", "profile")
|
|
60
|
+
PROMPT_KEYS = ("prompt", "brief", "instructions", "task", "message", "input")
|
|
61
|
+
MODEL_KEYS = ("model", "model_id", "modelId", "model_name")
|
|
62
|
+
|
|
63
|
+
TOOL_KEYS = ("tool_name", "toolName", "tool", "name")
|
|
64
|
+
INPUT_KEYS = ("tool_input", "toolInput", "arguments", "args", "input", "params", "parameters")
|
|
65
|
+
|
|
66
|
+
# Third-party MCP tools are not routed by this policy and never will be, and
|
|
67
|
+
# their argument namespaces are outside our control forever. One prefix test
|
|
68
|
+
# removes the entire class of false positives they would otherwise create.
|
|
69
|
+
SKIP_PREFIXES = ("mcp__",)
|
|
70
|
+
|
|
71
|
+
# Seeded empty on purpose: populate from field reports, not from guesses.
|
|
72
|
+
SKIP_TOOLS = frozenset()
|
|
73
|
+
|
|
74
|
+
ALLOW, BLOCK = "allow", "block"
|
|
75
|
+
|
|
76
|
+
# Path-ish tokens in a brief. Scanning is capped at the first 8 KiB -- the
|
|
77
|
+
# feature does not improve past that and a hot path should not read a novel.
|
|
78
|
+
PATH_SCAN_BYTES = 8192
|
|
79
|
+
PATH_RE = re.compile(r"[\w.-]+/[\w./-]+|[\w-]+\.[A-Za-z]{1,5}\b")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _first_str(source, keys):
|
|
83
|
+
for key in keys:
|
|
84
|
+
value = source.get(key)
|
|
85
|
+
if isinstance(value, str) and value.strip():
|
|
86
|
+
return value
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _first_dict(source, keys):
|
|
91
|
+
for key in keys:
|
|
92
|
+
value = source.get(key)
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
return value
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def harness(event):
|
|
99
|
+
"""Which harness this event came from. Best effort, and logged either way.
|
|
100
|
+
|
|
101
|
+
The env var is the override the in-process adapters set. Otherwise the
|
|
102
|
+
transcript path is the only hint a command hook gets; when it says nothing,
|
|
103
|
+
assume Claude Code, the one harness whose dispatch tool is verified.
|
|
104
|
+
"""
|
|
105
|
+
named = os.environ.get("LEOS_AGENT_HARNESS", "").strip().lower()
|
|
106
|
+
if named:
|
|
107
|
+
return named
|
|
108
|
+
hint = ""
|
|
109
|
+
if isinstance(event, dict):
|
|
110
|
+
hint = _first_str(event, ("transcript_path", "transcriptPath", "cwd"))
|
|
111
|
+
for name in ("codex", "cursor", "hermes", "opencode"):
|
|
112
|
+
if "/." + name in hint or "/" + name + "/" in hint:
|
|
113
|
+
return name
|
|
114
|
+
return "claude"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize(event, _harness=None):
|
|
118
|
+
"""event -> Dispatch, or None when this is not a subagent dispatch.
|
|
119
|
+
|
|
120
|
+
Never raises. Five harnesses' envelopes are unverified, and a KeyError here
|
|
121
|
+
would turn a routing guard into an outage.
|
|
122
|
+
"""
|
|
123
|
+
if not isinstance(event, dict):
|
|
124
|
+
return None
|
|
125
|
+
tool = _first_str(event, TOOL_KEYS)
|
|
126
|
+
if tool in SKIP_TOOLS or any(tool.startswith(p) for p in SKIP_PREFIXES):
|
|
127
|
+
return None
|
|
128
|
+
args = _first_dict(event, INPUT_KEYS)
|
|
129
|
+
if args is None:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
agent = _first_str(args, AGENT_KEYS)
|
|
133
|
+
prompt = _first_str(args, PROMPT_KEYS)
|
|
134
|
+
if not agent or not prompt:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
from dispatch_log import digest # local: a no-op call must not pay for this
|
|
138
|
+
|
|
139
|
+
head = prompt[:PATH_SCAN_BYTES]
|
|
140
|
+
return Dispatch(
|
|
141
|
+
tool=tool or "-",
|
|
142
|
+
agent=agent,
|
|
143
|
+
model=_first_str(args, MODEL_KEYS) or None,
|
|
144
|
+
prompt_bytes=len(prompt.encode("utf-8", "replace")),
|
|
145
|
+
prompt_lines=prompt.count("\n") + 1,
|
|
146
|
+
path_count=len(set(PATH_RE.findall(head))),
|
|
147
|
+
prompt_hash=digest(prompt),
|
|
148
|
+
prompt_head=prompt[:200],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def triviality(dispatch):
|
|
153
|
+
"""0..3. A features-only score; it never changes the exit code.
|
|
154
|
+
|
|
155
|
+
Say plainly what this can and cannot see: a trivial spawn has small *work*,
|
|
156
|
+
and tool input only shows the *brief*. The two failure modes happen to
|
|
157
|
+
collapse -- a short brief is either work too small to deserve a cold context
|
|
158
|
+
or work that is under-briefed, and the policy forbids both -- but roughly one
|
|
159
|
+
in six legitimate runner dispatches will still score here. That rate is fine
|
|
160
|
+
for a log line and disqualifying for a block, which is why this is only ever
|
|
161
|
+
a log line.
|
|
162
|
+
"""
|
|
163
|
+
if dispatch is None:
|
|
164
|
+
return 0
|
|
165
|
+
score = 0
|
|
166
|
+
if dispatch.prompt_bytes < 220:
|
|
167
|
+
score += 2
|
|
168
|
+
elif dispatch.prompt_bytes < 450:
|
|
169
|
+
score += 1
|
|
170
|
+
if dispatch.path_count == 1:
|
|
171
|
+
score += 1
|
|
172
|
+
if dispatch.prompt_lines == 1:
|
|
173
|
+
score += 1
|
|
174
|
+
return min(score, 3)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def routable(name):
|
|
178
|
+
"""Can this harness name a model per spawn?
|
|
179
|
+
|
|
180
|
+
Claude Code's dispatch tool takes one, and any harness Leo has configured in
|
|
181
|
+
routing.json has a model to name. Everywhere else the payload itself says to
|
|
182
|
+
inherit and say so -- blocking there would demand something the harness
|
|
183
|
+
cannot do, a false positive by construction.
|
|
184
|
+
"""
|
|
185
|
+
if name == "claude":
|
|
186
|
+
return True
|
|
187
|
+
try:
|
|
188
|
+
import routing
|
|
189
|
+
return name in routing.load()
|
|
190
|
+
except BaseException:
|
|
191
|
+
# routing.py exits on a malformed config. A hook must never inherit that.
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def decide(dispatch, name, is_routable):
|
|
196
|
+
"""(action, reason). The entire policy, and deliberately four lines of it."""
|
|
197
|
+
if dispatch is None:
|
|
198
|
+
return ALLOW, "not-a-dispatch"
|
|
199
|
+
if dispatch.agent.startswith("leo-"):
|
|
200
|
+
return ALLOW, "leo-tier" # the agent definition carries the model
|
|
201
|
+
if dispatch.model:
|
|
202
|
+
return ALLOW, "explicit-model" # a choice was typed; cheap or not
|
|
203
|
+
if not is_routable:
|
|
204
|
+
return ALLOW, "harness-cannot-route"
|
|
205
|
+
return BLOCK, "no-model"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def render_block(dispatch):
|
|
209
|
+
"""The refusal. It must name the remedies, or it costs a turn to discover them."""
|
|
210
|
+
return (
|
|
211
|
+
'[leo routing] BLOCKED - dispatch to agent "%s" names no model, so it would\n'
|
|
212
|
+
"silently inherit the parent's. Re-dispatch with one of:\n"
|
|
213
|
+
' subagent_type "leo-runner" reading, search, tests, logs, codemods, fan-out\n'
|
|
214
|
+
' subagent_type "leo-executor" an approved plan or a specified change\n'
|
|
215
|
+
' model: "<name>" investigation/debugging - naming it IS the reason\n'
|
|
216
|
+
"Set LEOS_AGENT_DISPATCH_GUARD=off to disable, =warn to log only."
|
|
217
|
+
) % dispatch.agent
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render_notice(dispatch):
|
|
221
|
+
return (
|
|
222
|
+
"[leo routing] %d-byte brief to %s: a fresh agent context is uncached and "
|
|
223
|
+
"costs more than an inline read. Inline it when one file answers it."
|
|
224
|
+
) % (dispatch.prompt_bytes, dispatch.agent)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _log(entry):
|
|
228
|
+
"""Best effort, always. A breadcrumb that cannot be written must not break a
|
|
229
|
+
dispatch, so every failure here is swallowed -- including a missing module."""
|
|
230
|
+
try:
|
|
231
|
+
import dispatch_log
|
|
232
|
+
dispatch_log.append(entry)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def evaluate(event, name=None):
|
|
238
|
+
"""(action, reason, dispatch, trivial) for one event. Shared by every adapter.
|
|
239
|
+
|
|
240
|
+
An in-process adapter knows which harness it is and passes `name`; detection
|
|
241
|
+
exists only for the command hooks, which get no say.
|
|
242
|
+
"""
|
|
243
|
+
name = name or harness(event)
|
|
244
|
+
dispatch = normalize(event, name)
|
|
245
|
+
action, reason = decide(dispatch, name, routable(name))
|
|
246
|
+
return action, reason, dispatch, triviality(dispatch)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv=None):
|
|
250
|
+
mode = os.environ.get("LEOS_AGENT_DISPATCH_GUARD", "on").strip().lower()
|
|
251
|
+
if mode == "off":
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
raw = sys.stdin.buffer.read().decode("utf-8", "replace")
|
|
256
|
+
event = json.loads(raw) if raw.strip() else None
|
|
257
|
+
except Exception:
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
name = harness(event if isinstance(event, dict) else {})
|
|
261
|
+
try:
|
|
262
|
+
action, reason, dispatch, trivial = evaluate(event)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
# The guard broke. Allow, and say so distinctly -- an "error" row is the
|
|
265
|
+
# only way a dead guard is ever noticed.
|
|
266
|
+
_breadcrumb(name, exc)
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
if dispatch is None:
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
session = _first_str(event, ("session_id", "sessionId")) if isinstance(event, dict) else ""
|
|
273
|
+
cwd = _first_str(event, ("cwd", "workspace", "directory")) if isinstance(event, dict) else ""
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
import dispatch_log
|
|
277
|
+
entry = dispatch_log.record(dispatch, action, reason, name, session, cwd, trivial)
|
|
278
|
+
_log(entry)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
if action == BLOCK and mode != "warn":
|
|
283
|
+
sys.stderr.write(render_block(dispatch) + "\n")
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
# A notice is user-facing only. systemMessage reaches Leo's transcript and
|
|
287
|
+
# never the model's context, so a false positive here costs zero tokens.
|
|
288
|
+
# verbose additionally spends ~60 tokens putting it in front of the model;
|
|
289
|
+
# it stays off until the log says the problem is frequent enough to be worth
|
|
290
|
+
# paying for.
|
|
291
|
+
if trivial >= 2:
|
|
292
|
+
payload = {"systemMessage": render_notice(dispatch)}
|
|
293
|
+
if mode == "verbose":
|
|
294
|
+
payload["hookSpecificOutput"] = {
|
|
295
|
+
"hookEventName": "PreToolUse",
|
|
296
|
+
"additionalContext": render_notice(dispatch),
|
|
297
|
+
}
|
|
298
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _breadcrumb(name, exc):
|
|
303
|
+
try:
|
|
304
|
+
import dispatch_log
|
|
305
|
+
dispatch_log.append({
|
|
306
|
+
"v": dispatch_log.RECORD_VERSION,
|
|
307
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
308
|
+
"harness": name,
|
|
309
|
+
"decision": "error",
|
|
310
|
+
"reason": "%s: %s" % (type(exc).__name__, exc),
|
|
311
|
+
})
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sys.exit(main())
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dispatch_log: the append-only record of every subagent dispatch the guard saw.
|
|
3
|
+
|
|
4
|
+
WHY A LOG AT ALL. dispatch_guard.py decides one call at a time and can never see
|
|
5
|
+
a fan-out, so the signals that need a second dispatch to interpret -- was a block
|
|
6
|
+
followed by a compliant re-dispatch, was a small brief one of five siblings --
|
|
7
|
+
are recorded here and resolved at read time. Judgment that lives in the reader
|
|
8
|
+
costs nothing on the hot path and can be revised without a Codex /hooks
|
|
9
|
+
re-approval.
|
|
10
|
+
|
|
11
|
+
NEVER PROMPT TEXT. This file sits in a home directory forever and would otherwise
|
|
12
|
+
accumulate briefs about whatever Leo works on. Prompts and working directories
|
|
13
|
+
are stored as truncated SHA-256, which is enough to notice the same brief
|
|
14
|
+
re-dispatched after a block and useless to anyone reading the file. Raw text only
|
|
15
|
+
under LEOS_AGENT_DISPATCH_LOG_PROMPTS=1, truncated, and documented as debug-only.
|
|
16
|
+
|
|
17
|
+
The file is ${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}/dispatch.jsonl,
|
|
18
|
+
beside routing.json and the handoffs -- data lives with the data, never inside
|
|
19
|
+
the plugin, so an upgrade or an uninstall cannot take it.
|
|
20
|
+
|
|
21
|
+
dispatch_log.py report [--limit N] [--json] what the guard has been seeing
|
|
22
|
+
dispatch_log.py path the log's path
|
|
23
|
+
|
|
24
|
+
Exit codes: 0 ok, 2 on bad usage.
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import collections
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
# Same data root and the same lock discipline as every other machine-local file.
|
|
36
|
+
# _locked takes any path (it locks a sibling .lock), which is why a .jsonl can
|
|
37
|
+
# use it even though state_file() would force a .json suffix.
|
|
38
|
+
from state import _data_root, _locked # noqa: E402
|
|
39
|
+
|
|
40
|
+
LOG_NAME = "dispatch.jsonl"
|
|
41
|
+
|
|
42
|
+
# One megabyte, one generation. ~230 bytes per record is roughly 4,500 dispatches
|
|
43
|
+
# per file and 9,000 retained -- months of history, bounded at 2 MiB forever, with
|
|
44
|
+
# no cron job and nothing to configure.
|
|
45
|
+
MAX_BYTES = 1 << 20
|
|
46
|
+
|
|
47
|
+
RECORD_VERSION = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def path():
|
|
51
|
+
return os.path.join(_data_root(), LOG_NAME)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def digest(text):
|
|
55
|
+
"""A short, irreversible stand-in for text we refuse to store."""
|
|
56
|
+
if not text:
|
|
57
|
+
return None
|
|
58
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _keep_prompts():
|
|
62
|
+
return os.environ.get("LEOS_AGENT_DISPATCH_LOG_PROMPTS") == "1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def record(dispatch, decision, reason, harness, session=None, cwd=None, trivial=0):
|
|
66
|
+
"""The on-disk shape for one dispatch. Pure; writes nothing."""
|
|
67
|
+
entry = {
|
|
68
|
+
"v": RECORD_VERSION,
|
|
69
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
70
|
+
"harness": harness,
|
|
71
|
+
"session": digest(session),
|
|
72
|
+
# Parallel dispatches from one assistant message land in the same
|
|
73
|
+
# two-second bucket, which is how `report` tells a fan-out from a
|
|
74
|
+
# sequence of lone spawns without the hot path ever reading the log.
|
|
75
|
+
"burst": "%s:%d" % (digest(session) or "-", int(time.time() // 2)),
|
|
76
|
+
"project": digest(cwd),
|
|
77
|
+
"decision": decision,
|
|
78
|
+
"reason": reason,
|
|
79
|
+
}
|
|
80
|
+
if dispatch is not None:
|
|
81
|
+
entry.update({
|
|
82
|
+
"tool": dispatch.tool,
|
|
83
|
+
"agent": dispatch.agent,
|
|
84
|
+
"model": dispatch.model,
|
|
85
|
+
"trivial": trivial,
|
|
86
|
+
"prompt_bytes": dispatch.prompt_bytes,
|
|
87
|
+
"prompt_lines": dispatch.prompt_lines,
|
|
88
|
+
"paths": dispatch.path_count,
|
|
89
|
+
"prompt": dispatch.prompt_hash,
|
|
90
|
+
})
|
|
91
|
+
if _keep_prompts():
|
|
92
|
+
entry["prompt_text"] = dispatch.prompt_head
|
|
93
|
+
return entry
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def append(entry):
|
|
97
|
+
"""Append one record. Rotates at MAX_BYTES. Raises only on real I/O trouble.
|
|
98
|
+
|
|
99
|
+
Callers must treat a failure here as cosmetic: the guard's decision is made
|
|
100
|
+
before this is called and emitted after it, so a read-only home or a full
|
|
101
|
+
disk costs a log line, never a dispatch.
|
|
102
|
+
"""
|
|
103
|
+
target = path()
|
|
104
|
+
with _locked(target):
|
|
105
|
+
try:
|
|
106
|
+
if os.path.getsize(target) > MAX_BYTES:
|
|
107
|
+
os.replace(target, target + ".1")
|
|
108
|
+
except OSError:
|
|
109
|
+
pass # absent, or unstattable; either way there is nothing to rotate
|
|
110
|
+
# 0600 explicitly: open("a") yields 0644 under a default umask, which is
|
|
111
|
+
# the wrong mode for a file that indexes Leo's projects even in hashes.
|
|
112
|
+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
113
|
+
with os.fdopen(fd, "a") as fh:
|
|
114
|
+
fh.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read(limit=None, target=None):
|
|
118
|
+
"""Records, oldest first. Tolerates a truncated final line from a crash."""
|
|
119
|
+
out = []
|
|
120
|
+
for candidate in ((target,) if target else (path() + ".1", path())):
|
|
121
|
+
try:
|
|
122
|
+
with open(candidate, encoding="utf-8", errors="replace") as fh:
|
|
123
|
+
for line in fh:
|
|
124
|
+
line = line.strip()
|
|
125
|
+
if not line:
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
entry = json.loads(line)
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue # a half-written last line, or a foreign line
|
|
131
|
+
if isinstance(entry, dict):
|
|
132
|
+
out.append(entry)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
continue
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
sys.exit("dispatch_log: %s: %s" % (candidate, exc.strerror or exc))
|
|
137
|
+
return out[-limit:] if limit else out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def summarise(entries):
|
|
141
|
+
"""The read-time judgment: burst collapse, tiers, and block conversion."""
|
|
142
|
+
# Only dispatches that actually ran count toward a burst. A block and the
|
|
143
|
+
# re-dispatch it forced land in the same two-second bucket, and counting
|
|
144
|
+
# both would let every blocked retry pose as a fan-out of two.
|
|
145
|
+
bursts = collections.Counter(
|
|
146
|
+
e.get("burst") for e in entries if e.get("decision") not in ("block", "error")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
tiers = collections.Counter()
|
|
150
|
+
for entry in entries:
|
|
151
|
+
agent = entry.get("agent") or ""
|
|
152
|
+
if agent.startswith("leo-"):
|
|
153
|
+
tiers[agent] += 1
|
|
154
|
+
elif entry.get("model"):
|
|
155
|
+
tiers["explicit model"] += 1
|
|
156
|
+
elif entry.get("decision") == "block":
|
|
157
|
+
tiers["blocked"] += 1
|
|
158
|
+
else:
|
|
159
|
+
tiers["inherited"] += 1
|
|
160
|
+
|
|
161
|
+
# A trivial-looking brief that was one of several in the same burst is a
|
|
162
|
+
# fan-out, which the policy wants. Only a lone small spawn is a finding --
|
|
163
|
+
# and only one that actually ran: a blocked dispatch spent nothing, so it
|
|
164
|
+
# cannot also be an over-delegation.
|
|
165
|
+
trivial = [
|
|
166
|
+
e for e in entries
|
|
167
|
+
if e.get("trivial", 0) >= 2
|
|
168
|
+
and e.get("decision") not in ("block", "error")
|
|
169
|
+
and bursts.get(e.get("burst"), 0) < 2
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
# Did a block actually change anything? A blocked brief whose hash comes back
|
|
173
|
+
# naming a tier is the guard working; one that never returns was abandoned.
|
|
174
|
+
blocked = {e.get("prompt") for e in entries if e.get("decision") == "block"}
|
|
175
|
+
blocked.discard(None)
|
|
176
|
+
converted = {
|
|
177
|
+
e.get("prompt") for e in entries
|
|
178
|
+
if e.get("prompt") in blocked and e.get("decision") != "block"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"records": len(entries),
|
|
183
|
+
"window": [entries[0].get("ts"), entries[-1].get("ts")] if entries else [],
|
|
184
|
+
"harnesses": dict(collections.Counter(e.get("harness") for e in entries)),
|
|
185
|
+
"decisions": dict(collections.Counter(e.get("decision") for e in entries)),
|
|
186
|
+
"tiers": dict(tiers),
|
|
187
|
+
"errors": sum(1 for e in entries if e.get("decision") == "error"),
|
|
188
|
+
"blocked": len(blocked),
|
|
189
|
+
"converted": len(converted),
|
|
190
|
+
"trivial_lone_spawns": len(trivial),
|
|
191
|
+
"agents": dict(collections.Counter(
|
|
192
|
+
"%s @ %s" % (e.get("agent") or "-", e.get("model") or "inherited")
|
|
193
|
+
for e in entries
|
|
194
|
+
)),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render(summary):
|
|
199
|
+
lines = []
|
|
200
|
+
# Errors lead. Conflating "the guard crashed" with "the guard allowed" is how
|
|
201
|
+
# a dead guard goes unnoticed for months, so a nonzero count is the headline.
|
|
202
|
+
if summary["errors"]:
|
|
203
|
+
lines.append("!! %d guard error(s) -- the guard failed open this many times" % summary["errors"])
|
|
204
|
+
if not summary["records"]:
|
|
205
|
+
lines.append("no dispatches recorded yet (%s)" % path())
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
lines.append("%d dispatch(es) %s .. %s" % (summary["records"], summary["window"][0], summary["window"][1]))
|
|
209
|
+
lines.append(" harnesses " + ", ".join("%s %d" % kv for kv in sorted(summary["harnesses"].items())))
|
|
210
|
+
lines.append(" tiers " + ", ".join("%s %d" % kv for kv in sorted(summary["tiers"].items())))
|
|
211
|
+
if summary["blocked"]:
|
|
212
|
+
lines.append(" blocks %d, of which %d re-dispatched with a tier named" % (
|
|
213
|
+
summary["blocked"], summary["converted"]))
|
|
214
|
+
lines.append(" lone small spawns %d (fan-outs excluded)" % summary["trivial_lone_spawns"])
|
|
215
|
+
lines.append(" agent @ model:")
|
|
216
|
+
for name, count in sorted(summary["agents"].items(), key=lambda kv: (-kv[1], kv[0])):
|
|
217
|
+
lines.append(" %-44s %d" % (name, count))
|
|
218
|
+
return "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main(argv=None):
|
|
222
|
+
parser = argparse.ArgumentParser(prog="dispatch_log.py", description=__doc__.splitlines()[0])
|
|
223
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
report = sub.add_parser("report", help="what the guard has been seeing")
|
|
225
|
+
report.add_argument("--limit", type=int, default=None, help="only the most recent N records")
|
|
226
|
+
report.add_argument("--json", action="store_true", help="machine-readable summary")
|
|
227
|
+
sub.add_parser("path", help="the log file's path")
|
|
228
|
+
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
if args.command == "path":
|
|
231
|
+
print(path())
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
summary = summarise(read(limit=args.limit))
|
|
235
|
+
print(json.dumps(summary, indent=1, sort_keys=True) if args.json else render(summary))
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|