chad-code 1.0.2__tar.gz → 1.0.4__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.4}/PKG-INFO +15 -15
  2. {chad_code-1.0.2 → chad_code-1.0.4}/README.md +14 -14
  3. {chad_code-1.0.2 → chad_code-1.0.4}/pyproject.toml +1 -1
  4. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/agent.py +130 -9
  5. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/config.py +15 -0
  6. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/diag.py +10 -7
  7. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/guardrails.py +230 -35
  8. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/levers.py +69 -0
  9. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/prompt.py +36 -4
  10. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/tools.py +34 -6
  11. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/tui.py +4 -3
  12. {chad_code-1.0.2 → chad_code-1.0.4/src/chad_code.egg-info}/PKG-INFO +15 -15
  13. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad_code.egg-info/SOURCES.txt +1 -0
  14. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_agent_guards.py +144 -4
  15. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_done_audit.py +97 -0
  16. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_edit.py +35 -0
  17. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_feel_pack.py +20 -4
  18. chad_code-1.0.4/tests/test_garble_invariant.py +86 -0
  19. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_lever_bite.py +102 -6
  20. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_log_redaction.py +38 -13
  21. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_subagent.py +8 -1
  22. {chad_code-1.0.2 → chad_code-1.0.4}/LICENSE +0 -0
  23. {chad_code-1.0.2 → chad_code-1.0.4}/setup.cfg +0 -0
  24. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/__init__.py +0 -0
  25. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/atif.py +0 -0
  26. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/base_engine.py +0 -0
  27. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/bench.py +0 -0
  28. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/cli.py +0 -0
  29. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/compaction.py +0 -0
  30. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/completion_engine.py +0 -0
  31. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/engine.py +0 -0
  32. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/ignore.py +0 -0
  33. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/lsp.py +0 -0
  34. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/mcp.py +0 -0
  35. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/mcp_oauth.py +0 -0
  36. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/mlx_fastpath.py +0 -0
  37. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/mlx_qsdpa.py +0 -0
  38. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/profiles.py +0 -0
  39. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/prove.py +0 -0
  40. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/render.py +0 -0
  41. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/repomap.py +0 -0
  42. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/session.py +0 -0
  43. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/skills.py +0 -0
  44. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/symbols.py +0 -0
  45. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/syntaxgate.py +0 -0
  46. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/toolcall_parse.py +0 -0
  47. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad/validate.py +0 -0
  48. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad_code.egg-info/dependency_links.txt +0 -0
  49. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad_code.egg-info/entry_points.txt +0 -0
  50. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad_code.egg-info/requires.txt +0 -0
  51. {chad_code-1.0.2 → chad_code-1.0.4}/src/chad_code.egg-info/top_level.txt +0 -0
  52. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_agent.py +0 -0
  53. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_agent_e2e.py +0 -0
  54. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_atif.py +0 -0
  55. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_bench.py +0 -0
  56. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_cli.py +0 -0
  57. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_compact_notice.py +0 -0
  58. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_compaction.py +0 -0
  59. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_completion_engine.py +0 -0
  60. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_config.py +0 -0
  61. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_confirm_preview.py +0 -0
  62. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_drift_warn.py +0 -0
  63. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_edit_corruption.py +0 -0
  64. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_engine.py +0 -0
  65. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_engine_kvquant.py +0 -0
  66. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_gate.py +0 -0
  67. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_ignore.py +0 -0
  68. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_intent.py +0 -0
  69. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_levers.py +0 -0
  70. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_lsp.py +0 -0
  71. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_mcp.py +0 -0
  72. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_mcp_oauth.py +0 -0
  73. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_mlx_fastpath.py +0 -0
  74. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_mlx_qsdpa.py +0 -0
  75. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_plan_review.py +0 -0
  76. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_prove.py +0 -0
  77. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_render.py +0 -0
  78. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_replace_lines.py +0 -0
  79. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_repomap.py +0 -0
  80. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_session.py +0 -0
  81. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_skills.py +0 -0
  82. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_symbols.py +0 -0
  83. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_syntaxgate.py +0 -0
  84. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_toolcall_parse.py +0 -0
  85. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_tools.py +0 -0
  86. {chad_code-1.0.2 → chad_code-1.0.4}/tests/test_tui.py +0 -0
  87. {chad_code-1.0.2 → chad_code-1.0.4}/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.4
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.4"
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"
@@ -435,6 +435,11 @@ class Agent:
435
435
  self.draft_accepted = 0
