chad-code 1.0.4__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.4 → chad_code-1.0.5}/PKG-INFO +22 -9
  2. {chad_code-1.0.4 → chad_code-1.0.5}/README.md +21 -8
  3. {chad_code-1.0.4 → 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.4 → chad_code-1.0.5}/src/chad/agent.py +132 -42
  6. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/base_engine.py +67 -7
  7. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/bench.py +2 -2
  8. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/cli.py +261 -124
  9. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/completion_engine.py +168 -15
  10. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/engine.py +61 -52
  11. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/guardrails.py +160 -72
  12. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/levers.py +132 -49
  13. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/mlx_fastpath.py +26 -7
  14. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/mlx_qsdpa.py +333 -32
  15. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/prompt.py +40 -8
  16. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/prove.py +4 -4
  17. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/repomap.py +4 -4
  18. chad_code-1.0.5/src/chad/serve.py +814 -0
  19. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/syntaxgate.py +18 -19
  20. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/toolcall_parse.py +4 -4
  21. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/tools.py +262 -16
  22. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/tui.py +2 -2
  23. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/PKG-INFO +22 -9
  24. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/SOURCES.txt +3 -0
  25. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_agent.py +1 -1
  26. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_agent_e2e.py +75 -19
  27. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_agent_guards.py +79 -13
  28. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_cli.py +248 -3
  29. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_completion_engine.py +84 -2
  30. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_config.py +1 -1
  31. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_confirm_preview.py +1 -1
  32. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_done_audit.py +237 -104
  33. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_edit.py +6 -6
  34. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_edit_corruption.py +1 -1
  35. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_engine.py +4 -8
  36. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_engine_kvquant.py +0 -8
  37. chad_code-1.0.5/tests/test_engine_pld_hybrid.py +227 -0
  38. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_gate.py +20 -0
  39. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_intent.py +4 -4
  40. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_lever_bite.py +100 -14
  41. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_mlx_fastpath.py +2 -0
  42. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_mlx_qsdpa.py +75 -0
  43. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_render.py +1 -1
  44. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_repomap.py +1 -1
  45. chad_code-1.0.5/tests/test_serve.py +815 -0
  46. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_session.py +1 -1
  47. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_symbols.py +1 -1
  48. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_syntaxgate.py +4 -4
  49. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_toolcall_parse.py +2 -2
  50. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_tools.py +204 -17
  51. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_tui.py +1 -1
  52. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_validate.py +1 -1
  53. chad_code-1.0.4/src/chad/__init__.py +0 -7
  54. {chad_code-1.0.4 → chad_code-1.0.5}/LICENSE +0 -0
  55. {chad_code-1.0.4 → chad_code-1.0.5}/setup.cfg +0 -0
  56. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/atif.py +0 -0
  57. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/compaction.py +0 -0
  58. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/config.py +0 -0
  59. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/diag.py +0 -0
  60. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/ignore.py +0 -0
  61. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/lsp.py +0 -0
  62. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/mcp.py +0 -0
  63. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/mcp_oauth.py +0 -0
  64. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/profiles.py +0 -0
  65. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/render.py +0 -0
  66. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/session.py +0 -0
  67. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/skills.py +0 -0
  68. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/symbols.py +0 -0
  69. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad/validate.py +0 -0
  70. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/dependency_links.txt +0 -0
  71. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/entry_points.txt +0 -0
  72. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/requires.txt +0 -0
  73. {chad_code-1.0.4 → chad_code-1.0.5}/src/chad_code.egg-info/top_level.txt +0 -0
  74. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_atif.py +0 -0
  75. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_bench.py +0 -0
  76. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_compact_notice.py +0 -0
  77. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_compaction.py +0 -0
  78. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_drift_warn.py +0 -0
  79. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_feel_pack.py +0 -0
  80. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_garble_invariant.py +0 -0
  81. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_ignore.py +0 -0
  82. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_levers.py +0 -0
  83. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_log_redaction.py +0 -0
  84. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_lsp.py +0 -0
  85. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_mcp.py +0 -0
  86. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_mcp_oauth.py +0 -0
  87. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_plan_review.py +0 -0
  88. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_prove.py +0 -0
  89. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_replace_lines.py +0 -0
  90. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_skills.py +0 -0
  91. {chad_code-1.0.4 → chad_code-1.0.5}/tests/test_subagent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.4
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.4"
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
@@ -597,6 +599,17 @@ class Agent:
597
599
  if self._confirm_cb is None and not sys.stdin.isatty():
598
600
  self._emit("info", f" [blocked destructive command in auto mode: "
599
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.]")
600
613
  return False
601
614
  if self._confirm_cb is not None:
602
615
  return self._confirm_cb(name, args)
