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.
- {chad_code-1.0.3 → chad_code-1.0.5}/PKG-INFO +22 -9
- {chad_code-1.0.3 → chad_code-1.0.5}/README.md +21 -8
- {chad_code-1.0.3 → chad_code-1.0.5}/pyproject.toml +1 -1
- chad_code-1.0.5/src/chad/__init__.py +14 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/agent.py +162 -47
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/base_engine.py +67 -7
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/bench.py +2 -2
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/cli.py +261 -124
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/completion_engine.py +168 -15
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/config.py +15 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/diag.py +10 -7
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/engine.py +61 -52
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/guardrails.py +219 -70
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/levers.py +168 -48
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mlx_fastpath.py +26 -7
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mlx_qsdpa.py +333 -32
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/prompt.py +76 -12
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/prove.py +4 -4
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/repomap.py +4 -4
- chad_code-1.0.5/src/chad/serve.py +814 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/syntaxgate.py +18 -19
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/toolcall_parse.py +4 -4
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/tools.py +295 -21
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/tui.py +6 -5
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/PKG-INFO +22 -9
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/SOURCES.txt +3 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent_e2e.py +75 -19
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_agent_guards.py +140 -11
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_cli.py +248 -3
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_completion_engine.py +84 -2
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_config.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_confirm_preview.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_done_audit.py +237 -104
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_edit.py +40 -5
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_edit_corruption.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_engine.py +4 -8
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_engine_kvquant.py +0 -8
- chad_code-1.0.5/tests/test_engine_pld_hybrid.py +227 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_feel_pack.py +20 -4
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_gate.py +20 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_intent.py +4 -4
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_lever_bite.py +148 -13
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_log_redaction.py +38 -13
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mlx_fastpath.py +2 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mlx_qsdpa.py +75 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_render.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_repomap.py +1 -1
- chad_code-1.0.5/tests/test_serve.py +815 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_session.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_subagent.py +8 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_symbols.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_syntaxgate.py +4 -4
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_toolcall_parse.py +2 -2
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_tools.py +204 -17
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_tui.py +1 -1
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_validate.py +1 -1
- chad_code-1.0.3/src/chad/__init__.py +0 -7
- {chad_code-1.0.3 → chad_code-1.0.5}/LICENSE +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/setup.cfg +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/atif.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/compaction.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/ignore.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/lsp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mcp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/mcp_oauth.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/profiles.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/render.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/session.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/skills.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/symbols.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad/validate.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/dependency_links.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/entry_points.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/requires.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/src/chad_code.egg-info/top_level.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_atif.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_bench.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_compact_notice.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_compaction.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_drift_warn.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_garble_invariant.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_ignore.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_levers.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_lsp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mcp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_mcp_oauth.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_plan_review.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_prove.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.5}/tests/test_replace_lines.py +0 -0
- {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
|
+
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
|
|
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).
|
|
143
|
-
|
|
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
|
-
| **≥
|
|
148
|
-
| **16 / 18
|
|
149
|
-
|
|
150
|
-
The 35B's working set
|
|
151
|
-
|
|
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
|
|
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).
|
|
106
|
-
|
|
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
|
-
| **≥
|
|
111
|
-
| **16 / 18
|
|
112
|
-
|
|
113
|
-
The 35B's working set
|
|
114
|
-
|
|
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.
|
|
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;
|
|
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
|
|
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 #
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 (
|
|
803
|
-
#
|
|
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
|
|
821
|
-
# landing_no_think is the
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
1139
|
-
#
|
|
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 (
|
|
1191
|
-
#
|
|
1192
|
-
#
|
|
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 (
|
|
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
|
-
#
|
|
1464
|
-
#
|
|
1465
|
-
#
|
|
1466
|
-
#
|
|
1467
|
-
#
|
|
1468
|
-
#
|
|
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
|
|
1476
|
-
#
|
|
1477
|
-
#
|
|
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
|
|
1492
|
-
#
|
|
1493
|
-
#
|
|
1494
|
-
#
|
|
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
|
-
# (
|
|
1598
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
1627
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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:
|