436
436
  self.think_tokens = 0 # tokens spent inside <think> blocks (reasoning overhead)
437
437
  self.think_capped = 0 # times the soft think-cap force-closed a step
438
+ # Real tool dispatches this agent has executed (fn actually ran — excludes
439
+ # terminal `done`, validation rejects, and harness-injected nudge messages,
440
+ # which reuse real tool names in the transcript). The evidence signal behind
441
+ # the sub-agent zero-evidence warning in _run_subagent.
442
+ self.tool_dispatches = 0
438
443
  # prefill accounting: the master cost for a local model is how many *new*
439
444
  # tokens it has to prefill across a turn (context bloat -> big prefills).
440
445
  # This is the metric symbolic/repo-map retrieval is meant to shrink.
@@ -689,12 +694,22 @@ class Agent:
689
694
  elif (not result or result.startswith("[stopped:")
690
695
  or result.startswith("[task failed:")):
691
696
  result = _salvage(result or "[task returned nothing]")
692
- # tool_calls (not `forwards`, a speculative-decoding counter that is 0 on the
693
- # normal path) is the number that diagnoses a sub-agent returning nothing: it
694
- # separates "never got to search" from "searched and lost its findings".
695
- log.info("TASK end | desc=%r | %.1fs | tool_calls=%d gen=%d prefill=%d | -> %s",
697
+ else:
698
+ # A confident, non-empty report produced with ZERO tool dispatches came
699
+ # from model memory, not this repo the one sub-agent failure the empty/
700
+ # crashed salvage above cannot see, and the most dangerous fold-back: it
701
+ # reads as evidence (guardrails.subagent_evidence_warning).
702
+ warned = guardrails.subagent_evidence_warning(result, sub.tool_dispatches)
703
+ if warned is not None:
704
+ log.info("TASK zero-evidence warning appended | desc=%r", description)
705
+ result = warned
706
+ # tool_dispatches (not a transcript count — harness nudges reuse real tool names
707
+ # in tool-role messages) is the number that diagnoses a sub-agent returning
708
+ # nothing: it separates "never got to search" from "searched and lost its
709
+ # findings", and 0 with a confident report is the answered-from-memory tell.
710
+ log.info("TASK end | desc=%r | %.1fs | tool_dispatches=%d gen=%d prefill=%d | -> %s",
696
711
  description, time.perf_counter() - _t0,
697
- sum(1 for m in sub.messages if m.get("role") == "tool") if sub else 0,
712
+ sub.tool_dispatches if sub else 0,
698
713
  sub.gen_tokens if sub else 0,
699
714
  sub.prefill_tokens if sub else 0, result_preview(result or ""))
700
715
  return result or "[task returned nothing]"
@@ -786,6 +801,13 @@ class Agent:
786
801
  gate_nudges = 0 # times the investigation->edit gate fired this turn
787
802
  subagent_sigs = set() # (description, prompt) of sub-agents already spawned this turn
788
803
  truncation_nudges = 0 # times we pushed past a token-cap truncation this turn
804
+ garble_nudges = 0 # malformed-tool-call re-nudges (own counter, so a step-0
805
+ # cap-hit can't spend the garble recovery budget)
806
+ consecutive_garbles = 0 # back-to-back garbled steps (drives exemplar + scrub)
807
+ last_garble_idx = None # self.messages index of the previous garbled assistant
808
+ # message (scrubbed if the next step garbles too)
809
+ done_audit_bounces = 0 # total audit bounces this turn (hard cap 2)
810
+ audit_absent_list = [] # task-named paths the FIRST audit stat'ed as absent
789
811
  landing_nudges = 0 # one-shot "you're out of steps, land the edit" near the cap
790
812
  consecutive_failed_bash = 0 # back-to-back errored bash with no edit (thrash)
791
813
  thrash_nudges = 0
@@ -1400,12 +1422,33 @@ class Agent:
1400
1422
  _stripped_for_markers = strip_think(text)
1401
1423
  garbled = ("<tool_call>" in _stripped_for_markers
1402
1424
  or "</function>" in _stripped_for_markers
1403
- or "<function=" in _stripped_for_markers)
1425
+ or "<function=" in _stripped_for_markers
1426
+ or _has_open_tool_call(text))
1427
+ if garbled:
1428
+ consecutive_garbles += 1
1429
+ # From the 2nd consecutive garble, scrub the PREVIOUS garbled
1430
+ # assistant body: the wrong dialect must not stay in context as a
1431
+ # few-shot example (repeated garbles in a row each condition on the
1432
+ # last). Costs one prefix-cache invalidation on this rare path.
1433
+ if (levers.enabled("garble_never_final")
1434
+ and consecutive_garbles >= 2 and last_garble_idx is not None):
1435
+ self.messages[last_garble_idx]["content"] = \
1436
+ guardrails.GARBLE_SCRUBBED
1437
+ log.info("GARBLE scrub at step %d: previous garbled message "
1438
+ "body removed from context (consecutive=%d)",
1439
+ step, consecutive_garbles)
1440
+ last_garble_idx = len(self.messages) - 1
1441
+ else:
1442
+ consecutive_garbles = 0
1404
1443
  kind, nudge = guardrails.nudge_for_no_calls(
1405
1444
  text, hit_cap, made_edit, unverified_edit, read_only_intent,
1406
1445
  action_task or run_task, truncation_nudges, answer_nudges,
1407
- verify_nudges, _has_open_tool_call(text), garbled_call=garbled)
1408
- if kind == "truncated":
1446
+ verify_nudges, _has_open_tool_call(text), garbled_call=garbled,
1447
+ garble_nudges=garble_nudges,
1448
+ consecutive_garbles=consecutive_garbles)
1449
+ if kind == "garble":
1450
+ garble_nudges += 1
1451
+ elif kind == "truncated":
1409
1452
  truncation_nudges += 1