@@ -675,7 +688,7 @@ class Agent:
675
688
  # Fail-safe: a sub-agent that ends early — step cap, crash, interrupt — or
676
689
  # that returns nothing at all must still hand back where it got to. Never
677
690
  # surface a bare sentinel: the parent then restarts the localization from
678
- # zero (the django-14007/sphinx-9230 failure), and the anti-respawn guard
691
+ # zero (the demonstrated failure), and the anti-respawn guard
679
692
  # above refuses the retry, so the turn dies with the findings still in the
680
693
  # dead sub-agent's transcript. progress_note is deterministic and model-free,
681
694
  # so it works even from a crashed turn: it re-reads the sub-agent's own tool
@@ -683,7 +696,7 @@ class Agent:
683
696
  # hypothesis it stated.
684
697
  def _salvage(res: str) -> str:
685
698
  if not levers.enabled("subagent_budget_note"):
686
- 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
687
700
  note = sub.budget_note or guardrails.progress_note(sub.messages)
688
701
  if not note:
689
702
  return res
@@ -753,7 +766,7 @@ class Agent:
753
766
  self.ctx_limit = fresh
754
767
  except Exception: # noqa: BLE001 — a pressure probe must never kill a turn
755
768
  pass
756
- # 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
757
770
  # (or prefill+persist it once) so the first turn doesn't re-prefill the
758
771
  # ~3.2k-token stable prefix every session. Cheap no-op on a warm cache.
759
772
  if self.engine.cache_dir and not self.engine._cached_ids:
@@ -814,8 +827,8 @@ class Agent:
814
827
  plan_reviews = 0 # one-shot "re-read the plan you just wrote" (levers.plan_review)
815
828
  # Files edited this turn -> mtime at last syntax check. Bash can mutate files
816
829
  # too (sed -i, python rewrites) but bypasses the write/edit syntax gate; watch
817
- # edited files and re-check them after any bash that touched them (iter-2:
818
- # 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).
819
832
  edited_syntax_watch: dict = {}
820
833
  think_cap_hits = 0 # soft think-cap firings this turn (drives escalation)
821
834
  repeat_stops = 0 # degenerate-repetition cut-offs this turn (3rd aborts the turn)
@@ -832,8 +845,8 @@ class Agent:
832
845
  # decode-speed-aware budget. turn_think_exhausted latches the once-only
833
846
  # log/steer; past it, no-think steps are paid on a duty cycle
834
847
  # (guardrails.turn_think_throttle) rather than muting the rest of the turn —
835
- # the blanket mute regressed run1 passes with garbled no-think tails (plan 107).
836
- # 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
837
850
  # wrap-up fires, the landing and everything after it stay no-think regardless.
838
851
  turn_think_tokens = 0
839
852
  turn_think_half_fired = False
@@ -876,7 +889,7 @@ class Agent:
876
889
  # Run-task intent (start/boot/serve/… — system-state imperatives with no file
877
890
  # deliverable): arms the anti-bail nudges alongside action_task but is kept OUT
878
891
  # of the no-empty-diff done gates below, which demand a landed edit a run task
879
- # legitimately never makes (plan 107 follow-up: qemu-startup classified as
892
+ # legitimately never makes (a qemu-startup task was classified as
880
893
  # neither, so a prose give-up with 81% of the wall left took the weakest path).
881
894
  run_task = self.mode != "plan" and _intent.get("run", False)
882
895
  # Progress-aware step cap (see guardrails.extend_step_cap): max_steps is the
@@ -1000,7 +1013,7 @@ class Agent:
1000
1013
  # Turn-level think budget: past exhaustion, forced no-think steps are
1001
1014
  # paid on a duty cycle (one per TURN_THINK_REARM_TOK further think tokens)
1002
1015
  # so thinking RESTORES once the model stops over-spending — a blanket
1003
- # 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
1004
1017
  # landing_no_think stays unconditional.
