leos-agent 10.7.0 → 10.7.2
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 +28 -20
- package/package.json +1 -1
- package/scripts/dispatch_guard.py +57 -14
- package/scripts/dispatch_log.py +18 -1
- package/scripts/usage_scan.py +11 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# leos-agent
|
|
2
2
|
|
|
3
|
-
Leo's portable agent operating policy, version **10.7.
|
|
3
|
+
Leo's portable agent operating policy, version **10.7.2**, installable on Claude
|
|
4
4
|
Code, Codex, Cursor, Hermes, Pi, and OpenCode through each harness's own plugin
|
|
5
5
|
system.
|
|
6
6
|
|
|
@@ -119,7 +119,7 @@ their global instruction file.
|
|
|
119
119
|
The payload has always said that a subagent dispatch must name a model. Prose
|
|
120
120
|
alone did not hold: a forgotten dispatch inherits the parent's expensive model
|
|
121
121
|
and pays a cold cache write per child, which is the single most expensive shape
|
|
122
|
-
this policy has. From 10.7.
|
|
122
|
+
this policy has. From 10.7.2 that half of the rule is enforced by a hook instead,
|
|
123
123
|
and the prose it replaced came out of the always-loaded payload — enforcement in
|
|
124
124
|
code costs **zero** context per turn, so the guard paid for itself in bytes
|
|
125
125
|
before saving a cent.
|
|
@@ -134,17 +134,21 @@ can name one. Three ways to comply, all of them one word:
|
|
|
134
134
|
| a generic agent, no model | `subagent_type: "leo-executor"` | an approved plan, a well-specified change |
|
|
135
135
|
| a generic agent, no model | `model: "<name>"` | investigation and debugging — naming it *is* the stated reason |
|
|
136
136
|
|
|
137
|
+
A plugin install namespaces the type — dispatch `leos-agent:leo-runner` rather
|
|
138
|
+
than `leo-runner`; the guard accepts either.
|
|
139
|
+
|
|
137
140
|
It never picks a model for you. It cannot force cheap work onto an expensive
|
|
138
141
|
problem, so it cannot cause a quality regression — only an explicit choice. It
|
|
139
142
|
is also deliberately narrow: a false block costs one re-dispatch, while a caught
|
|
140
143
|
inherited fan-out saves the cold prefix of every child, so the margin only holds
|
|
141
144
|
while the rule refuses to make judgment calls.
|
|
142
145
|
|
|
143
|
-
Detection is by **argument shape**, not tool name
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
guard allows the call and records
|
|
147
|
-
decision
|
|
146
|
+
Detection is by **argument shape**, not tool name: a dispatch is a call that
|
|
147
|
+
selects an agent and carries a brief. A shape the guard does not recognise is
|
|
148
|
+
left alone, so a harness it has never met keeps working. MCP tools are never
|
|
149
|
+
guarded. Anything that goes wrong inside the guard allows the call and records
|
|
150
|
+
`decision: "error"`, kept distinct from a decision to allow, because a guard
|
|
151
|
+
that dies quietly is worse than no guard.
|
|
148
152
|
|
|
149
153
|
```
|
|
150
154
|
LEOS_AGENT_DISPATCH_GUARD=on block (default)
|
|
@@ -154,13 +158,17 @@ LEOS_AGENT_DISPATCH_GUARD=on block (default)
|
|
|
154
158
|
LEOS_AGENT_DISPATCH_LOG_PROMPTS=1 debug only: keep 200 chars of brief text in the log
|
|
155
159
|
```
|
|
156
160
|
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
**Per harness.** Claude Code dispatches `Agent` with `subagent_type`, and a
|
|
162
|
+
plugin install namespaces it — both forms are tiers. Codex dispatches
|
|
163
|
+
`spawn_agent`, which selects behaviour by `model` and `reasoning_effort` rather
|
|
164
|
+
than by naming an agent, so there the guard requires `model`; its `message` is
|
|
165
|
+
encrypted, so the over-delegation heuristic and block-conversion tracking do not
|
|
166
|
+
apply on Codex. Hermes and OpenCode run the same policy in-process, blocking by
|
|
167
|
+
directive and by throw respectively. Pi has no hook surface.
|
|
168
|
+
|
|
169
|
+
**Codex hash-pins hooks.** A new version of the guard enforces nothing there
|
|
170
|
+
until it is re-approved through `/hooks`, and the symptom is silence — zero
|
|
171
|
+
Codex rows in `dispatch_log.py report`.
|
|
164
172
|
|
|
165
173
|
### What it records
|
|
166
174
|
|
|
@@ -187,7 +195,7 @@ gets it through its global instruction file, written by
|
|
|
187
195
|
[`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
|
|
188
196
|
|
|
189
197
|
```
|
|
190
|
-
<leos-agent version="10.7.
|
|
198
|
+
<leos-agent version="10.7.2">
|
|
191
199
|
...the payload...
|
|
192
200
|
</leos-agent>
|
|
193
201
|
```
|
|
@@ -256,7 +264,7 @@ that it is already installed and changes nothing.
|
|
|
256
264
|
Run the installer's uninstall first, while the script is still on disk:
|
|
257
265
|
|
|
258
266
|
```bash
|
|
259
|
-
python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.7.
|
|
267
|
+
python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.7.2/scripts/leo-install.py claude --uninstall
|
|
260
268
|
```
|
|
261
269
|
|
|
262
270
|
```bash
|
|
@@ -287,7 +295,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
|
|
|
287
295
|
run the script directly:
|
|
288
296
|
|
|
289
297
|
```bash
|
|
290
|
-
python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.
|
|
298
|
+
python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.2/scripts/leo-install.py codex
|
|
291
299
|
```
|
|
292
300
|
|
|
293
301
|
This writes `~/.codex/AGENTS.md` and installs two economical agents:
|
|
@@ -312,7 +320,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
|
|
|
312
320
|
**Uninstall**
|
|
313
321
|
|
|
314
322
|
```bash
|
|
315
|
-
python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.
|
|
323
|
+
python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.2/scripts/leo-install.py codex --uninstall
|
|
316
324
|
```
|
|
317
325
|
|
|
318
326
|
```bash
|
|
@@ -461,7 +469,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
|
|
|
461
469
|
install it explicitly:
|
|
462
470
|
|
|
463
471
|
```bash
|
|
464
|
-
pi install git:github.com/foxhatleo/leos-agent@v10.7.
|
|
472
|
+
pi install git:github.com/foxhatleo/leos-agent@v10.7.2
|
|
465
473
|
```
|
|
466
474
|
|
|
467
475
|
Re-run `/skill:install` afterwards.
|
|
@@ -681,7 +689,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
|
|
|
681
689
|
```
|
|
682
690
|
|
|
683
691
|
or replace the cachebuster suffix in the Codex manifest with one in the form
|
|
684
|
-
`10.7.
|
|
692
|
+
`10.7.2+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
|
|
685
693
|
reach a **new** session or thread.
|
|
686
694
|
|
|
687
695
|
`--check` exits non-zero when a file is out of date, and `--force` replaces a
|
package/package.json
CHANGED
|
@@ -49,7 +49,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
49
49
|
|
|
50
50
|
Dispatch = collections.namedtuple(
|
|
51
51
|
"Dispatch",
|
|
52
|
-
"tool agent model prompt_bytes prompt_lines path_count prompt_hash prompt_head",
|
|
52
|
+
"tool agent model prompt_bytes prompt_lines path_count prompt_hash prompt_head opaque",
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
# The agent-selection field is mandatory, and that is the whole trick. A rule
|
|
@@ -71,6 +71,19 @@ SKIP_PREFIXES = ("mcp__",)
|
|
|
71
71
|
# Seeded empty on purpose: populate from field reports, not from guesses.
|
|
72
72
|
SKIP_TOOLS = frozenset()
|
|
73
73
|
|
|
74
|
+
# Dispatch tools that select behaviour by model rather than by naming an agent,
|
|
75
|
+
# so the agent-key rule alone would never see them. Exact names only, and only
|
|
76
|
+
# ones observed in a real rollout: Codex's spawn_agent takes
|
|
77
|
+
# {task_name, message, fork_turns, model, reasoning_effort} -- task_name is a
|
|
78
|
+
# free-text label, so `model` is the entire routing decision there.
|
|
79
|
+
DISPATCH_TOOLS = ("spawn_agent",)
|
|
80
|
+
|
|
81
|
+
# Tools whose brief does not arrive as readable text. Codex encrypts `message`,
|
|
82
|
+
# so its length is a proxy at best and its hash changes on every re-send. Both
|
|
83
|
+
# the size heuristic and the conversion hash are suppressed rather than reported
|
|
84
|
+
# as if they meant something.
|
|
85
|
+
OPAQUE_BRIEF_TOOLS = ("spawn_agent",)
|
|
86
|
+
|
|
74
87
|
ALLOW, BLOCK = "allow", "block"
|
|
75
88
|
|
|
76
89
|
# Path-ish tokens in a brief. Scanning is capped at the first 8 KiB -- the
|
|
@@ -131,21 +144,26 @@ def normalize(event, _harness=None):
|
|
|
131
144
|
|
|
132
145
|
agent = _first_str(args, AGENT_KEYS)
|
|
133
146
|
prompt = _first_str(args, PROMPT_KEYS)
|
|
134
|
-
|
|
147
|
+
known = tool in DISPATCH_TOOLS
|
|
148
|
+
if not prompt or not (agent or known):
|
|
135
149
|
return None
|
|
136
150
|
|
|
137
151
|
from dispatch_log import digest # local: a no-op call must not pay for this
|
|
138
152
|
|
|
153
|
+
opaque = tool in OPAQUE_BRIEF_TOOLS
|
|
139
154
|
head = prompt[:PATH_SCAN_BYTES]
|
|
140
155
|
return Dispatch(
|
|
141
156
|
tool=tool or "-",
|
|
142
|
-
agent
|
|
157
|
+
# A model-routed dispatch names no agent; label it by its tool so the log
|
|
158
|
+
# reads sensibly without inventing a tier that was never selected.
|
|
159
|
+
agent=agent or tool,
|
|
143
160
|
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],
|
|
161
|
+
prompt_bytes=0 if opaque else len(prompt.encode("utf-8", "replace")),
|
|
162
|
+
prompt_lines=0 if opaque else prompt.count("\n") + 1,
|
|
163
|
+
path_count=0 if opaque else len(set(PATH_RE.findall(head))),
|
|
164
|
+
prompt_hash=None if opaque else digest(prompt),
|
|
165
|
+
prompt_head="" if opaque else prompt[:200],
|
|
166
|
+
opaque=opaque,
|
|
149
167
|
)
|
|
150
168
|
|
|
151
169
|
|
|
@@ -160,7 +178,7 @@ def triviality(dispatch):
|
|
|
160
178
|
for a log line and disqualifying for a block, which is why this is only ever
|
|
161
179
|
a log line.
|
|
162
180
|
"""
|
|
163
|
-
if dispatch is None:
|
|
181
|
+
if dispatch is None or dispatch.opaque:
|
|
164
182
|
return 0
|
|
165
183
|
score = 0
|
|
166
184
|
if dispatch.prompt_bytes < 220:
|
|
@@ -182,7 +200,9 @@ def routable(name):
|
|
|
182
200
|
inherit and say so -- blocking there would demand something the harness
|
|
183
201
|
cannot do, a false positive by construction.
|
|
184
202
|
"""
|
|
185
|
-
|
|
203
|
+
# Both take a model per spawn: Claude Code on its dispatch tool, Codex as
|
|
204
|
+
# spawn_agent's `model` argument.
|
|
205
|
+
if name in ("claude", "codex"):
|
|
186
206
|
return True
|
|
187
207
|
try:
|
|
188
208
|
import routing
|
|
@@ -192,11 +212,21 @@ def routable(name):
|
|
|
192
212
|
return False
|
|
193
213
|
|
|
194
214
|
|
|
215
|
+
def _is_tier(agent):
|
|
216
|
+
try:
|
|
217
|
+
from dispatch_log import is_tier
|
|
218
|
+
return is_tier(agent)
|
|
219
|
+
except Exception:
|
|
220
|
+
# Never let a missing sibling module turn a compliant dispatch into a
|
|
221
|
+
# refusal: fall back to allowing anything that looks like a tier.
|
|
222
|
+
return bool(agent) and agent.rsplit(":", 1)[-1].startswith("leo-")
|
|
223
|
+
|
|
224
|
+
|
|
195
225
|
def decide(dispatch, name, is_routable):
|
|
196
226
|
"""(action, reason). The entire policy, and deliberately four lines of it."""
|
|
197
227
|
if dispatch is None:
|
|
198
228
|
return ALLOW, "not-a-dispatch"
|
|
199
|
-
if dispatch.agent
|
|
229
|
+
if _is_tier(dispatch.agent):
|
|
200
230
|
return ALLOW, "leo-tier" # the agent definition carries the model
|
|
201
231
|
if dispatch.model:
|
|
202
232
|
return ALLOW, "explicit-model" # a choice was typed; cheap or not
|
|
@@ -205,13 +235,26 @@ def decide(dispatch, name, is_routable):
|
|
|
205
235
|
return BLOCK, "no-model"
|
|
206
236
|
|
|
207
237
|
|
|
208
|
-
def render_block(dispatch):
|
|
209
|
-
"""The refusal. It must name the remedies, or it costs a turn to discover them.
|
|
238
|
+
def render_block(dispatch, name=None):
|
|
239
|
+
"""The refusal. It must name the remedies, or it costs a turn to discover them.
|
|
240
|
+
|
|
241
|
+
The remedies differ by harness: Codex routes by `model` on spawn_agent and
|
|
242
|
+
has no agent to name, so offering it a subagent_type would be advice it
|
|
243
|
+
cannot take.
|
|
244
|
+
"""
|
|
245
|
+
if dispatch.tool in DISPATCH_TOOLS or name == "codex":
|
|
246
|
+
return (
|
|
247
|
+
"[leo routing] BLOCKED - %s names no model, so it would silently inherit\n"
|
|
248
|
+
"the parent's. Re-dispatch with `model` set (and `reasoning_effort` with it),\n"
|
|
249
|
+
"naming the economical tier's model for narrow work.\n"
|
|
250
|
+
"Set LEOS_AGENT_DISPATCH_GUARD=off to disable, =warn to log only."
|
|
251
|
+
) % dispatch.tool
|
|
210
252
|
return (
|
|
211
253
|
'[leo routing] BLOCKED - dispatch to agent "%s" names no model, so it would\n'
|
|
212
254
|
"silently inherit the parent's. Re-dispatch with one of:\n"
|
|
213
255
|
' subagent_type "leo-runner" reading, search, tests, logs, codemods, fan-out\n'
|
|
214
256
|
' subagent_type "leo-executor" an approved plan or a specified change\n'
|
|
257
|
+
' (a plugin install namespaces these: "leos-agent:leo-runner")\n'
|
|
215
258
|
' model: "<name>" investigation/debugging - naming it IS the reason\n'
|
|
216
259
|
"Set LEOS_AGENT_DISPATCH_GUARD=off to disable, =warn to log only."
|
|
217
260
|
) % dispatch.agent
|
|
@@ -280,7 +323,7 @@ def main(argv=None):
|
|
|
280
323
|
pass
|
|
281
324
|
|
|
282
325
|
if action == BLOCK and mode != "warn":
|
|
283
|
-
sys.stderr.write(render_block(dispatch) + "\n")
|
|
326
|
+
sys.stderr.write(render_block(dispatch, name) + "\n")
|
|
284
327
|
return 2
|
|
285
328
|
|
|
286
329
|
# A notice is user-facing only. systemMessage reaches Leo's transcript and
|
package/scripts/dispatch_log.py
CHANGED
|
@@ -47,6 +47,23 @@ MAX_BYTES = 1 << 20
|
|
|
47
47
|
RECORD_VERSION = 1
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
TIER_PREFIX = "leo-"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_tier(agent):
|
|
54
|
+
"""Is this one of the economical-tier agents?
|
|
55
|
+
|
|
56
|
+
Match the bare name after any namespace. A plugin install namespaces the
|
|
57
|
+
type -- Claude Code dispatches `leos-agent:leo-runner`, not `leo-runner` --
|
|
58
|
+
and a raw prefix test rejects that, which blocked the exact path the refusal
|
|
59
|
+
message recommends. That is the worst false positive this guard can have, so
|
|
60
|
+
the one place that decides it is shared rather than repeated.
|
|
61
|
+
"""
|
|
62
|
+
if not agent:
|
|
63
|
+
return False
|
|
64
|
+
return agent.rsplit(":", 1)[-1].rsplit("/", 1)[-1].startswith(TIER_PREFIX)
|
|
65
|
+
|
|
66
|
+
|
|
50
67
|
def path():
|
|
51
68
|
return os.path.join(_data_root(), LOG_NAME)
|
|
52
69
|
|
|
@@ -149,7 +166,7 @@ def summarise(entries):
|
|
|
149
166
|
tiers = collections.Counter()
|
|
150
167
|
for entry in entries:
|
|
151
168
|
agent = entry.get("agent") or ""
|
|
152
|
-
if agent
|
|
169
|
+
if is_tier(agent):
|
|
153
170
|
tiers[agent] += 1
|
|
154
171
|
elif entry.get("model"):
|
|
155
172
|
tiers["explicit model"] += 1
|
package/scripts/usage_scan.py
CHANGED
|
@@ -336,6 +336,16 @@ def scan_guard():
|
|
|
336
336
|
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
337
337
|
|
|
338
338
|
|
|
339
|
+
def _is_tier(agent):
|
|
340
|
+
"""Shared with the guard, so the report cannot disagree with enforcement
|
|
341
|
+
about what counts as a tier -- namespaced plugin types included."""
|
|
342
|
+
try:
|
|
343
|
+
import dispatch_log
|
|
344
|
+
return dispatch_log.is_tier(agent)
|
|
345
|
+
except Exception:
|
|
346
|
+
return bool(agent) and agent.rsplit(":", 1)[-1].startswith("leo-")
|
|
347
|
+
|
|
348
|
+
|
|
339
349
|
def routing_compliance(dispatches):
|
|
340
350
|
"""Problem (c), quantified: how many dispatches named a tier, and how many
|
|
341
351
|
let the parent's model ride along."""
|
|
@@ -343,7 +353,7 @@ def routing_compliance(dispatches):
|
|
|
343
353
|
inherited_bytes = 0
|
|
344
354
|
for d in dispatches:
|
|
345
355
|
agent = d.get("agent") or "-"
|
|
346
|
-
if agent
|
|
356
|
+
if _is_tier(agent):
|
|
347
357
|
tiers[agent] += 1
|
|
348
358
|
elif d.get("model"):
|
|
349
359
|
tiers["explicit model"] += 1
|