1410
1453
  elif kind == "no-edit":
1411
1454
  answer_nudges += 1
@@ -1417,6 +1460,21 @@ class Agent:
1417
1460
  has_code, action_task, run_task)
1418
1461
  self.messages.append({"role": "tool", "name": "edit", "content": nudge})
1419
1462
  continue
1463
+ # Invariant: a step whose text contains tool-call markers is NEVER a
1464
+ # final answer — the model was trying to act, not to finish. With the
1465
+ # nudge budget spent, hard-stop with a banked note instead of accepting
1466
+ # the garble (otherwise a run of garbles can burn the shared counter and
1467
+ # the audit latch, and the last one ships as the answer with most of the
1468
+ # wall budget still unspent).
1469
+ if garbled and levers.enabled("garble_never_final"):
1470
+ self.budget_note = guardrails.progress_note(self.messages)
1471
+ log.info("END step %d: GARBLE hard-stop (%d garble nudges spent) — "
1472
+ "a garbled tool call is never a final answer",
1473
+ step, garble_nudges)
1474
+ self._emit("info", " [turn stopped: repeated malformed tool calls "
1475
+ "— progress note banked; say 'continue' to retry]")
1476
+ return ("[stopped: the model kept emitting malformed tool calls "
1477
+ "— say 'continue' to resume]")
1420
1478
  # Iter-3 did-nothing gate: in auto/headless mode (every benchmark run), a
1421
1479
  # turn that ends having executed ZERO real tools is never a legitimate
1422
1480
  # completion — the keyword intent classifier misses tasks like "extract the
@@ -1465,6 +1523,8 @@ class Agent:
1465
1523
  })
1466
1524
  if audit:
1467
1525
  done_audit_fired = True
1526
+ done_audit_bounces += 1
1527
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1468
1528
  _runway = ((self._turn_budget_s
1469
1529
  - (time.monotonic() - turn_start))
1470
1530
  if self._turn_budget_s else float("inf"))
@@ -1474,6 +1534,26 @@ class Agent:
1474
1534
  self.messages.append({"role": "tool", "name": "edit",
1475
1535
  "content": audit})
1476
1536
  continue
1537
+ # Absent-path re-bounce: the first audit stat'ed task-named paths as
1538
+ # ABSENT and promised acceptance anyway; if they are STILL absent with
1539
+ # runway to spare, bounce one final time (cap 2/turn) — otherwise a
1540
+ # task-named deliverable the audit already flagged absent can go
1541
+ # unwritten and the accept still goes through.
1542
+ if self.mode != "plan" and not self._subagent \
1543
+ and done_audit_fired and done_audit_bounces < 2 \
1544
+ and audit_absent_list:
1545
+ _runway = ((self._turn_budget_s
1546
+ - (time.monotonic() - turn_start))
1547
+ if self._turn_budget_s else None)
1548
+ rebounce = guardrails.audit_rebounce(audit_absent_list, _runway)
1549
+ if rebounce:
1550
+ done_audit_bounces += 1
1551
+ audit_absent_list = []
1552
+ log.info("DONE-AUDIT rebounce (final-answer): still-absent "
1553
+ "task paths, runway=%s", _runway)
1554
+ self.messages.append({"role": "tool", "name": "edit",
1555
+ "content": rebounce})
1556
+ continue
1477
1557
  log.info("END step %d: model produced a FINAL ANSWER, no tool calls "
1478
1558
  "(did_work=%s, made_edit=%s, unverified_edit=%s)",
1479
1559
  step, did_work, made_edit, unverified_edit)
