chad-code 1.0.3__tar.gz → 1.0.5__tar.gz

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.
Files changed (91) hide show
  1. {chad_code-1.0.3 → chad_code-1.0.5}/PKG-INFO +22 -9
  2. {chad_code-1.0.3 → chad_code-1.0.5}/README.md +21 -8
  3. {chad_code-1.0.3 → chad_code-1.0.5}/pyproject.toml +1 -1
  4. chad_code-1.0.5/src/chad/__init__.py +14 -0
  5. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/agent.py +162 -47
  6. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/base_engine.py +67 -7
  7. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/bench.py +2 -2
  8. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/cli.py +261 -124
  9. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/completion_engine.py +168 -15
  10. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/config.py +15 -0
  11. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/diag.py +10 -7
  12. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/engine.py +61 -52
  13. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/guardrails.py +219 -70
  14. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/levers.py +168 -48
  15. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mlx_fastpath.py +26 -7
  16. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mlx_qsdpa.py +333 -32
  17. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/prompt.py +76 -12
  18. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/prove.py +4 -4
  19. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/repomap.py +4 -4
  20. chad_code-1.0.5/src/chad/serve.py +814 -0
  21. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/syntaxgate.py +18 -19
  22. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/toolcall_parse.py +4 -4
  23. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/tools.py +295 -21
  24. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/tui.py +6 -5
  25. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/PKG-INFO +22 -9
  26. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/SOURCES.txt +3 -0
  27. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent.py +1 -1
  28. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent_e2e.py +75 -19
  29. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent_guards.py +140 -11
  30. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_cli.py +248 -3
  31. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_completion_engine.py +84 -2
  32. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_config.py +1 -1
  33. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_confirm_preview.py +1 -1
  34. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_done_audit.py +237 -104
  35. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_edit.py +40 -5
  36. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_edit_corruption.py +1 -1
  37. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_engine.py +4 -8
  38. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_engine_kvquant.py +0 -8
  39. chad_code-1.0.5/tests/test_engine_pld_hybrid.py +227 -0
  40. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_feel_pack.py +20 -4
  41. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_gate.py +20 -0
  42. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_intent.py +4 -4
  43. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_lever_bite.py +148 -13
  44. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_log_redaction.py +38 -13
  45. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mlx_fastpath.py +2 -0
  46. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mlx_qsdpa.py +75 -0
  47. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_render.py +1 -1
  48. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_repomap.py +1 -1
  49. chad_code-1.0.5/tests/test_serve.py +815 -0
  50. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_session.py +1 -1
  51. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_subagent.py +8 -1
  52. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_symbols.py +1 -1
  53. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_syntaxgate.py +4 -4
  54. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_toolcall_parse.py +2 -2
  55. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_tools.py +204 -17
  56. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_tui.py +1 -1
  57. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_validate.py +1 -1
  58. chad_code-1.0.3/src/chad/__init__.py +0 -7
  59. {chad_code-1.0.3 → chad_code-1.0.5}/LICENSE +0 -0
  60. {chad_code-1.0.3 → chad_code-1.0.5}/setup.cfg +0 -0
  61. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/atif.py +0 -0
  62. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/compaction.py +0 -0
  63. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/ignore.py +0 -0
  64. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/lsp.py +0 -0
  65. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mcp.py +0 -0
  66. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mcp_oauth.py +0 -0
  67. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/profiles.py +0 -0
  68. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/render.py +0 -0
  69. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/session.py +0 -0
  70. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/skills.py +0 -0
  71. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/symbols.py +0 -0
  72. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/validate.py +0 -0
  73. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/dependency_links.txt +0 -0
  74. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/entry_points.txt +0 -0
  75. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/requires.txt +0 -0
  76. {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/top_level.txt +0 -0
  77. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_atif.py +0 -0
  78. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_bench.py +0 -0
  79. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_compact_notice.py +0 -0
  80. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_compaction.py +0 -0
  81. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_drift_warn.py +0 -0
  82. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_garble_invariant.py +0 -0
  83. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_ignore.py +0 -0
  84. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_levers.py +0 -0
  85. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_lsp.py +0 -0
  86. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mcp.py +0 -0
  87. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mcp_oauth.py +0 -0
  88. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_plan_review.py +0 -0
  89. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_prove.py +0 -0
  90. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_replace_lines.py +0 -0
  91. {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/nathansutton/chad
@@ -58,7 +58,7 @@ uvx chad-code # runs chad anywhere — the command is still `chad`
58
58
  uvx chad-code prove # 2-min offline smoke test: 4 tiny fix-it tasks, verified, timed 🗿
59
59
  ```
60
60
 
61
- First run picks the right Ornith model for your RAM (9B under 32 GB, 35B at 32 GB+), asks,
61
+ First run picks the right Ornith model for your RAM (9B under 24 GB, 35B at 24 GB+), asks,
62
62
  and downloads it once (~5 GB / ~13 GB, resumable) into the shared Hugging Face cache. While
63
63
  it downloads, `cd` into a project and think of a scoped first ask — *"fix the failing test
64
64
  in `tests/test_x.py`"* lands; *"improve my codebase"* flails.
@@ -139,16 +139,20 @@ uv run chad -c # resume this directory's last conversation
139
139
  ```
140
140
 
141
141
  **The model.** chad picks one for you by RAM and downloads it once into the shared Hugging
142
- Face cache (`~/.cache/huggingface`, reused across every project). No picker, no flags —
143
- override with `CHAD_MODEL=<repo or local dir>` if you must.
142
+ Face cache (`~/.cache/huggingface`, reused across every project). Override with
143
+ `--model 9b` / `--model 35b`, or `--model <repo or local dir>` for anything else.
144
144
 
145
145
  | Your Mac | Model | Footprint |
146
146
  |---|---|---|
147
- | **≥ 32 GB** | [Ornith-1.0-35B `UD-Q2_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX) — 35B MoE, 2-bit experts | ~13 GB resident (~16 GB with KV) |
148
- | **16 / 18 / 24 GB** | [Ornith-1.0-9B `UD-Q4_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX) — 4-bit AWQ | ~5 GB |
149
-
150
- The 35B's working set needs headroom a 24 GB Mac doesn't have (it SIGKILLs mid-turn), so its
151
- floor is **32 GB**; 24 GB and below run the 9B. Quant names follow
147
+ | **≥ 24 GB** | [Ornith-1.0-35B `UD-Q2_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX) — 35B MoE, 2-bit experts | ~13 GB resident (~16 GB with KV) |
148
+ | **16 / 18 GB** | [Ornith-1.0-9B `UD-Q4_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX) — 4-bit AWQ | ~5 GB |
149
+
150
+ The 35B's floor used to be 32 GB — its working set SIGKILLed a 24 GB Mac mid-turn. The
151
+ fused attention kernel and the 8-bit-from-the-start KV cache it enables cut the per-token
152
+ cache cost enough to give that headroom back, and the compaction trigger now sizes itself
153
+ from the live Metal budget, so a tight box narrows its context window instead of dying.
154
+ 24 GB runs the 35B; 16/18 GB still get the 9B. If a 24 GB machine feels tight next to your
155
+ other apps, `--model 9b` puts it back. Quant names follow
152
156
  [Unsloth's dynamic-quant convention](https://docs.unsloth.ai/) (`UD-…`).
153
157
 
154
158
  **Upgrading** — depends on how you installed: `uv tool upgrade chad-code`; `uvx --refresh
@@ -187,8 +191,17 @@ it chad uses the tree-sitter fallback automatically.
187
191
  | `--plan` | start in read-only plan mode (investigate + propose, edits blocked) |
188
192
  | `--yolo` | auto-approve bash/write/edit (skip confirm prompts) |
189
193
  | `--no-think` | skip Ornith's `<think>` blocks — faster on well-scoped work |
194
+ | `--model` | `35b`, `9b`, `auto`, or any HF repo id / local model dir |
190
195
  | `--repl` | plain line REPL instead of the TUI |
191
196
 
197
+ Plus three subcommands, each with its own `--help`:
198
+
199
+ | Command | What it does |
200
+ |---|---|
201
+ | `chad serve` | serve this Mac's model to a container or the LAN ([Configuration](docs/configuration.md#serving-the-local-model-to-a-container-chad-serve)) |
202
+ | `chad prove` | 2-minute offline smoke test: 4 tiny fix-it tasks, verified, timed |
203
+ | `chad levers` | print the harness lever registry as JSON (ablation driver) |
204
+
192
205
  A headless task (positional, or piped with no TTY) auto-approves mutating tools; the model
193
206
  runs greedy (temp 0). Every conversation is persisted under `~/.chad/sessions/`, and every
194
207
  resume forks a new branch rather than overwriting — details in
@@ -21,7 +21,7 @@ uvx chad-code # runs chad anywhere — the command is still `chad`
21
21
  uvx chad-code prove # 2-min offline smoke test: 4 tiny fix-it tasks, verified, timed 🗿
22
22
  ```
23
23
 
24
- First run picks the right Ornith model for your RAM (9B under 32 GB, 35B at 32 GB+), asks,
24
+ First run picks the right Ornith model for your RAM (9B under 24 GB, 35B at 24 GB+), asks,
25
25
  and downloads it once (~5 GB / ~13 GB, resumable) into the shared Hugging Face cache. While
26
26
  it downloads, `cd` into a project and think of a scoped first ask — *"fix the failing test
27
27
  in `tests/test_x.py`"* lands; *"improve my codebase"* flails.
@@ -102,16 +102,20 @@ uv run chad -c # resume this directory's last conversation
102
102
  ```
103
103
 
104
104
  **The model.** chad picks one for you by RAM and downloads it once into the shared Hugging
105
- Face cache (`~/.cache/huggingface`, reused across every project). No picker, no flags —
106
- override with `CHAD_MODEL=<repo or local dir>` if you must.
105
+ Face cache (`~/.cache/huggingface`, reused across every project). Override with
106
+ `--model 9b` / `--model 35b`, or `--model <repo or local dir>` for anything else.
107
107
 
108
108
  | Your Mac | Model | Footprint |
109
109
  |---|---|---|
110
- | **≥ 32 GB** | [Ornith-1.0-35B `UD-Q2_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX) — 35B MoE, 2-bit experts | ~13 GB resident (~16 GB with KV) |
111
- | **16 / 18 / 24 GB** | [Ornith-1.0-9B `UD-Q4_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX) — 4-bit AWQ | ~5 GB |
112
-
113
- The 35B's working set needs headroom a 24 GB Mac doesn't have (it SIGKILLs mid-turn), so its
114
- floor is **32 GB**; 24 GB and below run the 9B. Quant names follow
110
+ | **≥ 24 GB** | [Ornith-1.0-35B `UD-Q2_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX) — 35B MoE, 2-bit experts | ~13 GB resident (~16 GB with KV) |
111
+ | **16 / 18 GB** | [Ornith-1.0-9B `UD-Q4_K_XL`](https://huggingface.co/nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX) — 4-bit AWQ | ~5 GB |
112
+
113
+ The 35B's floor used to be 32 GB — its working set SIGKILLed a 24 GB Mac mid-turn. The
114
+ fused attention kernel and the 8-bit-from-the-start KV cache it enables cut the per-token
115
+ cache cost enough to give that headroom back, and the compaction trigger now sizes itself
116
+ from the live Metal budget, so a tight box narrows its context window instead of dying.
117
+ 24 GB runs the 35B; 16/18 GB still get the 9B. If a 24 GB machine feels tight next to your
118
+ other apps, `--model 9b` puts it back. Quant names follow
115
119
  [Unsloth's dynamic-quant convention](https://docs.unsloth.ai/) (`UD-…`).
116
120
 
117
121
  **Upgrading** — depends on how you installed: `uv tool upgrade chad-code`; `uvx --refresh
@@ -150,8 +154,17 @@ it chad uses the tree-sitter fallback automatically.
150
154
  | `--plan` | start in read-only plan mode (investigate + propose, edits blocked) |
151
155
  | `--yolo` | auto-approve bash/write/edit (skip confirm prompts) |
152
156
  | `--no-think` | skip Ornith's `<think>` blocks — faster on well-scoped work |
157
+ | `--model` | `35b`, `9b`, `auto`, or any HF repo id / local model dir |
153
158
  | `--repl` | plain line REPL instead of the TUI |
154
159
 
160
+ Plus three subcommands, each with its own `--help`:
161
+
162
+ | Command | What it does |
163
+ |---|---|
164
+ | `chad serve` | serve this Mac's model to a container or the LAN ([Configuration](docs/configuration.md#serving-the-local-model-to-a-container-chad-serve)) |
165
+ | `chad prove` | 2-minute offline smoke test: 4 tiny fix-it tasks, verified, timed |
166
+ | `chad levers` | print the harness lever registry as JSON (ablation driver) |
167
+
155
168
  A headless task (positional, or piped with no TTY) auto-approves mutating tools; the model
156
169
  runs greedy (temp 0). Every conversation is persisted under `~/.chad/sessions/`, and every
157
170
  resume forks a new branch rather than overwriting — details in
@@ -4,7 +4,7 @@
4
4
  # import name, and command name are independent. `uvx chad-code` runs the alias
5
5
  # script added under [project.scripts].
6
6
  name = "chad-code"
7
- version = "1.0.3"
7
+ version = "1.0.5"
8
8
  description = "Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,14 @@
1
+ """chad — a local MLX-backed, Claude-Code-style coding agent.
2
+
3
+ A flat collection of cooperating modules behind one console script (``chad``):
4
+ the inference engine, the tool layer, the agent loop, and the terminal UI.
5
+ """
6
+
7
+ __version__ = "1.0.5"
8
+
9
+ # chad sets no MLX_* runtime vars. MLX_METAL_FAST_SYNCH, MLX_MAX_OPS_PER_BUFFER
10
+ # and MLX_MAX_MB_PER_BUFFER were each measured end-to-end on the 35B and every
11
+ # setting was slower than mlx's own defaults (1-4% at both 8k and 32k), so the
12
+ # mechanism that used to apply them at package import is gone. Any future entry
13
+ # has to be set before mlx's backend initializes — package import is the last
14
+ # hook early enough, since chad imports mlx lazily inside functions.
@@ -373,8 +373,10 @@ class Agent:
373
373
  # a transcript whose protected floor exceeds ctx_limit isn't re-compacted —
374
374
  # and its warm prefix cache destroyed — on every single step.
375
375
  self._compact_state: dict = {}
376
+ self._deny_reason: str | None = None # headless guard-block explanation for the model
376
377
  # Live ctx-limit recheck: the startup limit was computed on
377
- # an idle box; Docker/harbor spinning up mid-session changes what's safe.
378
+ # an idle box; containers or other engines spinning up mid-session change
379
+ # what's safe.
378
380
  # Called at the top of each turn; only a >10% move is applied (hysteresis).
379
381
  self._ctx_limit_fn = ctx_limit_fn
380
382
  # Runaway-turn governor: a per-turn budget on cumulative prefill tokens
@@ -435,6 +437,11 @@ class Agent:
435
437
  self.draft_accepted = 0
436
438
  self.think_tokens = 0 # tokens spent inside <think> blocks (reasoning overhead)
437
439
  self.think_capped = 0 # times the soft think-cap force-closed a step
440
+ # Real tool dispatches this agent has executed (fn actually ran — excludes
441
+ # terminal `done`, validation rejects, and harness-injected nudge messages,
442
+ # which reuse real tool names in the transcript). The evidence signal behind
443
+ # the sub-agent zero-evidence warning in _run_subagent.
444
+ self.tool_dispatches = 0
438
445
  # prefill accounting: the master cost for a local model is how many *new*
439
446
  # tokens it has to prefill across a turn (context bloat -> big prefills).
440
447
  # This is the metric symbolic/repo-map retrieval is meant to shrink.
@@ -592,6 +599,17 @@ class Agent:
592
599
  if self._confirm_cb is None and not sys.stdin.isatty():
593
600
  self._emit("info", f" [blocked destructive command in auto mode: "
594
601
  f"{args.get('command', '')!r}; set CHAD_NO_DESTRUCTIVE_GUARD=1 to allow]")
602
+ # Tell the MODEL the truth about who blocked it and why: "[denied by
603
+ # user]" reads as a human refusal and teaches the wrong lesson (the
604
+ # measured trace: a model re-phrasing the same delete 30 times). The
605
+ # guard names itself and the fix — narrow the target.
606
+ if levers.enabled("scoped_destructive_guard"):
607
+ self._deny_reason = (
608
+ "[blocked by the destructive-command guard, not by a person: "
609
+ "the command matches a catastrophic pattern (recursive delete "
610
+ "of a filesystem root, top-level directory, or home tree — or "
611
+ "mkfs / dd-to-device / curl|sh). Re-issue it with a narrower "
612
+ "target (a specific subdirectory), or skip the deletion.]")
595
613
  return False
596
614
  if self._confirm_cb is not None:
597
615
  return self._confirm_cb(name, args)
@@ -670,7 +688,7 @@ class Agent:
670
688
  # Fail-safe: a sub-agent that ends early — step cap, crash, interrupt — or
671
689
  # that returns nothing at all must still hand back where it got to. Never
672
690
  # surface a bare sentinel: the parent then restarts the localization from
673
- # zero (the django-14007/sphinx-9230 failure), and the anti-respawn guard
691
+ # zero (the demonstrated failure), and the anti-respawn guard
674
692
  # above refuses the retry, so the turn dies with the findings still in the
675
693
  # dead sub-agent's transcript. progress_note is deterministic and model-free,
676
694
  # so it works even from a crashed turn: it re-reads the sub-agent's own tool
@@ -678,7 +696,7 @@ class Agent:
678
696
  # hypothesis it stated.
679
697
  def _salvage(res: str) -> str:
680
698
  if not levers.enabled("subagent_budget_note"):
681
- return res # pre-iter-2: the capped sub-agent's findings are discarded
699
+ return res # legacy arm: the capped sub-agent's findings are discarded
682
700
  note = sub.budget_note or guardrails.progress_note(sub.messages)
683
701
  if not note:
684
702
  return res
@@ -689,12 +707,22 @@ class Agent:
689
707
  elif (not result or result.startswith("[stopped:")
690
708
  or result.startswith("[task failed:")):
691
709
  result = _salvage(result or "[task returned nothing]")
692
- # tool_calls (not `forwards`, a speculative-decoding counter that is 0 on the
693
- # normal path) is the number that diagnoses a sub-agent returning nothing: it
694
- # separates "never got to search" from "searched and lost its findings".
695
- log.info("TASK end | desc=%r | %.1fs | tool_calls=%d gen=%d prefill=%d | -> %s",
710
+ else:
711
+ # A confident, non-empty report produced with ZERO tool dispatches came
712
+ # from model memory, not this repo — the one sub-agent failure the empty/
713
+ # crashed salvage above cannot see, and the most dangerous fold-back: it
714
+ # reads as evidence (guardrails.subagent_evidence_warning).
715
+ warned = guardrails.subagent_evidence_warning(result, sub.tool_dispatches)
716
+ if warned is not None:
717
+ log.info("TASK zero-evidence warning appended | desc=%r", description)
718
+ result = warned
719
+ # tool_dispatches (not a transcript count — harness nudges reuse real tool names
720
+ # in tool-role messages) is the number that diagnoses a sub-agent returning
721
+ # nothing: it separates "never got to search" from "searched and lost its
722
+ # findings", and 0 with a confident report is the answered-from-memory tell.
723
+ log.info("TASK end | desc=%r | %.1fs | tool_dispatches=%d gen=%d prefill=%d | -> %s",
696
724
  description, time.perf_counter() - _t0,
697
- sum(1 for m in sub.messages if m.get("role") == "tool") if sub else 0,
725
+ sub.tool_dispatches if sub else 0,
698
726
  sub.gen_tokens if sub else 0,
699
727
  sub.prefill_tokens if sub else 0, result_preview(result or ""))
700
728
  return result or "[task returned nothing]"
@@ -738,7 +766,7 @@ class Agent:
738
766
  self.ctx_limit = fresh
739
767
  except Exception: # noqa: BLE001 — a pressure probe must never kill a turn
740
768
  pass
741
- # ds4-style warm start: on a cold cache, load the system+tools KV from disk
769
+ # Warm start: on a cold cache, load the system+tools KV from disk
742
770
  # (or prefill+persist it once) so the first turn doesn't re-prefill the
743
771
  # ~3.2k-token stable prefix every session. Cheap no-op on a warm cache.
744
772
  if self.engine.cache_dir and not self.engine._cached_ids:
@@ -799,8 +827,8 @@ class Agent:
799
827
  plan_reviews = 0 # one-shot "re-read the plan you just wrote" (levers.plan_review)
800
828
  # Files edited this turn -> mtime at last syntax check. Bash can mutate files
801
829
  # too (sed -i, python rewrites) but bypasses the write/edit syntax gate; watch
802
- # edited files and re-check them after any bash that touched them (iter-2:
803
- # sphinx-7440's file survived 9 blind `sed -i` "fixes" unparseable, unflagged).
830
+ # edited files and re-check them after any bash that touched them (a measured
831
+ # file survived 9 blind `sed -i` "fixes" unparseable and unflagged).
804
832
  edited_syntax_watch: dict = {}
805
833
  think_cap_hits = 0 # soft think-cap firings this turn (drives escalation)
806
834
  repeat_stops = 0 # degenerate-repetition cut-offs this turn (3rd aborts the turn)
@@ -817,8 +845,8 @@ class Agent:
817
845
  # decode-speed-aware budget. turn_think_exhausted latches the once-only
818
846
  # log/steer; past it, no-think steps are paid on a duty cycle
819
847
  # (guardrails.turn_think_throttle) rather than muting the rest of the turn —
820
- # the blanket mute regressed run1 passes with garbled no-think tails (plan 107).
821
- # landing_no_think is the 103 landing's own unconditional latch: once the hard
848
+ # the blanket mute regressed passing runs with garbled no-think tails.
849
+ # landing_no_think is the hard-wrapup landing's own unconditional latch: once
822
850
  # wrap-up fires, the landing and everything after it stay no-think regardless.
823
851
  turn_think_tokens = 0
824
852
  turn_think_half_fired = False
@@ -861,7 +889,7 @@ class Agent:
861
889
  # Run-task intent (start/boot/serve/… — system-state imperatives with no file
862
890
  # deliverable): arms the anti-bail nudges alongside action_task but is kept OUT
863
891
  # of the no-empty-diff done gates below, which demand a landed edit a run task
864
- # legitimately never makes (plan 107 follow-up: qemu-startup classified as
892
+ # legitimately never makes (a qemu-startup task was classified as
865
893
  # neither, so a prose give-up with 81% of the wall left took the weakest path).
866
894
  run_task = self.mode != "plan" and _intent.get("run", False)
867
895
  # Progress-aware step cap (see guardrails.extend_step_cap): max_steps is the
@@ -985,7 +1013,7 @@ class Agent:
985
1013
  # Turn-level think budget: past exhaustion, forced no-think steps are
986
1014
  # paid on a duty cycle (one per TURN_THINK_REARM_TOK further think tokens)
987
1015
  # so thinking RESTORES once the model stops over-spending — a blanket
988
- # rest-of-turn mute regressed run1 passes (plan 107 F1). The 103 landing's
1016
+ # rest-of-turn mute regressed passing runs. The hard-landing's
989
1017
  # landing_no_think stays unconditional.
990
1018
  _tt_throttled = (turn_think_exhausted
991
1019
  and guardrails.turn_think_throttle(
@@ -1118,7 +1146,7 @@ class Agent:
1118
1146
  # set AND this step is actually thinking (a no-think escalation step has no
1119
1147
  # <think> to salvage). None => the engine path is byte-identical to before.
1120
1148
  step_ceiling = self.think_ceiling if (self.think_ceiling and step_thinking) else None
1121
- # Landing generations (after the 103 deadline abort armed the latch) are
1149
+ # Landing generations (after the deadline abort armed the latch) are
1122
1150
  # token-boxed so the forced landing can't itself run long or spiral: the
1123
1151
  # remaining wall only affords so many tokens, halved to leave room for the
1124
1152
  # landing's own tool dispatch. Default path is byte-identical (self.max_gen_tokens).
@@ -1135,8 +1163,8 @@ class Agent:
1135
1163
  except BackendError as e:
1136
1164
  # A transient backend fault (5xx / mid-stream error chunk) used to escape
1137
1165
  # run_turn and kill the process from cli.main — forfeiting the rest of an
1138
- # unattended task's budget (TB2 make-mips-interpreter died at 721s of a
1139
- # 1770s budget on a single llama.cpp 500). Re-issue the step instead: the
1166
+ # unattended task's budget — a measured run died at 721s of a 1770s
1167
+ # budget on a single llama.cpp 500. Re-issue the step instead: the
1140
1168
  # prompt is rebuilt from `messages` each iteration and the failed
1141
1169
  # generation was never appended, so a retry is a clean re-roll — and at
1142
1170
  # temp>0 a resample usually clears a parser-rejected completion.
@@ -1187,9 +1215,9 @@ class Agent:
1187
1215
  self._emit("stream", "\n")
1188
1216
  # strip any trailing special tokens the template will re-add — and any
1189
1217
  # LEAKED special-token literal anywhere in the text. A quantized model
1190
- # can emit a stray marker like <|mask_end|> mid-turn (NIGHT-7
1191
- # django-14404 r3: one leaked at step 12 and the turn read as a clean
1192
- # final answer, ending the run rc=0 with an unverified edit); scrubbed
1218
+ # can emit a stray marker like <|mask_end|> mid-turn (one leaked at
1219
+ # step 12 of a measured run and the turn read as a clean final answer,
1220
+ # ending rc=0 with an unverified edit); scrubbed
1193
1221
  # here so it can neither pollute the transcript nor masquerade as
1194
1222
  # content.
1195
1223
  text = _SPECIAL_TOKEN_RE.sub("", text).rstrip()
@@ -1222,6 +1250,20 @@ class Agent:
1222
1250
  elif "</think>" in text and len(text):
1223
1251
  frac = len(text.split("</think>", 1)[0]) / len(text)
1224
1252
  _think_delta = int(stats.generated_tokens * frac)
1253
+ elif (hit_cap and step_thinking and text
1254
+ and levers.enabled("capped_think_credit")):
1255
+ # Same reasoning as the soft-stop branch, for the generation that ran
1256
+ # to the RAW token cap while still inside <think>: no </think> was
1257
+ # emitted, so every token is reasoning. Without this the biggest
1258
+ # thinks in a run — a full cap each, and the ones the budget exists
1259
+ # to bound — credit zero and the throttle never engages.
1260
+ # close_unclosed_think above rests on the same premise: a thinking
1261
+ # generation with no </think> never left the block. The predicate is
1262
+ # the reasoning_length_stop telemetry's, minus the soft-stop overlap
1263
+ # the first branch already credits; an unclosed generation that
1264
+ # stopped SHORT of the cap is a truncation of some other kind, not
1265
+ # the reasoning overspend this counts.
1266
+ _think_delta = stats.generated_tokens
1225
1267
  else:
1226
1268
  _think_delta = 0
1227
1269
  self.think_tokens += _think_delta
@@ -1234,7 +1276,7 @@ class Agent:
1234
1276
  turn_think_tokens += _think_delta
1235
1277
  # Inert below TURN_THINK_MIN_WALL_S: a short auto-continue tail clamps to
1236
1278
  # the LO budget and half-fires on its first step, churning against
1237
- # hard_wrapup's landing (plan 107 F2 — the regex-log relaunch signature).
1279
+ # hard_wrapup's landing (the regex-log relaunch signature).
1238
1280
  if (self._turn_budget_s
1239
1281
  and self._turn_budget_s >= guardrails.TURN_THINK_MIN_WALL_S
1240
1282
  and self.mode != "plan" and not read_only_intent
@@ -1460,21 +1502,55 @@ class Agent:
1460
1502
  "— progress note banked; say 'continue' to retry]")
1461
1503
  return ("[stopped: the model kept emitting malformed tool calls "
1462
1504
  "— say 'continue' to resume]")
1463
- # Iter-3 did-nothing gate: in auto/headless mode (every benchmark run), a
1464
- # turn that ends having executed ZERO real tools is never a legitimate
1465
- # completion — the keyword intent classifier misses tasks like "extract the
1466
- # secret and save it" (no action verb), so action_task is False and the
1467
- # gate below wouldn't fire (TB2 vulnerable-secret shipped a 28-token garble
1468
- # as its answer). read_only/explain asks are exempt. Banks a note so
1505
+ # Did-nothing gate: in auto/headless mode a turn that ends having
1506
+ # executed ZERO real tools is never a legitimate completion — the keyword
1507
+ # intent classifier misses tasks like "extract the secret and save it"
1508
+ # (no action verb), so action_task is False and the gate below wouldn't
1509
+ # fire, which is how a turn once shipped a 28-token garble as its final
1510
+ # answer. read_only/explain asks are exempt. Banks a note so
1469
1511
  # auto-continue relaunches fresh instead of shipping nothing.
1470
1512
  did_nothing = self.mode == "auto" and not read_only_intent and not did_work
1471
1513
  if (action_task and not read_only_intent
1472
1514
  and (not made_edit or unverified_edit)) or did_nothing:
1515
+ # Churn→audit handoff: this hard stop used to fire with the
1516
+ # audit still silent — the turn ends, a progress note carrying
1517
+ # the model's own completion claim gets banked, and each
1518
+ # relaunch re-dones into the same stop until the continue
1519
+ # allowance is exhausted. Hand the ending to the audit ONCE
1520
+ # instead: quoted requirements + path facts land IN CONTEXT
1521
+ # with the turn's work. A further empty-diff ending still
1522
+ # hard-stops exactly as below (churn capped, not replaced);
1523
+ # latch shared with the accept-path audit.
1524
+ if self.mode != "plan" and not self._subagent \
1525
+ and levers.enabled("done_audit") \
1526
+ and levers.enabled("audit_churn_handoff") \
1527
+ and not done_audit_fired:
1528
+ audit_task = guardrails.audit_task_text(user_text)
1529
+ audit = guardrails.done_audit(audit_task, {
1530
+ "turn_start_epoch": turn_start_epoch,
1531
+ "wall_s": time.monotonic() - turn_start,
1532
+ "wall_budget_s": self._turn_budget_s,
1533
+ "step_walls": step_walls,
1534
+ }, entry="handoff")
1535
+ if audit:
1536
+ done_audit_fired = True
1537
+ done_audit_bounces += 1
1538
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1539
+ _runway = ((self._turn_budget_s
1540
+ - (time.monotonic() - turn_start))
1541
+ if self._turn_budget_s else float("inf"))
1542
+ log.info("DONE-AUDIT bounce (churn-handoff, "
1543
+ "final-answer): paths=%s runway=%.0fs",
1544
+ guardrails.audit_extract_paths(audit_task),
1545
+ _runway)
1546
+ self.messages.append({"role": "tool", "name": "edit",
1547
+ "content": audit})
1548
+ continue
1473
1549
  # Iter-2 no-empty-diff gate: an action task may not END on a prose
1474
1550
  # "final answer" while no change landed (or the change is
1475
- # unverified) — the demonstrated failures (django-14007,
1476
- # sphinx-9230): 49–97s bails accepted as final answers with an
1477
- # empty diff and 97% of the budget unused. Bank a progress note
1551
+ # unverified) — the demonstrated failures: 49–97s bails accepted
1552
+ # as final answers with an empty diff and 97% of the budget
1553
+ # unused. Bank a progress note
1478
1554
  # and end as a hard stop, so --auto-continue (headless) or the
1479
1555
  # user's 'continue' (TUI) relaunches a fresh attempt with the
1480
1556
  # note instead of silently shipping nothing.
@@ -1488,11 +1564,10 @@ class Agent:
1488
1564
  "— progress note banked; say 'continue' to retry]")
1489
1565
  return ("[stopped: the turn ended without applying a verified "
1490
1566
  "change — say 'continue' to resume]")
1491
- # Done-audit, final-answer twin (Part B readout): a prose
1492
- # final answer on an action task is a `done` in all but name, and this
1493
- # accept path bypassed every done gate — several of Part B's wrong-dones
1494
- # exited here with the lever ON but never engaged (build-pmars on.2,
1495
- # log-summary on.1, large-scale-text-editing off.1). Same lever, same
1567
+ # Done-audit, final-answer twin: a prose final answer on an action
1568
+ # task is a `done` in all but name, and this accept path bypassed every
1569
+ # done gate — in the measured set several wrong-dones exited here with
1570
+ # the lever ON but never engaged. Same lever, same
1496
1571
  # guards, same once-per-turn latch as the done-tool branch below; the
1497
1572
  # steer's "call done again" converts a prose-ender into a done-caller,
1498
1573
  # which the latch then accepts.
@@ -1591,11 +1666,39 @@ class Agent:
1591
1666
  continue
1592
1667
  if action_task and not read_only_intent and self.mode != "plan" \
1593
1668
  and (not made_edit or unverified_edit):
1669
+ # Churn→audit handoff, done-tool twin (see the final-answer
1670
+ # site above): one audit bounce before the hard stop; the
1671
+ # next empty-diff done stops exactly as below.
1672
+ if not self._subagent \
1673
+ and levers.enabled("done_audit") \
1674
+ and levers.enabled("audit_churn_handoff") \
1675
+ and not done_audit_fired:
1676
+ audit_task = guardrails.audit_task_text(user_text)
1677
+ audit = guardrails.done_audit(audit_task, {
1678
+ "turn_start_epoch": turn_start_epoch,
1679
+ "wall_s": time.monotonic() - turn_start,
1680
+ "wall_budget_s": self._turn_budget_s,
1681
+ "step_walls": step_walls,
1682
+ }, entry="handoff")
1683
+ if audit:
1684
+ done_audit_fired = True
1685
+ done_audit_bounces += 1
1686
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1687
+ _runway = ((self._turn_budget_s
1688
+ - (time.monotonic() - turn_start))
1689
+ if self._turn_budget_s else float("inf"))
1690
+ log.info("DONE-AUDIT bounce (churn-handoff): paths=%s "
1691
+ "runway=%.0fs",
1692
+ guardrails.audit_extract_paths(audit_task),
1693
+ _runway)
1694
+ self.messages.append({"role": "tool", "name": "done",
1695
+ "content": audit})
1696
+ continue
1594
1697
  # Same no-empty-diff gate as the prose-final-answer path: `done`
1595
1698
  # with nothing landed (or landed-unverified after the verify
1596
1699
  # nudges ran out) becomes a resumable hard stop, not a success
1597
- # (matplotlib-25332 r3: done accepted at 84s with edits in tree
1598
- # and zero successful post-edit commands).
1700
+ # (measured: done accepted at 84s with edits in tree and zero
1701
+ # successful post-edit commands).
1599
1702
  self.budget_note = guardrails.progress_note(
1600
1703
  self.messages,
1601
1704
  rejected_claim=str(terminal.get("summary") or ""))
@@ -1606,12 +1709,12 @@ class Agent:
1606
1709
  "progress note banked; say 'continue' to retry]")
1607
1710
  return ("[stopped: `done` was called without a landed+verified "
1608
1711
  "change — say 'continue' to resume]")
1609
- # Done-audit: the TB2.1 autopsy's largest bucket (20/43 fails)
1712
+ # Done-audit: the largest measured fail bucket (20/43)
1610
1713
  # was dones whose claimed verification was a WEAKER predicate than the
1611
- # task's own checker — and the generic recheck below was ON for all of
1714
+ # task's own wording — and the generic recheck below was ON for all of
1612
1715
  # them. On a done every gate above would accept, bounce ONCE with the
1613
1716
  # task statement's own requirement lines quoted plus stat facts for each
1614
- # path it names; the NEXT done is accepted unconditionally (the 070
1717
+ # path it names; the NEXT done is accepted unconditionally (the
1615
1718
  # anti-spiral latch — the model is told so, which keeps the steer
1616
1719
  # credible). Supersedes done_spec_recheck while enabled: stacking both
1617
1720
  # would force two bounces per turn. Post-audit edits deliberately do NOT
@@ -1623,8 +1726,8 @@ class Agent:
1623
1726
  # as possible…" name no action verb); the anchors requirement inside
1624
1727
  # done_audit (concrete paths / imperative requirement lines) is the
1625
1728
  # task-text-derived action detector.
1626
- # Not in sub-agents (Part B: circuit-fibsqrt's bounce fired inside one
1627
- # with runway=infs — sub-agents carry no wall budget, so the runway
1729
+ # Not in sub-agents (a measured bounce fired inside one at
1730
+ # runway=inf — sub-agents carry no wall budget, so the runway
1628
1731
  # guard is inert there, and their delegated prompt is not the task
1629
1732
  # statement the audit should quote). audit_task strips the harness
1630
1733
  # appendices (progress note / review-pass preamble) a relaunched
@@ -1806,10 +1909,14 @@ class Agent:
1806
1909
  "plan to ./plans/NNN-title.md.]")
1807
1910
  elif not plan_write and not self._confirm(name, args):
1808
1911
  # A plan write is the expected action in plan mode, so it skips the
1809
- # confirm prompt; everything else still goes through _confirm.
1810
- result = "[denied by user]"
1912
+ # confirm prompt; everything else still goes through _confirm. A
1913
+ # genuine human "no" stays "[denied by user]"; the headless guard
1914
+ # block carries its own explanation (set in _confirm).
1915
+ result = self._deny_reason or "[denied by user]"
1916
+ self._deny_reason = None
1811
1917
  else:
1812
1918
  _t0 = time.perf_counter()
1919
+ self.tool_dispatches += 1
1813
1920
  try:
1814
1921
  result = fn(args, self._should_stop)
1815
1922
  if plan_write and result.startswith("[wrote"):
@@ -1821,6 +1928,15 @@ class Agent:
1821
1928
  # whole — several calls in one step stack into one prefill, so later
1822
1929
  # results only get what's left of the step budget (floor-protected).
1823
1930
  result = _clip_tool_result(result, cap=_step_tool_cap(step_tool_chars))
1931
+ # Duplicate read-only output: if this (clipped) result is
1932
+ # byte-identical to a tool message still in the transcript, append a
1933
+ # short pointer instead of paying the body's prefill again — see
1934
+ # guardrails.elide_duplicate_result for the safety argument.
1935
+ _elided = guardrails.elide_duplicate_result(name, result, self.messages)
1936
+ if _elided is not None:
1937
+ log.info("TOOL %s duplicate result elided (%d chars)",
1938
+ name, len(result))
1939
+ result = _elided
1824
1940
  step_tool_chars += len(result)
1825
1941
  if _PREFILL_TRACE:
1826
1942
  self._trace_tools_pending.append([name, round(_tool_s, 4)])
@@ -1992,8 +2108,7 @@ def repl(engine: BaseEngine, yolo: bool, ctx_limit: int = 24000, resume: list =
1992
2108
  thinking: bool = True, ctx_limit_fn=None):
1993
2109
  agent = Agent(engine, yolo=yolo, ctx_limit=ctx_limit, thinking=thinking,
1994
2110
  resume=resume, persist=True, ctx_limit_fn=ctx_limit_fn)
1995
- label = engine.model_id.split("/")[-1] + (" + draft" if getattr(engine, "draft", None) else "")
1996
- print(banner(label, ctx_limit, mode=agent.mode))
2111
+ print(banner(engine.model_id.split("/")[-1], ctx_limit, mode=agent.mode))
1997
2112
  print(f"{C_DIM}type a task, or /reset, /exit.{C_RST}")
1998
2113
  while True:
1999
2114
  try:
@@ -13,26 +13,73 @@ by a backend that never loads mlx. `GenStats` lives here (not `engine.py`) preci
13
13
  the remote adapter can build one without dragging in `mlx.core`.
14
14
  """
15
15
 
16
- from dataclasses import dataclass
17
- from typing import Any, Callable, Optional, Protocol, runtime_checkable
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Callable, Iterable, Optional, Protocol, runtime_checkable
18
18
 
19
19
  # Close-and-continue salvage: the marker injected to force-close a <think> run
20
20
  # that has blown past the ceiling, so decoding continues into the ACTION *in the same step*
21
21
  # — vs the soft think-cap, which ends the step and lets the next one re-derive the whole
22
- # reasoning (the 084 anti-fix: force-close-then-new-step measured 3.8x total think). Both
22
+ # reasoning (the measured anti-fix: force-close-then-new-step cost 3.8x total think). Both
23
23
  # engines inject this same string: the MLX path tokenizes it and feeds the ids through the
24
24
  # append-only cache; the llama path hands it back as an assistant-prefix continuation.
25
25
  THINK_CLOSE = "\n</think>\n\n"
26
26
 
27
27
 
28
- def think_ceiling_hit(text: str, n_tokens: int, ceiling: Optional[int]) -> bool:
28
+ def think_ceiling_hit(text: str, n_tokens: int, ceiling: Optional[int],
29
+ think_closed: Optional[bool] = None) -> bool:
29
30
  """True when generation is still inside the auto-opened <think> block (no </think>
30
31
  emitted yet) and has run past `ceiling` tokens — the close-and-continue trigger
31
32
  `ceiling` None/0 disables it (byte-identical to no ceiling). Pure so both
32
- engines share one definition and it is unit-testable without a model."""
33
- if not ceiling:
33
+ engines share one definition and it is unit-testable without a model.
34
+
35
+ Called once per decoded token by both engines, so the cheap token-count test runs
36
+ FIRST: below the ceiling — which is almost always — this never touches `text` at all.
37
+ `think_closed` lets a caller that already tracks the marker incrementally (see
38
+ `TailWatch`) skip the scan entirely; None means "work it out from `text`", which is
39
+ the original behavior and what the unit tests exercise."""
40
+ if not ceiling or n_tokens < ceiling:
34
41
  return False
35
- return n_tokens >= ceiling and "</think>" not in text
42
+ if think_closed is None:
43
+ think_closed = "</think>" in text
44
+ return not think_closed
45
+
46
+
47
+ class TailWatch:
48
+ """Has any of these needles appeared yet in an append-only text? Answered per
49
+ segment, in constant time, without re-reading the text.
50
+
51
+ The naive spelling — `any(n in text for n in needles)` after every decoded token —
52
+ is quadratic in the generated length, because every token re-scans everything
53
+ generated so far. Measured on the streaming client: 500 tokens costs 6ms, 2k costs
54
+ 41ms, 8k costs 242ms, 16k costs 777ms of pure CPU spent re-reading. That CPU is on
55
+ the thread draining the token stream, so it is also what decides how fast the
56
+ stream gets drained.
57
+
58
+ A needle can only become newly complete if it straddles the segment just appended,
59
+ so only `len(needle) - 1` characters of carry-over are ever relevant:
60
+
61
+ …already scanned…│ tail │ new segment │
62
+ └──────┬──────────┘
63
+ the only window a new match can live in
64
+
65
+ Latching: once found, always found (the text only grows), so `hit` never goes back
66
+ to False and later feeds short-circuit."""
67
+
68
+ def __init__(self, needles: Iterable[str]):
69
+ self._needles = [n for n in (needles or []) if n]
70
+ self._carry = max((len(n) for n in self._needles), default=1) - 1
71
+ self._tail = ""
72
+ self.hit = False
73
+
74
+ def feed(self, seg: str) -> bool:
75
+ """Absorb the next segment; return whether any needle has been seen yet."""
76
+ if self.hit or not self._needles or not seg:
77
+ return self.hit
78
+ window = self._tail + seg
79
+ if any(n in window for n in self._needles):
80
+ self.hit = True
81
+ self._tail = window[-self._carry:] if self._carry else ""
82
+ return self.hit
36
83
 
37
84
 
38
85
  class BackendError(RuntimeError):
@@ -82,6 +129,19 @@ class GenStats:
82
129
  # that can't report cached_tokens / per-forward
83
130
  # accounting sets this so callers know the
84
131
  # throughput/prefill numbers are estimates.
132
+ gen_ids: list = field(default_factory=list)
133
+ # the token ids generated this turn. The engine knows
134
+ # them exactly; a caller must never re-derive them by
135
+ # slicing `_cached_ids`, whose shape differs per decode
136
+ # path (the prompt-lookup path stores what it FED the
137
+ # cache, which omits the final pending token, and an
138
+ # OOM empties it). `chad serve` puts these on the wire
139
+ # so a remote client can mirror the server's cache.
140
+ cache_reset: bool = False # the prefix cache was DROPPED during this turn (Metal
141
+ # OOM recovery), so nothing is resident afterwards —
142
+ # not even the prompt. A caller mirroring cache state
143
+ # must clear its mirror rather than assume prompt+gen,
144
+ # which is the one thing `gen_ids` alone cannot say.
85
145
 
86
146
  @property
87
147
  def tok_per_s(self) -> float: