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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # leos-agent
2
2
 
3
- Leo's portable agent operating policy, version **10.7.0**, installable on Claude
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.0 that half of the rule is enforced by a hook instead,
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 — only Claude Code's dispatch
144
- tool is verified, so an unanticipated one degrades to a no-op rather than a
145
- broken harness. MCP tools are never guarded. Anything that goes wrong inside the
146
- guard allows the call and records `decision: "error"`, kept distinct from a
147
- decision to allow, because a guard that dies quietly is worse than no guard.
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
- **Coverage is honest, not uniform.** Claude Code is verified. Codex, Cursor,
158
- Hermes and OpenCode are wired with the same policy but their dispatch argument
159
- shapes are unconfirmed; there the guard is best-effort and no-ops rather than
160
- misfires. Pi has no hook surface and gets nothing. **Codex hash-pins hooks**, so
161
- upgrading to 10.7.0 — and every later edit to the guard — needs re-approval
162
- through `/hooks` there. Until you do, Codex silently enforces nothing; zero Codex
163
- rows in the report is the symptom.
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.0">
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.0/scripts/leo-install.py claude --uninstall
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.0/scripts/leo-install.py codex
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.0/scripts/leo-install.py codex --uninstall
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.0
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.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leos-agent",
3
- "version": "10.7.0",
3
+ "version": "10.7.2",
4
4
  "description": "Leo's portable agent operating policy: orchestrator main thread, subagent-first execution, cost-tiered model routing.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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
- if not agent or not prompt:
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=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
- if name == "claude":
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.startswith("leo-"):
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
@@ -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.startswith("leo-"):
169
+ if is_tier(agent):
153
170
  tiers[agent] += 1
154
171
  elif entry.get("model"):
155
172
  tiers["explicit model"] += 1
@@ -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.startswith("leo-"):
356
+ if _is_tier(agent):
347
357
  tiers[agent] += 1
348
358
  elif d.get("model"):
349
359
  tiers["explicit model"] += 1