@@ -1483,6 +1563,8 @@ class Agent:
1483
1563
  # The step acted — the spiral broke on its own, so clear the no-think escalation
1484
1564
  # streak: escalation is only for consecutive capped/salvaged stalls.
1485
1565
  capped_stall_streak = 0
1566
+ consecutive_garbles = 0 # a parsed call ends the garble streak
1567
+ last_garble_idx = None
1486
1568
 
1487
1569
  # Terminal tool -> end the turn cleanly, but enforce verify-before-done
1488
1570
  # (forge's prerequisite idea): if files were changed and nothing has been
@@ -1574,6 +1656,8 @@ class Agent:
1574
1656
  })
1575
1657
  if audit:
1576
1658
  done_audit_fired = True
1659
+ done_audit_bounces += 1
1660
+ audit_absent_list = guardrails.audit_absent_paths(audit_task)
1577
1661
  _runway = ((self._turn_budget_s
1578
1662
  - (time.monotonic() - turn_start))
1579
1663
  if self._turn_budget_s else float("inf"))
@@ -1582,6 +1666,22 @@ class Agent:
1582
1666
  self.messages.append({"role": "tool", "name": "done",
1583
1667
  "content": audit})
1584
1668
  continue
1669
+ # Absent-path re-bounce (see the final-answer twin above): a
1670
+ # still-absent task-named path at accept time gets ONE more bounce,
1671
+ # then the next done is accepted unconditionally.
1672
+ if done_audit_fired and done_audit_bounces < 2 and audit_absent_list:
1673
+ _runway = ((self._turn_budget_s
1674
+ - (time.monotonic() - turn_start))
1675
+ if self._turn_budget_s else None)
1676
+ rebounce = guardrails.audit_rebounce(audit_absent_list, _runway)
1677
+ if rebounce:
1678
+ done_audit_bounces += 1
1679
+ audit_absent_list = []
1680
+ log.info("DONE-AUDIT rebounce: still-absent task paths, "
1681
+ "runway=%s", _runway)
1682
+ self.messages.append({"role": "tool", "name": "done",
1683
+ "content": rebounce})
1684
+ continue
1585
1685
  # Iter-3 deliverable recheck (levers.done_spec_recheck): one last self-check
1586
1686
  # that the required outputs actually exist at the right path/format before
1587
1687
  # we accept done — the hidden container-end-state verifier gives no second
@@ -1725,6 +1825,7 @@ class Agent:
1725
1825
  result = "[denied by user]"
1726
1826
  else:
1727
1827
  _t0 = time.perf_counter()
1828
+ self.tool_dispatches += 1
1728
1829
  try:
1729
1830
  result = fn(args, self._should_stop)
1730
1831
  if plan_write and result.startswith("[wrote"):
@@ -1736,6 +1837,15 @@ class Agent:
1736
1837
  # whole — several calls in one step stack into one prefill, so later
1737
1838
  # results only get what's left of the step budget (floor-protected).
1738
1839
  result = _clip_tool_result(result, cap=_step_tool_cap(step_tool_chars))
1840
+ # Duplicate read-only output: if this (clipped) result is
1841
+ # byte-identical to a tool message still in the transcript, append a
1842
+ # short pointer instead of paying the body's prefill again — see
1843
+ # guardrails.elide_duplicate_result for the safety argument.
1844
+ _elided = guardrails.elide_duplicate_result(name, result, self.messages)
1845
+ if _elided is not None:
1846
+ log.info("TOOL %s duplicate result elided (%d chars)",
1847
+ name, len(result))
1848
+ result = _elided
1739
1849
  step_tool_chars += len(result)
1740
1850
  if _PREFILL_TRACE:
1741
1851
  self._trace_tools_pending.append([name, round(_tool_s, 4)])