1005
1018
  _tt_throttled = (turn_think_exhausted
1006
1019
  and guardrails.turn_think_throttle(
@@ -1133,7 +1146,7 @@ class Agent:
1133
1146
  # set AND this step is actually thinking (a no-think escalation step has no
1134
1147
  # <think> to salvage). None => the engine path is byte-identical to before.
1135
1148
  step_ceiling = self.think_ceiling if (self.think_ceiling and step_thinking) else None
1136
- # Landing generations (after the 103 deadline abort armed the latch) are
1149
+ # Landing generations (after the deadline abort armed the latch) are
1137
1150
  # token-boxed so the forced landing can't itself run long or spiral: the
1138
1151
  # remaining wall only affords so many tokens, halved to leave room for the
1139
1152
  # landing's own tool dispatch. Default path is byte-identical (self.max_gen_tokens).
@@ -1150,8 +1163,8 @@ class Agent:
1150
1163
  except BackendError as e:
1151
1164
  # A transient backend fault (5xx / mid-stream error chunk) used to escape
1152
1165
  # run_turn and kill the process from cli.main — forfeiting the rest of an
1153
- # unattended task's budget (TB2 make-mips-interpreter died at 721s of a
1154
- # 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
1155
1168
  # prompt is rebuilt from `messages` each iteration and the failed
1156
1169
  # generation was never appended, so a retry is a clean re-roll — and at
1157
1170
  # temp>0 a resample usually clears a parser-rejected completion.
@@ -1202,9 +1215,9 @@ class Agent:
1202
1215
  self._emit("stream", "\n")
1203
1216
  # strip any trailing special tokens the template will re-add — and any
1204
1217
  # LEAKED special-token literal anywhere in the text. A quantized model
1205
- # can emit a stray marker like <|mask_end|> mid-turn (NIGHT-7
1206
- # django-14404 r3: one leaked at step 12 and the turn read as a clean
1207
- # 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
1208
1221
  # here so it can neither pollute the transcript nor masquerade as
1209
1222
  # content.
1210
1223
  text = _SPECIAL_TOKEN_RE.sub("", text).rstrip()
@@ -1237,6 +1250,20 @@ class Agent:
1237
1250
  elif "</think>" in text and len(text):
1238
1251
  frac = len(text.split("</think>", 1)[0]) / len(text)
1239
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
1240
1267
  else:
1241
1268
  _think_delta = 0
1242
1269
  self.think_tokens += _think_delta
@@ -1249,7 +1276,7 @@ class Agent:
1249
1276
  turn_think_tokens += _think_delta
1250
1277
  # Inert below TURN_THINK_MIN_WALL_S: a short auto-continue tail clamps to
1251
1278
  # the LO budget and half-fires on its first step, churning against
1252
- # hard_wrapup's landing (plan 107 F2 — the regex-log relaunch signature).
1279
+ # hard_wrapup's landing (the regex-log relaunch signature).
1253
1280
  if (self._turn_budget_s
1254
1281
  and self._turn_budget_s >= guardrails.TURN_THINK_MIN_WALL_S
1255
1282
  and self.mode != "plan" and not read_only_intent
@@ -1475,21 +1502,55 @@ class Agent:
1475
1502
  "— progress note banked; say 'continue' to retry]")
1476
1503
  return ("[stopped: the model kept emitting malformed tool calls "
1477
1504
  "— say 'continue' to resume]")
1478
- # Iter-3 did-nothing gate: in auto/headless mode (every benchmark run), a
1479
- # turn that ends having executed ZERO real tools is never a legitimate
1480
- # completion — the keyword intent classifier misses tasks like "extract the
1481
- # secret and save it" (no action verb), so action_task is False and the
1482
- # gate below wouldn't fire (TB2 vulnerable-secret shipped a 28-token garble
1483
- # 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
1484
1511
  # auto-continue relaunches fresh instead of shipping nothing.
1485
1512
  did_nothing = self.mode == "auto" and not read_only_intent and not did_work
1486
1513
  if (action_task and not read_only_intent
1487
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
1488
1549
  # Iter-2 no-empty-diff gate: an action task may not END on a prose
1489
1550
  # "final answer" while no change landed (or the change is
1490
- # unverified) — the demonstrated failures (django-14007,
1491
- # sphinx-9230): 49–97s bails accepted as final answers with an
1492
- # 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
1493
1554
  # and end as a hard stop, so --auto-continue (headless) or the
1494
1555
  # user's 'continue' (TUI) relaunches a fresh attempt with the
1495
1556
  # note instead of silently shipping nothing.
@@ -1503,11 +1564,10 @@ class Agent:
1503
1564
  "— progress note banked; say 'continue' to retry]")
1504
1565
  return ("[stopped: the turn ended without applying a verified "
1505
1566
  "change — say 'continue' to resume]")
1506
- # Done-audit, final-answer twin (Part B readout): a prose
1507
- # final answer on an action task is a `done` in all but name, and this
1508
- # accept path bypassed every done gate — several of Part B's wrong-dones
1509
- # exited here with the lever ON but never engaged (build-pmars on.2,
1510
- # 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
1511
1571
  # guards, same once-per-turn latch as the done-tool branch below; the
1512
1572
  # steer's "call done again" converts a prose-ender into a done-caller,
1513
1573
  # which the latch then accepts.
@@ -1606,11 +1666,39 @@ class Agent:
1606
1666
  continue
1607
1667
  if action_task and not read_only_intent and self.mode != "plan" \
1608
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
1609
1697
  # Same no-empty-diff gate as the prose-final-answer path: `done`
1610
1698
  # with nothing landed (or landed-unverified after the verify
1611
1699
  # nudges ran out) becomes a resumable hard stop, not a success
1612
- # (matplotlib-25332 r3: done accepted at 84s with edits in tree
1613
- # and zero successful post-edit commands).
1700
+ # (measured: done accepted at 84s with edits in tree and zero
1701
+ # successful post-edit commands).
1614
1702
  self.budget_note = guardrails.progress_note(
1615
1703
  self.messages,
1616
1704
  rejected_claim=str(terminal.get("summary") or ""))
@@ -1621,12 +1709,12 @@ class Agent:
1621
1709
  "progress note banked; say 'continue' to retry]")
1622
1710
  return ("[stopped: `done` was called without a landed+verified "
1623
1711
  "change — say 'continue' to resume]")
1624
- # Done-audit: the TB2.1 autopsy's largest bucket (20/43 fails)
1712
+ # Done-audit: the largest measured fail bucket (20/43)
1625
1713
  # was dones whose claimed verification was a WEAKER predicate than the
1626
- # 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
1627
1715
  # them. On a done every gate above would accept, bounce ONCE with the
1628
1716
  # task statement's own requirement lines quoted plus stat facts for each
1629
- # path it names; the NEXT done is accepted unconditionally (the 070
1717
+ # path it names; the NEXT done is accepted unconditionally (the
1630
1718
  # anti-spiral latch — the model is told so, which keeps the steer
1631
1719
  # credible). Supersedes done_spec_recheck while enabled: stacking both
1632
1720
  # would force two bounces per turn. Post-audit edits deliberately do NOT
@@ -1638,8 +1726,8 @@ class Agent:
1638
1726
  # as possible…" name no action verb); the anchors requirement inside
1639
1727
  # done_audit (concrete paths / imperative requirement lines) is the
1640
1728
  # task-text-derived action detector.
1641
- # Not in sub-agents (Part B: circuit-fibsqrt's bounce fired inside one
1642
- # 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
1643
1731
  # guard is inert there, and their delegated prompt is not the task
1644
1732
  # statement the audit should quote). audit_task strips the harness
1645
1733
  # appendices (progress note / review-pass preamble) a relaunched
@@ -1821,8 +1909,11 @@ class Agent:
1821
1909
  "plan to ./plans/NNN-title.md.]")
