chad-code 1.0.2__tar.gz → 1.0.3__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 (87) hide show
  1. {chad_code-1.0.2/src/chad_code.egg-info → chad_code-1.0.3}/PKG-INFO +15 -15
  2. {chad_code-1.0.2 → chad_code-1.0.3}/README.md +14 -14
  3. {chad_code-1.0.2 → chad_code-1.0.3}/pyproject.toml +1 -1
  4. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/agent.py +100 -4
  5. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/guardrails.py +169 -35
  6. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/levers.py +32 -0
  7. {chad_code-1.0.2 → chad_code-1.0.3/src/chad_code.egg-info}/PKG-INFO +15 -15
  8. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad_code.egg-info/SOURCES.txt +1 -0
  9. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_agent_guards.py +81 -4
  10. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_done_audit.py +97 -0
  11. chad_code-1.0.3/tests/test_garble_invariant.py +86 -0
  12. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_lever_bite.py +53 -6
  13. {chad_code-1.0.2 → chad_code-1.0.3}/LICENSE +0 -0
  14. {chad_code-1.0.2 → chad_code-1.0.3}/setup.cfg +0 -0
  15. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/__init__.py +0 -0
  16. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/atif.py +0 -0
  17. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/base_engine.py +0 -0
  18. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/bench.py +0 -0
  19. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/cli.py +0 -0
  20. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/compaction.py +0 -0
  21. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/completion_engine.py +0 -0
  22. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/config.py +0 -0
  23. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/diag.py +0 -0
  24. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/engine.py +0 -0
  25. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/ignore.py +0 -0
  26. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/lsp.py +0 -0
  27. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/mcp.py +0 -0
  28. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/mcp_oauth.py +0 -0
  29. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/mlx_fastpath.py +0 -0
  30. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/mlx_qsdpa.py +0 -0
  31. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/profiles.py +0 -0
  32. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/prompt.py +0 -0
  33. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/prove.py +0 -0
  34. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/render.py +0 -0
  35. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/repomap.py +0 -0
  36. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/session.py +0 -0
  37. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/skills.py +0 -0
  38. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/symbols.py +0 -0
  39. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/syntaxgate.py +0 -0
  40. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/toolcall_parse.py +0 -0
  41. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/tools.py +0 -0
  42. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/tui.py +0 -0
  43. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad/validate.py +0 -0
  44. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad_code.egg-info/dependency_links.txt +0 -0
  45. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad_code.egg-info/entry_points.txt +0 -0
  46. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad_code.egg-info/requires.txt +0 -0
  47. {chad_code-1.0.2 → chad_code-1.0.3}/src/chad_code.egg-info/top_level.txt +0 -0
  48. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_agent.py +0 -0
  49. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_agent_e2e.py +0 -0
  50. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_atif.py +0 -0
  51. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_bench.py +0 -0
  52. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_cli.py +0 -0
  53. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_compact_notice.py +0 -0
  54. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_compaction.py +0 -0
  55. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_completion_engine.py +0 -0
  56. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_config.py +0 -0
  57. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_confirm_preview.py +0 -0
  58. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_drift_warn.py +0 -0
  59. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_edit.py +0 -0
  60. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_edit_corruption.py +0 -0
  61. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_engine.py +0 -0
  62. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_engine_kvquant.py +0 -0
  63. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_feel_pack.py +0 -0
  64. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_gate.py +0 -0
  65. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_ignore.py +0 -0
  66. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_intent.py +0 -0
  67. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_levers.py +0 -0
  68. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_log_redaction.py +0 -0
  69. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_lsp.py +0 -0
  70. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_mcp.py +0 -0
  71. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_mcp_oauth.py +0 -0
  72. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_mlx_fastpath.py +0 -0
  73. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_mlx_qsdpa.py +0 -0
  74. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_plan_review.py +0 -0
  75. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_prove.py +0 -0
  76. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_render.py +0 -0
  77. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_replace_lines.py +0 -0
  78. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_repomap.py +0 -0
  79. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_session.py +0 -0
  80. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_skills.py +0 -0
  81. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_subagent.py +0 -0
  82. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_symbols.py +0 -0
  83. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_syntaxgate.py +0 -0
  84. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_toolcall_parse.py +0 -0
  85. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_tools.py +0 -0
  86. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_tui.py +0 -0
  87. {chad_code-1.0.2 → chad_code-1.0.3}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.2
3
+ Version: 1.0.3
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
@@ -39,7 +39,7 @@ Dynamic: license-file
39
39
 