@@ -1854,7 +1964,18 @@ class Agent:
1854
1964
  if made_edit and not _gov_prev_made:
1855
1965
  readonly_streak = 0
1856
1966
  elif did_work and not made_edit:
1857
- readonly_streak += 1
1967
+ # A bash step that isn't provably read-only is ACTION, not
1968
+ # investigation — `git merge`, `apt-get install`, redirects are ops
1969
+ # progress. Otherwise the gate can count an entire git/ops workflow as
1970
+ # "investigation" and demand an edit at a decision point where there is
1971
+ # nothing to edit yet.
1972
+ if levers.enabled("gate_ops_exempt") and any(
1973
+ n == "bash"
1974
+ and not guardrails.is_readonly_bash(str(a.get("command", "")))
1975
+ for n, a in calls):
1976
+ readonly_streak = 0
1977
+ else:
1978
+ readonly_streak += 1
1858
1979
  if not read_only_intent:
1859
1980
  gate = guardrails.investigation_gate(readonly_streak, made_edit, gate_nudges)
1860
1981
  if gate:
@@ -31,6 +31,21 @@ def eq(name: str, expected: str) -> bool:
31
31
  return os.environ.get(name) == expected
32
32
 
33
33
 
34
+ def traces_enabled() -> bool:
35
+ """Whether chad may write local diagnostic traces under ~/.chad — the readable
36
+ `session.log` (user query, tool-call args, bash/write/edit previews, result previews)
37
+ and the persistent input history at ~/.chad/history.
38
+
39
+ Privacy-first default: **OFF**. chad is a local, single-user agent, so these traces
40
+ never leave the machine, but they still record command/file previews in plaintext
41
+ outside the repo — so they are opt-in. Set **`CHAD_SESSION_LOG`** (any truthy value)
42
+ to turn them on. `CHAD_NO_SESSION_LOG`, if set, forces them OFF and wins over the
43
+ opt-in (a hard kill switch, kept for compatibility with the pre-opt-in default)."""
44
+ if flag("CHAD_NO_SESSION_LOG"):
45
+ return False
46
+ return flag("CHAD_SESSION_LOG")
47
+
48
+
34
49
  def env_str(name, default=None):
35
50
  """The env value if set to a non-empty string, else `default`. Mirrors the common
36
51
  `os.environ.get(name) or <fallback>` idiom (empty string collapses to the default)."""
@@ -9,8 +9,10 @@ declare done, which cache served the turn. This module is that trace.
9
9
 
10
10
  The log is bounded (5 MB x3 rotation) and previews pass through a best-effort secret
11
11
  redactor, but it still records command/file previews in plaintext outside the repo, so
12
- treat it as sensitive. None of this touches the model-facing transcript or the tool
13
- results the model sees it is the diagnostic trace only.
12
+ treat it as sensitive. For that reason it is **opt-in**: privacy-first, chad writes no
13
+ trace under ~/.chad unless CHAD_SESSION_LOG is set (see config.traces_enabled). None of
14
+ this touches the model-facing transcript or the tool results the model sees — it is the
15
+ diagnostic trace only.
14
16
  """
15
17
  import json
16
18
  import logging
@@ -22,11 +24,12 @@ from . import config
22
24
 
23
25
  _LOG_DIR = os.path.expanduser("~/.chad")
24
26
  log = logging.getLogger("chad")
25
- # Local privacy opt-out: set CHAD_NO_SESSION_LOG (any truthy value) to disable the
26
- # diagnostic session log entirely, matching the CHAD_NO_VALIDATE convention. When opted
27
- # out we install a NullHandler (so the many log.info calls stay cheap no-ops and Python
28
- # never warns about missing handlers) and never create ~/.chad for the log's sake.
29
- _DISABLED = config.flag("CHAD_NO_SESSION_LOG")
27
+ # Privacy-first default: the diagnostic session log is OFF unless opted in. Set
28
+ # CHAD_SESSION_LOG (any truthy value) to enable it; CHAD_NO_SESSION_LOG still forces it
29
+ # off (see config.traces_enabled). When disabled we install a NullHandler (so the many
30
+ # log.info calls stay cheap no-ops and Python never warns about missing handlers) and
31
+ # never create ~/.chad for the log's sake.
32
+ _DISABLED = not config.traces_enabled()
30
33
  if _DISABLED:
31
34
  log.addHandler(logging.NullHandler())
32
35
  log.propagate = False