1822
1910
  elif not plan_write and not self._confirm(name, args):
1823
1911
  # A plan write is the expected action in plan mode, so it skips the
1824
- # confirm prompt; everything else still goes through _confirm.
1825
- 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
1826
1917
  else:
1827
1918
  _t0 = time.perf_counter()
1828
1919
  self.tool_dispatches += 1
@@ -2017,8 +2108,7 @@ def repl(engine: BaseEngine, yolo: bool, ctx_limit: int = 24000, resume: list =
2017
2108
  thinking: bool = True, ctx_limit_fn=None):
2018
2109
  agent = Agent(engine, yolo=yolo, ctx_limit=ctx_limit, thinking=thinking,
2019
2110
  resume=resume, persist=True, ctx_limit_fn=ctx_limit_fn)
2020
- label = engine.model_id.split("/")[-1] + (" + draft" if getattr(engine, "draft", None) else "")
2021
- print(banner(label, ctx_limit, mode=agent.mode))
2111
+ print(banner(engine.model_id.split("/")[-1], ctx_limit, mode=agent.mode))
2022
2112
  print(f"{C_DIM}type a task, or /reset, /exit.{C_RST}")
2023
2113
  while True:
2024
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:
@@ -97,7 +97,7 @@ def _agentic(model_id: str, why: str, context_tokens: int, apply_fix: bool):
97
97
  from .agent import build_system_prompt, close_unclosed_think
98
98
  from .engine import Engine
99
99
 
100
- eng = Engine(model_id=model_id, draft_id=None, cache_dir=None)
100
+ eng = Engine(model_id=model_id, cache_dir=None)
101
101
  eng.load()
102
102
  tok = eng.tok
103
103
 
@@ -203,7 +203,7 @@ def main(argv=None) -> int:
203
203
 
204
204
  # cache_dir=None so the on-disk warm-prefix can't pre-load and skew the *cold* prefill
205
205
  # number; this benchmark measures from a genuinely cold cache.
206
- eng = Engine(model_id=model_id, draft_id=None, cache_dir=None)
206
+ eng = Engine(model_id=model_id, cache_dir=None)
207
207
  sys.stderr.write(f"loading {model_id} [{why}] ...\n")
208
208
  load_s = eng.load()
209
209