40
40
  [![tests](https://github.com/nathansutton/chad/actions/workflows/tests.yml/badge.svg)](https://github.com/nathansutton/chad/actions/workflows/tests.yml)
41
41
 
42
- <img src="docs/tbench-size-vs-score.png" width="840" alt="Terminal-Bench 2.0: accuracy vs. model size. chad + Ornith (a 35B MoE) lands on the Claude Sonnet 4.5 line, matching open models many times its size and standing alone in the laptop-class ≤40B band.">
42
+ <img src="docs/tbench-size-vs-score.png" width="840" alt="Terminal-Bench 2.1: accuracy vs. cost per run. Every verified entry is a proprietary frontier model in a datacenter, costing $130–$2,000 per run. chad + Ornith (a 35B MoE) clears 57% on an Apple Silicon laptop for the electricity the only no-API-cost point on the board.">
43
43
 
44
44
  > Claude can do anything, for anyone, anywhere. chad does one thing. 🗿
45
45
  > *Coding under supervision.*
@@ -86,20 +86,20 @@ instrument:
86
86
  > Real session, unedited (cold model load cut): a local 35B reasons through the failure,
87
87
  > edits the file, reruns the tests, confirms green.
88
88
 
89
- ## Sonnet on your laptop
89
+ ## Frontier scores, laptop cost
90
90
 
91
91
  The exam is [Terminal-Bench](https://www.tbench.ai/leaderboard), the standard benchmark for
92
- CLI coding agents. chad won't top it — the A students are frontier models in datacenters.
93
- The number worth looking at is **how much capability chad wrings out of 35B parameters**:
94
- on Terminal-Bench 2.1, chad + Ornith lands around the **Claude Sonnet 4.5** line, matching
95
- open models many times its size and beating every open model in its own weight class by a
96
- wide margin. On a laptop, *capability per parameter* is the axis you actually compete on
97
- and that's where chad + Ornith is out on the frontier alone.
98
-
99
- > **Placeholder number** — the full verified run is still in flight (`≈50%`, k=1). The whole
100
- > benchmark is **publicly reproducible from a Mac**: the exact Harbor adapter, the runner,
101
- > and the recipe live in [`benchmarks/tb2/`](benchmarks/tb2/README.md). Check it, don't
102
- > trust it.
92
+ CLI coding agents. chad won't top it — every verified entry on Terminal-Bench 2.1 is a
93
+ frontier model in a datacenter, scoring 59–84%. The number worth looking at is what that
94
+ capability **costs**: the paid field spends **$130–$2,000 in API fees per run**. chad +
95
+ Ornith clears **57%** on an Apple Silicon laptop, for the electricity no API, no tokens,
96
+ no datacenter. On a laptop, *capability per dollar* is the axis you actually compete on, and
97
+ chad is the only point on the board that runs there.
98
+
99
+ > **Provisional number** — `57%` (51/89, k=1, self-run, not yet leaderboard-verified). The
100
+ > whole benchmark is **publicly reproducible from a Mac**: the exact Harbor adapter, the
101
+ > runner, and the recipe live in [`benchmarks/tb2/`](benchmarks/tb2/README.md). Check it,
102
+ > don't trust it.
103
103
 
104
104
  ## The bet: at this end of the report card, the harness beats the model
105
105
 
@@ -214,7 +214,7 @@ Both are covered in full in the [Configuration reference](docs/configuration.md)
214
214
  cache, the trimmable/append-only trade, and the ideas borrowed from other agents.
215
215
  - **[Throughput & performance](docs/benchmarks.md)** — prefill / decode / warm-step numbers
216
216
  you can reproduce with `chad-bench`.
217
- - **[Terminal-Bench 2.0 reproduction](benchmarks/tb2/README.md)** — the exact Harbor adapter
217
+ - **[Terminal-Bench 2.1 reproduction](benchmarks/tb2/README.md)** — the exact Harbor adapter
218
218
  and runner behind the chart; serve Ornith yourself and check the number.
219
219
  - **[Configuration reference](docs/configuration.md)** — Agent Skills, MCP servers, the
220
220
  context window, every environment variable, and the safety opt-outs.
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![tests](https://github.com/nathansutton/chad/actions/workflows/tests.yml/badge.svg)](https://github.com/nathansutton/chad/actions/workflows/tests.yml)
4
4
 
5
- <img src="docs/tbench-size-vs-score.png" width="840" alt="Terminal-Bench 2.0: accuracy vs. model size. chad + Ornith (a 35B MoE) lands on the Claude Sonnet 4.5 line, matching open models many times its size and standing alone in the laptop-class ≤40B band.">
5
+ <img src="docs/tbench-size-vs-score.png" width="840" alt="Terminal-Bench 2.1: accuracy vs. cost per run. Every verified entry is a proprietary frontier model in a datacenter, costing $130–$2,000 per run. chad + Ornith (a 35B MoE) clears 57% on an Apple Silicon laptop for the electricity the only no-API-cost point on the board.">
6
6
 
7
7
  > Claude can do anything, for anyone, anywhere. chad does one thing. 🗿
8
8
  > *Coding under supervision.*
@@ -49,20 +49,20 @@ instrument:
49
49
  > Real session, unedited (cold model load cut): a local 35B reasons through the failure,
50
50
  > edits the file, reruns the tests, confirms green.
51
51
 
52
- ## Sonnet on your laptop
52
+ ## Frontier scores, laptop cost
53
53
 
54
54
  The exam is [Terminal-Bench](https://www.tbench.ai/leaderboard), the standard benchmark for
55
- CLI coding agents. chad won't top it — the A students are frontier models in datacenters.
56
- The number worth looking at is **how much capability chad wrings out of 35B parameters**:
57
- on Terminal-Bench 2.1, chad + Ornith lands around the **Claude Sonnet 4.5** line, matching
58
- open models many times its size and beating every open model in its own weight class by a
59
- wide margin. On a laptop, *capability per parameter* is the axis you actually compete on
60
- and that's where chad + Ornith is out on the frontier alone.
61
-
62
- > **Placeholder number** — the full verified run is still in flight (`≈50%`, k=1). The whole
63
- > benchmark is **publicly reproducible from a Mac**: the exact Harbor adapter, the runner,
64
- > and the recipe live in [`benchmarks/tb2/`](benchmarks/tb2/README.md). Check it, don't
65
- > trust it.
55
+ CLI coding agents. chad won't top it — every verified entry on Terminal-Bench 2.1 is a
56
+ frontier model in a datacenter, scoring 59–84%. The number worth looking at is what that
57
+ capability **costs**: the paid field spends **$130–$2,000 in API fees per run**. chad +
58
+ Ornith clears **57%** on an Apple Silicon laptop, for the electricity no API, no tokens,
59
+ no datacenter. On a laptop, *capability per dollar* is the axis you actually compete on, and
60
+ chad is the only point on the board that runs there.
61
+
62
+ > **Provisional number** — `57%` (51/89, k=1, self-run, not yet leaderboard-verified). The
63
+ > whole benchmark is **publicly reproducible from a Mac**: the exact Harbor adapter, the
64
+ > runner, and the recipe live in [`benchmarks/tb2/`](benchmarks/tb2/README.md). Check it,
65
+ > don't trust it.
66
66
 
67
67
  ## The bet: at this end of the report card, the harness beats the model
68
68
 
@@ -177,7 +177,7 @@ Both are covered in full in the [Configuration reference](docs/configuration.md)
177
177
  cache, the trimmable/append-only trade, and the ideas borrowed from other agents.
178
178
  - **[Throughput & performance](docs/benchmarks.md)** — prefill / decode / warm-step numbers
179
179
  you can reproduce with `chad-bench`.
180
- - **[Terminal-Bench 2.0 reproduction](benchmarks/tb2/README.md)** — the exact Harbor adapter
180
+ - **[Terminal-Bench 2.1 reproduction](benchmarks/tb2/README.md)** — the exact Harbor adapter
181
181
  and runner behind the chart; serve Ornith yourself and check the number.
182
182
  - **[Configuration reference](docs/configuration.md)** — Agent Skills, MCP servers, the
183
183
  context window, every environment variable, and the safety opt-outs.
@@ -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.2"
7
+ version = "1.0.3"
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"
@@ -786,6 +786,13 @@ class Agent:
786
786
  gate_nudges = 0 # times the investigation->edit gate fired this turn
787
787
  subagent_sigs = set() # (description, prompt) of sub-agents already spawned this turn
788
788
  truncation_nudges = 0 # times we pushed past a token-cap truncation this turn
789
+ garble_nudges = 0 # malformed-tool-call re-nudges (own counter, so a step-0
790
+ # cap-hit can't spend the garble recovery budget)
791
+ consecutive_garbles = 0 # back-to-back garbled steps (drives exemplar + scrub)
792
+ last_garble_idx = None # self.messages index of the previous garbled assistant
793
+ # message (scrubbed if the next step garbles too)
794
+ done_audit_bounces = 0 # total audit bounces this turn (hard cap 2)
795
+ audit_absent_list = [] # task-named paths the FIRST audit stat'ed as absent
789
796
  landing_nudges = 0 # one-shot "you're out of steps, land the edit" near the cap
790
797
  consecutive_failed_bash = 0 # back-to-back errored bash with no edit (thrash)
791
798
  thrash_nudges = 0
@@ -1400,12 +1407,33 @@ class Agent:
1400
1407
  _stripped_for_markers = strip_think(text)
1401
1408
  garbled = ("<tool_call>" in _stripped_for_markers
1402
1409
  or "</function>" in _stripped_for_markers
1403
- or "<function=" in _stripped_for_markers)
1410
+ or "<function=" in _stripped_for_markers
1411
+ or _has_open_tool_call(text))
1412
+ if garbled:
1413
+ consecutive_garbles += 1
1414
+ # From the 2nd consecutive garble, scrub the PREVIOUS garbled
1415
+ # assistant body: the wrong dialect must not stay in context as a
1416
+ # few-shot example (repeated garbles in a row each condition on the
1417
+ # last). Costs one prefix-cache invalidation on this rare path.
1418
+ if (levers.enabled("garble_never_final")
1419
+ and consecutive_garbles >= 2 and last_garble_idx is not None):
1420
+ self.messages[last_garble_idx]["content"] = \
1421
+ guardrails.GARBLE_SCRUBBED
1422
+ log.info("GARBLE scrub at step %d: previous garbled message "
1423
+ "body removed from context (consecutive=%d)",
1424
+ step, consecutive_garbles)
1425
+ last_garble_idx = len(self.messages) - 1
1426
+ else:
1427
+ consecutive_garbles = 0
1404
1428
  kind, nudge = guardrails.nudge_for_no_calls(
1405
1429
  text, hit_cap, made_edit, unverified_edit, read_only_intent,
1406
1430
  action_task or run_task, truncation_nudges, answer_nudges,
1407
- verify_nudges, _has_open_tool_call(text), garbled_call=garbled)
1408
- if kind == "truncated":
1431
+ verify_nudges, _has_open_tool_call(text), garbled_call=garbled,
1432
+ garble_nudges=garble_nudges,
1433
+ consecutive_garbles=consecutive_garbles)
1434
+ if kind == "garble":
1435
+ garble_nudges += 1
1436
+ elif kind == "truncated":
1409
1437
  truncation_nudges += 1
1410
1438
  elif kind == "no-edit":
1411
1439
  answer_nudges += 1
@@ -1417,6 +1445,21 @@ class Agent:
1417
1445
  has_code, action_task, run_task)
1418
1446
  self.messages.append({"role": "tool", "name": "edit", "content": nudge})
1419
1447
  continue
1448
+ # Invariant: a step whose text contains tool-call markers is NEVER a
1449
+ # final answer — the model was trying to act, not to finish. With the
1450
+ # nudge budget spent, hard-stop with a banked note instead of accepting
1451
+ # the garble (otherwise a run of garbles can burn the shared counter and
1452
+ # the audit latch, and the last one ships as the answer with most of the
1453
+ # wall budget still unspent).
1454
+ if garbled and levers.enabled("garble_never_final"):
1455
+ self.budget_note = guardrails.progress_note(self.messages)
1456
+ log.info("END step %d: GARBLE hard-stop (%d garble nudges spent) — "
1457
+ "a garbled tool call is never a final answer",
1458
+ step, garble_nudges)
1459
+ self._emit("info", " [turn stopped: repeated malformed tool calls "
1460
+ "— progress note banked; say 'continue' to retry]")
1461
+ return ("[stopped: the model kept emitting malformed tool calls "
1462
+ "— say 'continue' to resume]")
1420
1463
  # Iter-3 did-nothing gate: in auto/headless mode (every benchmark run), a
1421
1464
  # turn that ends having executed ZERO real tools is never a legitimate
1422
1465
  # completion — the keyword intent classifier misses tasks like "extract the
@@ -1465,6 +1508,8 @@ class Agent:
1465
1508
  })
1466
1509
  if audit:
1467
1510
  done_audit_fired = True
1511
+ done_audit_bounces += 1
1512
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1468
1513
  _runway = ((self._turn_budget_s
1469
1514
  - (time.monotonic() - turn_start))
1470
1515
  if self._turn_budget_s else float("inf"))
@@ -1474,6 +1519,26 @@ class Agent:
1474
1519
  self.messages.append({"role": "tool", "name": "edit",
1475
1520
  "content": audit})
1476
1521
  continue
1522
+ # Absent-path re-bounce: the first audit stat'ed task-named paths as
1523
+ # ABSENT and promised acceptance anyway; if they are STILL absent with
1524
+ # runway to spare, bounce one final time (cap 2/turn) — otherwise a
1525
+ # task-named deliverable the audit already flagged absent can go
1526
+ # unwritten and the accept still goes through.
1527
+ if self.mode != "plan" and not self._subagent \
1528
+ and done_audit_fired and done_audit_bounces < 2 \
1529
+ and audit_absent_list:
1530
+ _runway = ((self._turn_budget_s
1531
+ - (time.monotonic() - turn_start))
1532
+ if self._turn_budget_s else None)
1533
+ rebounce = guardrails.audit_rebounce(audit_absent_list, _runway)
1534
+ if rebounce:
1535
+ done_audit_bounces += 1
1536
+ audit_absent_list = []
1537
+ log.info("DONE-AUDIT rebounce (final-answer): still-absent "
1538
+ "task paths, runway=%s", _runway)
1539
+ self.messages.append({"role": "tool", "name": "edit",
1540
+ "content": rebounce})
1541
+ continue
1477
1542
  log.info("END step %d: model produced a FINAL ANSWER, no tool calls "
1478
1543
  "(did_work=%s, made_edit=%s, unverified_edit=%s)",
1479
1544
  step, did_work, made_edit, unverified_edit)
@@ -1483,6 +1548,8 @@ class Agent:
1483
1548
  # The step acted — the spiral broke on its own, so clear the no-think escalation
1484
1549
  # streak: escalation is only for consecutive capped/salvaged stalls.
1485
1550
  capped_stall_streak = 0
1551
+ consecutive_garbles = 0 # a parsed call ends the garble streak
1552
+ last_garble_idx = None
1486
1553
 
1487
1554
  # Terminal tool -> end the turn cleanly, but enforce verify-before-done
1488
1555
  # (forge's prerequisite idea): if files were changed and nothing has been
@@ -1574,6 +1641,8 @@ class Agent:
1574
1641
  })
1575
1642
  if audit:
1576
1643
  done_audit_fired = True
1644
+ done_audit_bounces += 1
1645
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1577
1646
  _runway = ((self._turn_budget_s
1578
1647
  - (time.monotonic() - turn_start))
1579
1648
  if self._turn_budget_s else float("inf"))
@@ -1582,6 +1651,22 @@ class Agent:
1582
1651
  self.messages.append({"role": "tool", "name": "done",
1583
1652
  "content": audit})
1584
1653
  continue
1654
+ # Absent-path re-bounce (see the final-answer twin above): a
1655
+ # still-absent task-named path at accept time gets ONE more bounce,
1656
+ # then the next done is accepted unconditionally.
1657
+ if done_audit_fired and done_audit_bounces < 2 and audit_absent_list:
1658
+ _runway = ((self._turn_budget_s
1659
+ - (time.monotonic() - turn_start))
1660
+ if self._turn_budget_s else None)
1661
+ rebounce = guardrails.audit_rebounce(audit_absent_list, _runway)
1662
+ if rebounce:
1663
+ done_audit_bounces += 1
1664
+ audit_absent_list = []
1665
+ log.info("DONE-AUDIT rebounce: still-absent task paths, "
1666
+ "runway=%s", _runway)
1667
+ self.messages.append({"role": "tool", "name": "done",
1668
+ "content": rebounce})
1669
+ continue
1585
1670
  # Iter-3 deliverable recheck (levers.done_spec_recheck): one last self-check
1586
1671
  # that the required outputs actually exist at the right path/format before
1587
1672
  # we accept done — the hidden container-end-state verifier gives no second
@@ -1854,7 +1939,18 @@ class Agent:
1854
1939
  if made_edit and not _gov_prev_made:
1855
1940
  readonly_streak = 0
1856
1941
  elif did_work and not made_edit:
1857
- readonly_streak += 1
1942
+ # A bash step that isn't provably read-only is ACTION, not
1943
+ # investigation — `git merge`, `apt-get install`, redirects are ops
1944
+ # progress. Otherwise the gate can count an entire git/ops workflow as
1945
+ # "investigation" and demand an edit at a decision point where there is
1946
+ # nothing to edit yet.
1947
+ if levers.enabled("gate_ops_exempt") and any(
1948
+ n == "bash"
1949
+ and not guardrails.is_readonly_bash(str(a.get("command", "")))
1950
+ for n, a in calls):
1951
+ readonly_streak = 0
1952
+ else:
1953
+ readonly_streak += 1
1858
1954
  if not read_only_intent:
1859
1955
  gate = guardrails.investigation_gate(readonly_streak, made_edit, gate_nudges)
1860
1956
  if gate:
@@ -169,6 +169,96 @@ def reverts_working_tree(command: str) -> bool:
169
169
  return bool(command) and any(p.search(command) for p in _REVERT_PATTERNS)
170
170
 
171
171
 
172
+ # Command heads that only OBSERVE state. Used by the investigation gate: a bash step
173
+ # whose every segment starts with one of these (and redirects nothing to a file) is
174
+ # investigation; anything else — `git merge`, `apt-get install`, `mkdir`, `tar x`,
175
+ # a redirect — is ACTION and must reset the read-only streak (otherwise the gate can
176
+ # count an entire git/ops workflow as "investigation" and demand an edit at a decision
177
+ # point where there is nothing to edit yet).
178
+ _READONLY_HEADS = frozenset((
179
+ "ls", "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg",
180
+ "find", "file", "stat", "wc", "which", "type", "pwd", "echo", "printf", "du",
181
+ "df", "ps", "env", "printenv", "sort", "uniq", "cut", "tr", "diff", "cmp",
182
+ "md5sum", "sha1sum", "sha256sum", "strings", "xxd", "hexdump", "od",
183
+ "readlink", "realpath", "basename", "dirname", "test", "[", "true", "false",
184
+ "date", "whoami", "id", "uname", "hostname", "tree", "awk", "sed", "jq",
185
+ "column", "nl", "tac", "sleep",
186
+ ))
187
+ _READONLY_GIT_SUBS = frozenset((
188
+ "log", "status", "diff", "show", "describe", "rev-parse", "ls-files",
189
+ "ls-remote", "ls-tree", "blame", "reflog", "shortlog", "grep", "cat-file",
190
+ "rev-list", "name-rev", "var", "count-objects",
191
+ ))
192
+ # Harmless stderr plumbing stripped before the "any redirect ⇒ mutating" check.
193
+ _STDERR_REDIR_RE = re.compile(r"2>\s*&1|2>\s*/dev/null|&>\s*/dev/null|>\s*/dev/null")
194
+ _SEGMENT_SPLIT_RE = re.compile(r"\|\||&&|[;|]")
195
+
196
+
197
+ def is_readonly_bash(command: str) -> bool:
198
+ """Conservatively true when a bash command only OBSERVES state: every pipeline
199
+ segment's head is in the read-only allowlist (`cd`/`env`-style prefixes skipped;
200
+ `git <readonly-sub>` allowed; `sed -i` excluded) and nothing is redirected to a
201
+ file. Anything unrecognized is NOT read-only — for the investigation gate that
202
+ is the safe direction (an ops step wrongly counted as investigation harasses the
203
+ model; a read wrongly counted as action merely delays the gate)."""
204
+ if not command.strip():
205
+ return True
206
+ cleaned = _STDERR_REDIR_RE.sub("", command)
207
+ if ">" in cleaned or "<(" in cleaned:
208
+ return False
209
+ for seg in _SEGMENT_SPLIT_RE.split(cleaned):
210
+ words = seg.strip().split()
211
+ # skip wrappers/prefixes that don't decide the verb
212
+ while words and (words[0] == "cd" or "=" in words[0] or words[0] in
213
+ ("env", "sudo", "command", "builtin", "time", "nice")):
214
+ if words[0] == "cd": # `cd x && grep …`: drop `cd` + its argument
215
+ words = words[2:]
216
+ else:
217
+ words = words[1:]
218
+ if not words:
219
+ continue
220
+ head = words[0]
221
+ if head == "git":
222
+ if len(words) < 2 or words[1] not in _READONLY_GIT_SUBS:
223
+ return False
224
+ elif head not in _READONLY_HEADS:
225
+ return False
226
+ elif head == "sed" and any(w.startswith("-i") or w.startswith("--in-place")
227
+ for w in words[1:]):
228
+ return False
229
+ return True
230
+
231
+
232
+ def audit_absent_paths(task_text, paths=None):
233
+ """The task-mentioned paths that do not currently exist — recorded at the first
234
+ done-audit bounce so `audit_rebounce` can later check exactly these (and nothing
235
+ else) at accept time."""
236
+ if paths is None:
237
+ paths = audit_extract_paths(task_text)
238
+ return [p for p in paths if not os.path.exists(p)]
239
+
240
+
241
+ def audit_rebounce(absent_paths, runway_s):
242
+ """The one final done-audit bounce (levers.audit_absent_rebounce), or None to
243
+ accept. Fires only when a path the FIRST audit already reported absent is STILL
244
+ absent and there is real runway left — the miss it closes is a task-named
245
+ deliverable the audit stat'ed as absent, where the acceptance promise forced the
246
+ done through and the file was never written. Caller owns the 2-bounce cap and the
247
+ still-once-per-turn latch semantics."""
248
+ if not levers.enabled("audit_absent_rebounce") or not absent_paths:
249
+ return None
250
+ if runway_s is not None and runway_s <= AUDIT_MIN_RUNWAY_S:
251
+ return None
252
+ still = [p for p in absent_paths if not os.path.exists(p)]
253
+ if not still:
254
+ return None
255
+ return ("[final audit — the task statement names path(s) that still do not "
256
+ "exist:\n" + "\n".join(f" - {p}" for p in still) +
257
+ "\nIf the task requires them, create them at exactly these paths now "
258
+ "(then re-verify). If the task genuinely wants them absent, call done "
259
+ "again. Your next `done` will be accepted unconditionally.]")
260
+
261
+
172
262
  def update_work_flags(name, args, result, did_work, made_edit, unverified_edit):
173
263
  """Update the (did_work, made_edit, unverified_edit) guardrail flags after one
174
264
  tool result; returns the new triple. A substantive tool counts as real work; a
@@ -416,8 +506,17 @@ def done_audit(task_text, turn_state):
416
506
  req_lines = audit_requirement_lines(task_text, paths)
417
507
  if not req_lines:
418
508
  return None
419
- parts = ["[done-audit one-time check before this `done` is accepted; your NEXT "
420
- "`done` will be accepted without further audit. The hidden grader checks "
509
+ # The acceptance promise must stay truthful: with the absent-path re-bounce
510
+ # armed (levers.audit_absent_rebounce) one further existence-only check may run,
511
+ # so say so; otherwise keep the original unconditional promise (the anti-spiral
512
+ # latch depends on the model believing it).
513
+ if levers.enabled("audit_absent_rebounce"):
514
+ promise = ("your NEXT `done` will be accepted after at most one further "
515
+ "check that the paths below exist.")
516
+ else:
517
+ promise = "your NEXT `done` will be accepted without further audit."
518
+ parts = ["[done-audit — one-time check before this `done` is accepted; " + promise
519
+ + " The hidden grader checks "
421
520
  "the task's OWN requirements, not the checks you happened to run. From the "
422
521
  "task statement:"]
423
522
  parts += [f" > {ln}" for ln in req_lines]
@@ -431,48 +530,83 @@ def done_audit(task_text, turn_state):
431
530
  return "\n".join(parts)
432
531
 
433
532
 
533
+ GARBLE_NUDGE_CAP = 6 # per-turn garble re-nudges (own counter, separate from truncation)
534
+
535
+ # Placeholder that replaces a garbled assistant message body once the NEXT step
536
+ # garbles too — the wrong dialect must not stay in context as a few-shot example
537
+ # (repeated garbles in a row each condition on the last). Costs one prefix-cache
538
+ # invalidation on a rare path.
539
+ GARBLE_SCRUBBED = "[a malformed tool call was removed here — it did not run]"
540
+
541
+ # Appended to the garble nudge from the 2nd consecutive garbled step: the model is
542
+ # clearly stuck in a wrong dialect, so show the contract instead of describing it.
543
+ TOOLCALL_EXEMPLAR = (
544
+ "\nThis is the ONLY valid format — copy its shape exactly:\n"
545
+ "<tool_call>\n"
546
+ '{"name": "bash", "arguments": {"command": "ls /app"}}\n'
547
+ "</tool_call>\n"
548
+ "No <function=...>, no <parameter=...> tags, nothing else inside the block.")
549
+
550
+
434
551
  def nudge_for_no_calls(text, hit_cap, made_edit, unverified_edit, read_only_intent,
435
552
  action_task, truncation_nudges, answer_nudges, verify_nudges,
436
- open_tool_call, garbled_call=False):
553
+ open_tool_call, garbled_call=False, garble_nudges=0,
554
+ consecutive_garbles=0):
437
555
  """Pick the nudge for a step that produced NO tool call, in the original priority
