leos-agent 10.7.1 → 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 +25 -20
- package/package.json +1 -1
- package/scripts/dispatch_guard.py +45 -13
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.
|
|
@@ -143,11 +143,12 @@ is also deliberately narrow: a false block costs one re-dispatch, while a caught
|
|
|
143
143
|
inherited fan-out saves the cold prefix of every child, so the margin only holds
|
|
144
144
|
while the rule refuses to make judgment calls.
|
|
145
145
|
|
|
146
|
-
Detection is by **argument shape**, not tool name
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
guard allows the call and records
|
|
150
|
-
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.
|
|
151
152
|
|
|
152
153
|
```
|
|
153
154
|
LEOS_AGENT_DISPATCH_GUARD=on block (default)
|
|
@@ -157,13 +158,17 @@ LEOS_AGENT_DISPATCH_GUARD=on block (default)
|
|
|
157
158
|
LEOS_AGENT_DISPATCH_LOG_PROMPTS=1 debug only: keep 200 chars of brief text in the log
|
|
158
159
|
```
|
|
159
160
|
|
|
160
|
-
**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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`.
|
|
167
172
|
|
|
168
173
|
### What it records
|
|
169
174
|
|
|
@@ -190,7 +195,7 @@ gets it through its global instruction file, written by
|
|
|
190
195
|
[`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
|
|
191
196
|
|
|
192
197
|
```
|
|
193
|
-
<leos-agent version="10.7.
|
|
198
|
+
<leos-agent version="10.7.2">
|
|
194
199
|
...the payload...
|
|
195
200
|
</leos-agent>
|
|
196
201
|
```
|
|
@@ -259,7 +264,7 @@ that it is already installed and changes nothing.
|
|
|
259
264
|
Run the installer's uninstall first, while the script is still on disk:
|
|
260
265
|
|
|
261
266
|
```bash
|
|
262
|
-
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
|
|
263
268
|
```
|
|
264
269
|
|
|
265
270
|
```bash
|
|
@@ -290,7 +295,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
|
|
|
290
295
|
run the script directly:
|
|
291
296
|
|
|
292
297
|
```bash
|
|
293
|
-
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
|
|
294
299
|
```
|
|
295
300
|
|
|
296
301
|
This writes `~/.codex/AGENTS.md` and installs two economical agents:
|
|
@@ -315,7 +320,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
|
|
|
315
320
|
**Uninstall**
|
|
316
321
|
|
|
317
322
|
```bash
|
|
318
|
-
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
|
|
319
324
|
```
|
|
320
325
|
|
|
321
326
|
```bash
|
|
@@ -464,7 +469,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
|
|
|
464
469
|
install it explicitly:
|
|
465
470
|
|
|
466
471
|
```bash
|
|
467
|
-
pi install git:github.com/foxhatleo/leos-agent@v10.7.
|
|
472
|
+
pi install git:github.com/foxhatleo/leos-agent@v10.7.2
|
|
468
473
|
```
|
|
469
474
|
|
|
470
475
|
Re-run `/skill:install` afterwards.
|
|
@@ -684,7 +689,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
|
|
|
684
689
|
```
|
|
685
690
|
|
|
686
691
|
or replace the cachebuster suffix in the Codex manifest with one in the form
|
|
687
|
-
`10.7.
|
|
692
|
+
`10.7.2+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
|
|
688
693
|
reach a **new** session or thread.
|
|
689
694
|
|
|
690
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
|
|
@@ -215,8 +235,20 @@ def decide(dispatch, name, is_routable):
|
|
|
215
235
|
return BLOCK, "no-model"
|
|
216
236
|
|
|
217
237
|
|
|
218
|
-
def render_block(dispatch):
|
|
219
|
-
"""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
|
|
220
252
|
return (
|
|
221
253
|
'[leo routing] BLOCKED - dispatch to agent "%s" names no model, so it would\n'
|
|
222
254
|
"silently inherit the parent's. Re-dispatch with one of:\n"
|
|
@@ -291,7 +323,7 @@ def main(argv=None):
|
|
|
291
323
|
pass
|
|
292
324
|
|
|
293
325
|
if action == BLOCK and mode != "warn":
|
|
294
|
-
sys.stderr.write(render_block(dispatch) + "\n")
|
|
326
|
+
sys.stderr.write(render_block(dispatch, name) + "\n")
|
|
295
327
|
return 2
|
|
296
328
|
|
|
297
329
|
# A notice is user-facing only. systemMessage reaches Leo's transcript and
|