438
- order: (1) TRUNCATED — hit the token cap mid-thought, so it isn't an answer;
439
- (2) ANSWERED ON PAPER produced/described code but never applied it; (3) UNVERIFIED
440
- EDIT edited but never ran the check. Pure: returns (kind, nudge_text) or
441
- (None, None); the caller bumps the matching counter and appends the nudge. `kind`
442
- is one of 'truncated' / 'no-edit' / 'unverified-edit'. (open_tool_call is run_turn's
443
- _has_open_tool_call(text): an unbalanced <tool_call>/<function> that parsed to nothing.)"""
556
+ order: (1) GARBLE/TRUNCATED — a tool-call attempt that parsed to nothing, or the
557
+ token cap hit mid-thought, so it isn't an answer; (2) ANSWERED ON PAPER —
558
+ produced/described code but never applied it; (3) UNVERIFIED EDIT edited but
559
+ never ran the check. Pure: returns (kind, nudge_text) or (None, None); the caller
560
+ bumps the matching counter and appends the nudge. `kind` is one of 'garble' /
561
+ 'truncated' / 'no-edit' / 'unverified-edit'. (open_tool_call is run_turn's
562
+ _has_open_tool_call(text): an unbalanced <tool_call>/<function> that parsed to
563
+ nothing.)"""
444
564
  has_code = "```" in text
445
- # An UNBALANCED tool-call attempt that parsed to zero calls is never a final answer,
446
- # whether or not the token cap was hit. Two causes: (a) hit_cap — a `write` whose
447
- # content overran the budget (guide it to write in bounded pieces); (b) NOT hit_cap
448
- # the model stopped mid-call for another reason (sampling glitch / premature EOS, e.g.
449
- # TB2 vulnerable-secret died at 45s of 900 on a 28-token `{"name":"bash>` garble that
450
- # the old code accepted as the final answer). Bounded by truncation_nudges. This fires
451
- # before the bare-stall branch so a garbled call isn't misread as an empty stall.
452
- if (open_tool_call or garbled_call) and truncation_nudges < 2:
453
- if open_tool_call and hit_cap:
565
+ # A tool-call attempt that parsed to zero calls is never a final answer, whether
566
+ # or not the token cap was hit. Causes: (a) hit_cap — a `write` whose content
567
+ # overran the budget (guide it to write in bounded pieces); (b) an unclosed or
568
+ # closed-but-unparseable block sampling glitch, premature EOS, or a slide into
569
+ # a foreign XML tool-call dialect. With the garble_never_final lever these use
570
+ # their OWN counter (garble_nudges, cap 6) a shared `truncation_nudges < 2` lets
571
+ # an unrelated step-0 cap-hit spend the garble budget, after which a run of garbles
572
+ # can be ACCEPTED as the final answer with most of the wall budget still left. This
573
+ # fires before the bare-stall branch so a garbled call isn't misread as an empty
574
+ # stall.
575
+ if open_tool_call and hit_cap:
576
+ # A call cut off AT the token cap is a length problem (a too-long `write`),
577
+ # not a dialect garble — it stays in the truncation family/counter.
578
+ if truncation_nudges < 2:
454
579
  nudge = ("[your tool call was cut off at the length limit — the "
455
580
  "content was too long to emit in one call. Do NOT retry it "
456
581
  "whole. Create the file with `write` using only the FIRST "
457
582
  "portion of the content, then append the rest with one or "
458
583
  "more `edit` calls. Emit one complete tool call at a time.]")
459
- elif open_tool_call:
460
- nudge = ("[your last tool call was malformed and did not run — it opened a "
461
- "<tool_call> (or <function=…>) that was never properly closed, so no "
462
- "tool executed and nothing happened. Re-emit it now as ONE complete, "
463
- "well-formed <tool_call> block with valid JSON arguments.]")
464
- else:
465
- # garbled_call: the block WAS closed but nothing inside parsed — mixed
466
- # JSON/XML dialects, invalid JSON the repair pass couldn't reconstruct,
467
- # etc. Without this branch the garble is accepted as a final answer
468
- # (TB2 count-dataset-tokens, 2026-07-12: `{"name": "bash", …
469
- # </parameter></function></tool_call>` ended the task with budget left).
470
- nudge = ("[your last tool call was malformed and did not run — the "
471
- "<tool_call> block did not contain one valid JSON object, so no "
472
- "tool executed and nothing happened. Re-emit it now as ONE "
473
- "complete <tool_call> block: a single JSON object with \"name\" "
474
- "and \"arguments\", no XML tags inside.]")
475
- return "truncated", nudge
584
+ return "truncated", nudge
585
+ elif open_tool_call or garbled_call:
586
+ if levers.enabled("garble_never_final"):
587
+ allowed, kind = garble_nudges < GARBLE_NUDGE_CAP, "garble"
588
+ else: # legacy (ablation arm): shared counter, kind counted as truncation
589
+ allowed, kind = truncation_nudges < 2, "truncated"
590
+ if allowed:
591
+ if open_tool_call:
592
+ nudge = ("[your last tool call was malformed and did not run — it opened a "
593
+ "<tool_call> (or <function=…>) that was never properly closed, so no "
594
+ "tool executed and nothing happened. Re-emit it now as ONE complete, "
595
+ "well-formed <tool_call> block with valid JSON arguments.]")
596
+ else:
597
+ # garbled_call: the block WAS closed but nothing inside parsed — mixed
598
+ # JSON/XML dialects, invalid JSON the repair pass couldn't reconstruct,
599
+ # etc. Without this branch the garble is accepted as a final answer —
600
+ # e.g. a `{"name": "bash", … </parameter></function></tool_call>` mash
601
+ # that ends the task with budget still left.
602
+ nudge = ("[your last tool call was malformed and did not run — the "
603
+ "<tool_call> block did not contain one valid JSON object, so no "
604
+ "tool executed and nothing happened. Re-emit it now as ONE "
605
+ "complete <tool_call> block: a single JSON object with \"name\" "
606
+ "and \"arguments\", no XML tags inside.]")
607
+ if kind == "garble" and consecutive_garbles >= 2:
608
+ nudge = nudge[:-1] + TOOLCALL_EXEMPLAR + "]"
609
+ return kind, nudge
476
610
  if hit_cap and truncation_nudges < 2:
477
611
  # Cap hit but the call (if any) was balanced — a plain mid-thought truncation.
478
612
  nudge = ("[your reply was cut off at the length limit before you "
@@ -277,6 +277,38 @@ LEVERS: dict[str, Lever] = {
277
277
  "Only active with a wall budget configured; inert in interactive/unmetered runs.",
278
278
  "iter12"),
279
279
 
280
+ # --- iter-13: lever *interactions*. Two otherwise-winnable tasks were lost not
281
+ # to the model but to guardrails fighting each other — a garbled tool
282
+ # call accepted as the final answer once the shared nudge counter and the
283
+ # done-audit latch were both spent, and the investigation gate counting a
284
+ # `git merge`/`apt-get` ops workflow as "investigation" and demanding an edit
285
+ # at a point where there was nothing yet to edit. --------------------------
286
+ "garble_never_final": Lever(
287
+ "A step whose text contains tool-call markers is NEVER accepted as a final "
288
+ "answer and never reaches the done-audit final-answer twin: garbles get their "
289
+ "own nudge counter (cap 6, not shared with cap-truncations), a canonical "
290
+ "call exemplar from the 2nd consecutive garble, a scrub of the previous "
291
+ "garbled message body (so the model stops few-shotting its own broken "
292
+ "dialect), and — if nudges run out — a banked-note hard stop. OFF restores "
293
+ "the shared-counter failure: a run of garbles burns the shared counter + "
294
+ "audit latch and the last is accepted as the answer with most of the wall "
295
+ "budget still left.",
296
+ "iter13", REGRESSION_GUARD),
297
+ "audit_absent_rebounce": Lever(
298
+ "When the done-audit's first bounce stat'ed task-mentioned paths as ABSENT "
299
+ "and they are STILL absent at the accepting done/final-answer (runway > "
300
+ "120s), bounce one final time naming only the missing paths; the next done "
301
+ "is then accepted unconditionally (hard cap 2 bounces/turn). The first "
302
+ "audit's acceptance promise is reworded to stay truthful while this is on.",
303
+ "iter13"),
304
+ "gate_ops_exempt": Lever(
305
+ "A bash step whose command is not provably read-only counts as ACTION for "
306
+ "the investigation gate (resets the read-only streak): `git merge`, "
307
+ "`apt-get install`, redirects etc. are ops progress, not investigation. "
308
+ "The gate keeps firing on genuine grep/read loops. OFF restores the gate "
309
+ "harassing ops-heavy tasks that legitimately run many non-read commands.",
310
+ "iter13"),
311
+
280
312
  # --- from the LangChain harness-tuning playbook. -------------------------------
281
313
  "compact_notice": Lever(
282
314
  "After compaction, inject an in-band message telling the model its context was "