chad-code 1.0.3__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.3/src/chad_code.egg-info → chad_code-1.0.4}/PKG-INFO +1 -1
  2. {chad_code-1.0.3 → chad_code-1.0.4}/pyproject.toml +1 -1
  3. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/agent.py +30 -5
  4. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/config.py +15 -0
  5. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/diag.py +10 -7
  6. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/guardrails.py +61 -0
  7. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/levers.py +37 -0
  8. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/prompt.py +36 -4
  9. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/tools.py +34 -6
  10. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/tui.py +4 -3
  11. {chad_code-1.0.3 → chad_code-1.0.4/src/chad_code.egg-info}/PKG-INFO +1 -1
  12. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent_guards.py +63 -0
  13. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_edit.py +35 -0
  14. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_feel_pack.py +20 -4
  15. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_lever_bite.py +49 -0
  16. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_log_redaction.py +38 -13
  17. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_subagent.py +8 -1
  18. {chad_code-1.0.3 → chad_code-1.0.4}/LICENSE +0 -0
  19. {chad_code-1.0.3 → chad_code-1.0.4}/README.md +0 -0
  20. {chad_code-1.0.3 → chad_code-1.0.4}/setup.cfg +0 -0
  21. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/__init__.py +0 -0
  22. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/atif.py +0 -0
  23. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/base_engine.py +0 -0
  24. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/bench.py +0 -0
  25. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/cli.py +0 -0
  26. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/compaction.py +0 -0
  27. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/completion_engine.py +0 -0
  28. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/engine.py +0 -0
  29. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/ignore.py +0 -0
  30. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/lsp.py +0 -0
  31. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mcp.py +0 -0
  32. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mcp_oauth.py +0 -0
  33. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mlx_fastpath.py +0 -0
  34. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mlx_qsdpa.py +0 -0
  35. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/profiles.py +0 -0
  36. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/prove.py +0 -0
  37. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/render.py +0 -0
  38. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/repomap.py +0 -0
  39. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/session.py +0 -0
  40. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/skills.py +0 -0
  41. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/symbols.py +0 -0
  42. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/syntaxgate.py +0 -0
  43. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/toolcall_parse.py +0 -0
  44. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/validate.py +0 -0
  45. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/SOURCES.txt +0 -0
  46. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/dependency_links.txt +0 -0
  47. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/entry_points.txt +0 -0
  48. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/requires.txt +0 -0
  49. {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/top_level.txt +0 -0
  50. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent.py +0 -0
  51. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent_e2e.py +0 -0
  52. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_atif.py +0 -0
  53. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_bench.py +0 -0
  54. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_cli.py +0 -0
  55. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_compact_notice.py +0 -0
  56. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_compaction.py +0 -0
  57. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_completion_engine.py +0 -0
  58. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_config.py +0 -0
  59. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_confirm_preview.py +0 -0
  60. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_done_audit.py +0 -0
  61. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_drift_warn.py +0 -0
  62. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_edit_corruption.py +0 -0
  63. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_engine.py +0 -0
  64. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_engine_kvquant.py +0 -0
  65. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_garble_invariant.py +0 -0
  66. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_gate.py +0 -0
  67. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_ignore.py +0 -0
  68. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_intent.py +0 -0
  69. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_levers.py +0 -0
  70. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_lsp.py +0 -0
  71. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mcp.py +0 -0
  72. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mcp_oauth.py +0 -0
  73. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mlx_fastpath.py +0 -0
  74. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mlx_qsdpa.py +0 -0
  75. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_plan_review.py +0 -0
  76. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_prove.py +0 -0
  77. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_render.py +0 -0
  78. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_replace_lines.py +0 -0
  79. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_repomap.py +0 -0
  80. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_session.py +0 -0
  81. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_skills.py +0 -0
  82. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_symbols.py +0 -0
  83. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_syntaxgate.py +0 -0
  84. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_toolcall_parse.py +0 -0
  85. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_tools.py +0 -0
  86. {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_tui.py +0 -0
  87. {chad_code-1.0.3 → 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.3
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
@@ -4,7 +4,7 @@
4
4
  # import name, and command name are independent. `uvx chad-code` runs the alias
5
5
  # script added under [project.scripts].
6
6
  name = "chad-code"
7
- version = "1.0.3"
7
+ version = "1.0.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]"
@@ -1810,6 +1825,7 @@ class Agent:
1810
1825
  result = "[denied by user]"
1811
1826
  else:
1812
1827
  _t0 = time.perf_counter()
1828
+ self.tool_dispatches += 1
1813
1829
  try:
1814
1830
  result = fn(args, self._should_stop)
1815
1831
  if plan_write and result.startswith("[wrote"):
@@ -1821,6 +1837,15 @@ class Agent:
1821
1837
  # whole — several calls in one step stack into one prefill, so later
1822
1838
  # results only get what's left of the step budget (floor-protected).
1823
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
1824
1849
  step_tool_chars += len(result)
1825
1850
  if _PREFILL_TRACE:
1826
1851
  self._trace_tools_pending.append([name, round(_tool_s, 4)])
@@ -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
@@ -1412,3 +1412,64 @@ def is_repeat_loop(seen_before: int) -> bool:
1412
1412
  def loop_should_abort(loop_nudges: int) -> bool:
1413
1413
  """After incrementing the loop-nudge counter, more than 2 nudges -> abort."""
1414
1414
  return loop_nudges > 2
1415
+
1416
+
1417
+ # --- iter-14: duplicate read-only result elision. Small models re-read files and
1418
+ # re-run searches they have already loaded; on a non-trimmable prefix cache every
1419
+ # duplicate body is appended prefill paid on every later step. Hosted harnesses solve
1420
+ # this with an opt-in changed-since parameter the model must remember to pass; here
1421
+ # the harness does it automatically. Byte-equality against the
1422
+ # LIVE transcript is the whole safety argument: a changed file, different args, or a
1423
+ # compaction rewrite/drop of the earlier message all break equality, so content is only
1424
+ # elided while a verbatim copy is provably still in the model's context.
1425
+
1426
+ DUP_ELIDE_MIN_CHARS = 400 # below this the pointer saves nothing over the body
1427
+
1428
+ # Read-only builtins whose output is a pure function of (args, workspace state). bash is
1429
+ # excluded even when read-only (env-dependent, side-effectful); task is excluded (a
1430
+ # sub-agent run is expensive but never byte-identical in spirit — and re-spawns already
1431
+ # have their own guard); MCP tools are excluded (openWorld results may legitimately
1432
+ # repeat, e.g. polling).
1433
+ DUP_ELIDABLE = {"read", "grep", "glob", "repo_map", "overview", "view_symbol",
1434
+ "find_symbol", "find_refs"}
1435
+
1436
+
1437
+ def elide_duplicate_result(name, result, messages):
1438
+ """The short replacement result when `result` is byte-identical to an earlier tool
1439
+ message still in the transcript, else None. Compare AFTER clipping — the transcript
1440
+ stores clipped content, and equality must be against what the model actually has."""
1441
+ if not levers.enabled("dup_result_elide"):
1442
+ return None
1443
+ if name not in DUP_ELIDABLE or len(result) < DUP_ELIDE_MIN_CHARS:
1444
+ return None
1445
+ if not any(m.get("role") == "tool" and m.get("name") == name
1446
+ and m.get("content") == result for m in messages):
1447
+ return None
1448
+ return (f"[identical output elided: this {name} returned exactly the same result "
1449
+ f"as your earlier {name} above — nothing has changed. That content is "
1450
+ f"still in your context; use it from there instead of re-running the "
1451
+ f"call.]")
1452
+
1453
+
1454
+ # The zero-evidence sub-agent tell (iter-14): a confident, non-empty report produced
1455
+ # with no tool dispatches came from model memory, not the repo. Warn-not-reject: a
1456
+ # local re-spawn doubles GPU cost, a false reject breaks the turn, and a false accept
1457
+ # merely restores the pre-warning status quo.
1458
+
1459
+ SUBAGENT_EVIDENCE_WARNING = (
1460
+ "\n[warning: the sub-agent answered WITHOUT reading any files or running any "
1461
+ "searches — this report is from model memory, not this repository. Verify the "
1462
+ "claims with grep/read before relying on them.]")
1463
+
1464
+
1465
+ def subagent_evidence_warning(result, tool_dispatches):
1466
+ """The result with the zero-evidence warning appended, or None when no warning is
1467
+ due (the sub-agent did real work, the result is already a failure sentinel the
1468
+ salvage path owns, or the lever is off)."""
1469
+ if not levers.enabled("subagent_evidence_warn"):
1470
+ return None
1471
+ if tool_dispatches > 0:
1472
+ return None
1473
+ if not result or not result.strip() or result.startswith(("[task", "[stopped:")):
1474
+ return None
1475
+ return result.rstrip() + SUBAGENT_EVIDENCE_WARNING
@@ -309,6 +309,35 @@ LEVERS: dict[str, Lever] = {
309
309
  "harassing ops-heavy tasks that legitimately run many non-read commands.",
310
310
  "iter13"),
311
311
 
312
+ # --- iter-14: tool-result economics (2026-07). Two ideas from a teardown of a
313
+ # token-optimizing agent harness: normalize typographic unicode when matching
314
+ # edits, and never re-send file content the model provably already has; both
315
+ # map cleanly onto a prefill-dominated local loop. ---------------------------
316
+ "edit_typo_match": Lever(
317
+ "Fourth edit-match rung: when exact / escape-normalized / whitespace-flexible "
318
+ "matching all miss, retry the whitespace-flexible match with typographic "
319
+ "punctuation folded to ASCII on BOTH sides (curly quotes, en/em dashes, "
320
+ "ellipsis, non-breaking space) — the drift when a model re-types prose or "
321
+ "docstrings it saw rendered. Still requires a unique match; never fires on "
322
+ "code that matched an earlier rung. OFF loses only the recovery.",
323
+ "iter14"),
324
+ "subagent_evidence_warn": Lever(
325
+ "A sub-agent that returns a confident, non-empty report having dispatched ZERO "
326
+ "tools answered from model memory, not the repo; its folded result gets an "
327
+ "explicit verify-before-relying warning appended. Warn-not-reject on purpose: "
328
+ "a local re-spawn doubles GPU cost, and a false reject breaks the turn while a "
329
+ "false accept merely restores the pre-warning status quo. OFF folds the "
330
+ "unverified report back silently.",
331
+ "iter14"),
332
+ "dup_result_elide": Lever(
333
+ "A read-only tool result byte-identical to a tool message still in the "
334
+ "transcript is replaced with a short pointer to the earlier copy instead of "
335
+ "re-appending the full body to the prefill. Equality against the LIVE "
336
+ "transcript is the safety proof: compaction rewrites or drops old messages, "
337
+ "which breaks equality, so content is only elided while it is genuinely "
338
+ "still in context. OFF re-appends duplicate output verbatim.",
339
+ "iter14"),
340
+
312
341
  # --- from the LangChain harness-tuning playbook. -------------------------------
313
342
  "compact_notice": Lever(
314
343
  "After compaction, inject an in-band message telling the model its context was "
@@ -326,6 +355,14 @@ LEVERS: dict[str, Lever] = {
326
355
  "Append the active model profile's prompt block to the system prompt "
327
356
  "(model-specific accommodations; see profiles.py).",
328
357
  "playbook"),
358
+
359
+ # --- from the ai-codex teardown: an index the model already has beats one it must
360
+ # fetch. chad had the ranked map but only as a tool; this puts a digest in-prompt.
361
+ "workspace_map": Lever(
362
+ "Inject a ranked repo_map digest into the system-prompt dynamic tail instead of "
363
+ "a flat file listing, so the model orients without a reflexive step-1 repo_map "
364
+ "call. Degrades to the flat listing when repomap is unavailable (see prompt.py).",
365
+ "ai-codex"),
329
366
  }
330
367
 
331
368
 
@@ -209,12 +209,20 @@ def _dynamic_context() -> list:
209
209
  f"- Shell: {os.environ.get('SHELL', 'unknown')}",
210
210
  f"- Working directory: {os.getcwd()}",
211
211
  ]
212
- snapshot = _workspace_snapshot()
213
- if snapshot:
212
+ ranked = _workspace_map()
213
+ if ranked:
214
214
  dynamic.append(
215
- "\n# Workspace files (a real project use grep/read to inspect before answering)\n"
216
- + snapshot
215
+ "\n# Workspace map (ranked by reference centrality; signatures only "
216
+ "call repo_map for a wider/focused map, view_symbol/read for bodies)\n"
217
+ + ranked
217
218
  )
219
+ else:
220
+ snapshot = _workspace_snapshot()
221
+ if snapshot:
222
+ dynamic.append(
223
+ "\n# Workspace files (a real project — use grep/read to inspect before answering)\n"
224
+ + snapshot
225
+ )
218
226
  test_cmd = _detect_test_command()
219
227
  if test_cmd:
220
228
  dynamic.append(
@@ -334,6 +342,30 @@ def _detect_test_command() -> str:
334
342
  return ""
335
343
 
336
344
 
345
+ def _workspace_map(budget: int = 600) -> str:
346
+ """A small, ranked repo_map digest for the system-prompt tail, so the model orients
347
+ structurally at session start instead of burning a reflexive step-1 repo_map call
348
+ (chad already pays for the ranked index; the tool stays for a wider/focused map).
349
+
350
+ Built once per session (build_system_prompt runs once in Agent.__init__) and reuses
351
+ the on-disk mtime cache, so it doesn't churn the KV prefix or add per-turn cost.
352
+ Returns "" on any failure so _dynamic_context falls back to the flat file listing —
353
+ keeping behavior identical on wheel-less platforms where repomap degrades to empty."""
354
+ from . import levers
355
+ if not levers.enabled("workspace_map"):
356
+ return ""
357
+ try:
358
+ from . import repomap
359
+ digest = repomap.service().repo_map(budget_tokens=budget)
360
+ except Exception: # noqa: BLE001 - orientation is best-effort; degrade to snapshot
361
+ return ""
362
+ # repo_map's non-map sentinels ("[no source files found]", "[interrupted]") and any
363
+ # empty/whitespace result mean "no usable map" — fall back rather than inject noise.
364
+ if not digest or not digest.strip() or digest.strip().startswith("["):
365
+ return ""
366
+ return digest
367
+
368
+
337
369
  def _workspace_snapshot(limit: int = 60) -> str:
338
370
  """A short listing of the project's code files, so the model knows it's working
339
371
  in a real repo (Claude Code injects similar context). Prefers git-tracked files."""
@@ -382,10 +382,30 @@ def _line_offsets(data: str):
382
382
  return offs
383
383
 
384
384
 
385
- def _ws_flexible_spans(data: str, old: str):
385
+ # Typographic punctuation folded to ASCII for the last-resort edit match (iter-14).
386
+ # Only characters a model plausibly re-types the other way when
387
+ # quoting prose/docstrings it saw rendered: quotes, dashes, ellipsis, nbsp. Deliberately
388
+ # NOT general unicode normalization — identifiers and string literals in code must not
389
+ # be conflated beyond this list.
390
+ _TYPO_MAP = str.maketrans({
391
+ "‘": "'", "’": "'", "‚": "'", "‛": "'", # single quotes
392
+ "“": '"', "”": '"', "„": '"', # double quotes
393
+ "–": "-", "—": "-", "―": "-", # en/em/horizontal dash
394
+ " ": " ", # non-breaking space
395
+ })
396
+
397
+
398
+ def _norm_typo(s: str) -> str:
399
+ return s.translate(_TYPO_MAP).replace("…", "...")
400
+
401
+
402
+ def _ws_flexible_spans(data: str, old: str, typo: bool = False):
386
403
  """Char spans (start, end) where `old` matches a run of lines in `data` ignoring
387
- each line's leading/trailing whitespace. Skips all-blank patterns (too ambiguous)."""
388
- norm = [l.strip() for l in old.strip("\n").split("\n")]
404
+ each line's leading/trailing whitespace. Skips all-blank patterns (too ambiguous).
405
+ With `typo=True`, additionally folds typographic punctuation to ASCII on both
406
+ sides before comparing (the match is still line-exact otherwise)."""
407
+ fold = (lambda l: _norm_typo(l).strip()) if typo else (lambda l: l.strip())
408
+ norm = [fold(l) for l in old.strip("\n").split("\n")]
389
409
  if not any(norm):
390
410
  return []
391
411
  dlines = data.split("\n")
@@ -393,7 +413,7 @@ def _ws_flexible_spans(data: str, old: str):
393
413
  n = len(norm)
394
414
  spans = []
395
415
  for i in range(len(dlines) - n + 1):
396
- if [dlines[i + j].strip() for j in range(n)] == norm:
416
+ if [fold(dlines[i + j]) for j in range(n)] == norm:
397
417
  spans.append((offs[i], offs[i + n - 1] + len(dlines[i + n - 1])))
398
418
  return spans
399
419
 
@@ -647,8 +667,16 @@ def tool_edit(path: str, old: str, new: str) -> str:
647
667
  return f"[old string appears {c} times; make it unique by including more surrounding lines]"
648
668
 
649
669
  # (3) whitespace-flexible: indentation / trailing-space drift, still requiring uniqueness.
670
+ # (4) typography-normalized: same match with curly quotes / en–em dashes / ellipsis /
671
+ # nbsp folded to ASCII on both sides — the drift when the model re-types prose it
672
+ # saw rendered (or the file uses typographic punctuation the model ASCII-fied).
673
+ # Tried only when rung 3 found nothing, and still requires a unique match.
650
674
  probe = uold if uold != old else old
651
675
  spans = _ws_flexible_spans(data, probe)
676
+ how = "indentation/whitespace"
677
+ if not spans and levers.enabled("edit_typo_match"):
678
+ spans = _ws_flexible_spans(data, probe, typo=True)
679
+ how = "typographic quotes/dashes and whitespace"
652
680
  if len(spans) == 1:
653
681
  s, e = spans[0]
654
682
  head = data[s:e].split("\n")[0]
@@ -669,7 +697,7 @@ def tool_edit(path: str, old: str, new: str) -> str:
669
697
  landed = raw
670
698
  else:
671
699
  res = _apply_edit(path, data, data[:s] + repl + data[e:],
672
- " (recovered: matched ignoring indentation/whitespace)"
700
+ f" (recovered: matched ignoring {how})"
673
701
  + (note_new if used_unew else ""))
674
702
  landed = repl
675
703
  # Echo the landed region with visible whitespace (ky-timeoutMessage, session
@@ -678,7 +706,7 @@ def tool_edit(path: str, old: str, new: str) -> str:
678
706
  # `_apply_edit` can return a syntaxgate rejection or drift warning instead.
679
707
  return res + _landed_hint(landed) if res.startswith("[edited") else res
680
708
  if len(spans) > 1:
681
- return (f"[old string matches {len(spans)} places ignoring whitespace; include "
709
+ return (f"[old string matches {len(spans)} places ignoring {how}; include "
682
710
  f"more surrounding lines to make it unique]")
683
711
 
684
712
  return (f"[old string not found; no change made.{_closest_hint(data, old)}]"
@@ -239,9 +239,10 @@ class _ChadCompleter(Completer):
239
239
 
240
240
  def _make_history():
241
241
  """Persistent input history: `FileHistory` at ~/.chad/history (mode 0600 — it can
242
- hold typed paths/snippets, like the session store), or `InMemoryHistory` when
243
- CHAD_NO_SESSION_LOG is set. Falls back to in-memory on any filesystem error."""
244
- if config.flag("CHAD_NO_SESSION_LOG"):
242
+ hold typed paths/snippets, like the session store), or `InMemoryHistory` when local
243
+ traces are not opted in (see config.traces_enabled). Falls back to in-memory on any
244
+ filesystem error."""
245
+ if not config.traces_enabled():
245
246
  return InMemoryHistory()
246
247
  path = os.path.expanduser("~/.chad/history")
247
248
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chad-code
3
- Version: 1.0.3
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
@@ -1163,3 +1163,66 @@ if __name__ == "__main__":
1163
1163
  test_classify_sync_kind()
1164
1164
  print(f"\n{PASS} passed, {FAIL} failed")
1165
1165
  raise SystemExit(1 if FAIL else 0)
1166
+
1167
+
1168
+ def test_dup_result_elide():
1169
+ # iter-14: a read-only result byte-identical to a tool message
1170
+ # still in the transcript is elided to a short pointer; anything that breaks
1171
+ # byte-equality (changed file, different args, compaction rewrite) flows through.
1172
+ from chad.guardrails import DUP_ELIDE_MIN_CHARS, elide_duplicate_result
1173
+ body = "1 line\n" * 200 # comfortably over the threshold
1174
+ check("dup: body over threshold", len(body) >= DUP_ELIDE_MIN_CHARS)
1175
+ msgs = [{"role": "tool", "name": "read", "content": body}]
1176
+ hit = elide_duplicate_result("read", body, msgs)
1177
+ check("dup: identical read elided", hit is not None and "elided" in hit, hit)
1178
+ check("dup: pointer is short", hit is not None and len(hit) < 300, hit)
1179
+ check("dup: changed content flows through",
1180
+ elide_duplicate_result("read", body + "x", msgs) is None)
1181
+ check("dup: name mismatch flows through",
1182
+ elide_duplicate_result("grep", body, msgs) is None)
1183
+ check("dup: bash never elided",
1184
+ elide_duplicate_result(
1185
+ "bash", body, [{"role": "tool", "name": "bash", "content": body}]) is None)
1186
+ check("dup: short result flows through",
1187
+ elide_duplicate_result(
1188
+ "read", "[empty]", [{"role": "tool", "name": "read", "content": "[empty]"}]) is None)
1189
+ # a compaction rewrite (head/tail collapse) breaks equality -> full result again
1190
+ collapsed = body[:100] + "\n[...collapsed...]\n" + body[-50:]
1191
+ check("dup: collapsed prior flows through",
1192
+ elide_duplicate_result(
1193
+ "read", body, [{"role": "tool", "name": "read", "content": collapsed}]) is None)
1194
+
1195
+
1196
+ def test_dup_result_elide_lever_off(monkeypatch):
1197
+ from chad.guardrails import elide_duplicate_result
1198
+ monkeypatch.setenv("CHAD_DISABLE", "dup_result_elide")
1199
+ body = "y" * 500
1200
+ check("dup lever off: no elision",
1201
+ elide_duplicate_result(
1202
+ "read", body, [{"role": "tool", "name": "read", "content": body}]) is None)
1203
+
1204
+
1205
+ def test_subagent_evidence_warning():
1206
+ # iter-14: a confident report with zero tool dispatches is the answered-from-memory
1207
+ # tell; it gets a verify warning appended. Real work, failure sentinels (the salvage
1208
+ # path owns those), and empty results are left alone.
1209
+ from chad.guardrails import SUBAGENT_EVIDENCE_WARNING, subagent_evidence_warning
1210
+ report = "The bug is in utils.py line 40: off-by-one in the loop bound."
1211
+ warned = subagent_evidence_warning(report, 0)
1212
+ check("evidence: zero-dispatch report is warned",
1213
+ warned == report + SUBAGENT_EVIDENCE_WARNING, warned)
1214
+ check("evidence: real work passes clean",
1215
+ subagent_evidence_warning(report, 3) is None)
1216
+ check("evidence: failure sentinel untouched",
1217
+ subagent_evidence_warning("[task returned nothing]", 0) is None)
1218
+ check("evidence: stopped sentinel untouched",
1219
+ subagent_evidence_warning("[stopped: loop]", 0) is None)
1220
+ check("evidence: empty result untouched",
1221
+ subagent_evidence_warning(" ", 0) is None)
1222
+
1223
+
1224
+ def test_subagent_evidence_warning_lever_off(monkeypatch):
1225
+ from chad.guardrails import subagent_evidence_warning
1226
+ monkeypatch.setenv("CHAD_DISABLE", "subagent_evidence_warn")
1227
+ check("evidence lever off: no warning",
1228
+ subagent_evidence_warning("confident memory answer", 0) is None)
@@ -245,3 +245,38 @@ if __name__ == "__main__":
245
245
  test_ws_only_edit_result_echoes_landed_indentation()
246
246
  print(f"\n{PASS} passed, {FAIL} failed")
247
247
  raise SystemExit(1 if FAIL else 0)
248
+
249
+
250
+ def test_typo_recovery_file_has_unicode_model_sends_ascii():
251
+ # iter-14: file uses typographic punctuation, the model
252
+ # re-types it ASCII-fied. Rungs 1-3 miss; the typo-normalized rung must land.
253
+ before = 'MSG = "cache — warm start"\nprint(MSG)\n'
254
+ res, after = run(before, 'MSG = "cache - warm start"', 'MSG = "cache - hot start"')
255
+ check("typo(ascii old): edit landed", res.startswith("[edited"), res)
256
+ check("typo(ascii old): note names the rung", "typographic" in res, res)
257
+ check("typo(ascii old): new text is verbatim", 'MSG = "cache - hot start"' in after, after)
258
+ check("typo(ascii old): rest untouched", "print(MSG)" in after, after)
259
+
260
+
261
+ def test_typo_recovery_model_sends_unicode_file_has_ascii():
262
+ # Opposite direction: the model quotes with curly punctuation, file is ASCII.
263
+ before = "note = 'it is fine...'\nx = 1\n"
264
+ res, after = run(before, "note = ‘it is fine…’", "note = 'all good...'")
265
+ check("typo(unicode old): edit landed", res.startswith("[edited"), res)
266
+ check("typo(unicode old): replacement present", "note = 'all good...'" in after, after)
267
+
268
+
269
+ def test_typo_recovery_requires_unique_match():
270
+ # SAFETY: two lines that both match after folding must be rejected, file untouched.
271
+ before = 'a = "x — y"\nb = "x — y"\n'
272
+ res, after = run(before, '"x - y"', '"z"')
273
+ check("typo ambiguous: rejected", not res.startswith("[edited"), res)
274
+ check("typo ambiguous: file untouched", after == before, after)
275
+
276
+
277
+ def test_typo_recovery_lever_off(monkeypatch):
278
+ monkeypatch.setenv("CHAD_DISABLE", "edit_typo_match")
279
+ before = 'MSG = "cache — warm start"\nprint(MSG)\n'
280
+ res, after = run(before, 'MSG = "cache - warm start"', 'MSG = "x"')
281
+ check("typo lever off: not found", "not found" in res, res)
282
+ check("typo lever off: file untouched", after == before, after)
@@ -5,8 +5,8 @@ pin the *logic*:
5
5
 
6
6
  * item 2 — slash-command matching, `@`-token extraction, and IGNORE_DIRS-aware path
7
7
  completion (the completer itself just forwards to these);
8
- * item 3 — FileHistory at ~/.chad/history is created 0600 and CHAD_NO_SESSION_LOG
9
- falls back to in-memory;
8
+ * item 3 — FileHistory at ~/.chad/history is created 0600 only when opted in
9
+ (CHAD_SESSION_LOG); off by default and under CHAD_NO_SESSION_LOG it stays in-memory;
10
10
  * item 4 — pygments highlighting is byte-identical to the plain path when the import is
11
11
  unavailable (monkeypatched off) and adds color when present, with the +/- diff
12
12
  coloring left as the outer layer.
@@ -68,8 +68,11 @@ def test_path_matches_respects_ignore_dirs(tmp_path):
68
68
 
69
69
  # -- item 3: persistent history ---------------------------------------------
70
70
 
71
- def test_file_history_is_created_0600(tmp_path, monkeypatch):
71
+ def test_file_history_opt_in_is_created_0600(tmp_path, monkeypatch):
72
+ # Persistent history is a local trace, so it follows the same opt-in gate as the
73
+ # session log: only written when CHAD_SESSION_LOG is set.
72
74
  monkeypatch.setenv("HOME", str(tmp_path))
75
+ monkeypatch.setenv("CHAD_SESSION_LOG", "1")
73
76
  monkeypatch.delenv("CHAD_NO_SESSION_LOG", raising=False)
74
77
  h = _make_history()
75
78
  p = tmp_path / ".chad" / "history"
@@ -78,8 +81,21 @@ def test_file_history_is_created_0600(tmp_path, monkeypatch):
78
81
  assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
79
82
 
80
83
 
81
- def test_history_opt_out_is_in_memory(tmp_path, monkeypatch):
84
+ def test_history_off_by_default_is_in_memory(tmp_path, monkeypatch):
85
+ # Privacy-first default: with no opt-in, history stays in memory and ~/.chad/history
86
+ # is never written.
82
87
  monkeypatch.setenv("HOME", str(tmp_path))
88
+ monkeypatch.delenv("CHAD_SESSION_LOG", raising=False)
89
+ monkeypatch.delenv("CHAD_NO_SESSION_LOG", raising=False)
90
+ h = _make_history()
91
+ assert type(h).__name__ == "InMemoryHistory"
92
+ assert not (tmp_path / ".chad" / "history").exists()
93
+
94
+
95
+ def test_history_no_session_log_overrides_opt_in(tmp_path, monkeypatch):
96
+ # The hard kill switch wins even when the opt-in is set.
97
+ monkeypatch.setenv("HOME", str(tmp_path))
98
+ monkeypatch.setenv("CHAD_SESSION_LOG", "1")
83
99
  monkeypatch.setenv("CHAD_NO_SESSION_LOG", "1")
84
100
  h = _make_history()
85
101
  assert type(h).__name__ == "InMemoryHistory"
@@ -674,6 +674,55 @@ def test_playbook_levers_have_dedicated_suites(monkeypatch):
674
674
  assert profiles.prompt_block(None) == ""
675
675
 
676
676
 
677
+ # === iter-14 ===============================================================
678
+
679
+ def test_edit_typo_match_bite(monkeypatch, tmp_path):
680
+ """ASCII-fied `old` against a typographic file lands only while the rung is on."""
681
+ n = bite("edit_typo_match")
682
+ src = 'MSG = "cache — warm"\n'
683
+ p = tmp_path / "f.py"
684
+ on(monkeypatch)
685
+ p.write_text(src)
686
+ assert tools.tool_edit(
687
+ str(p), 'MSG = "cache - warm"', 'MSG = "cache - hot"').startswith("[edited")
688
+ off(monkeypatch, n)
689
+ p.write_text(src)
690
+ assert "not found" in tools.tool_edit(
691
+ str(p), 'MSG = "cache - warm"', 'MSG = "cache - hot"')
692
+
693
+
694
+ def test_dup_result_elide_bite(monkeypatch):
695
+ """An identical read-only result is elided only while the lever is on."""
696
+ n = bite("dup_result_elide")
697
+ body = "z" * 500
698
+ msgs = [{"role": "tool", "name": "read", "content": body}]
699
+ on(monkeypatch)
700
+ assert guardrails.elide_duplicate_result("read", body, msgs) is not None
701
+ off(monkeypatch, n)
702
+ assert guardrails.elide_duplicate_result("read", body, msgs) is None
703
+
704
+
705
+ def test_subagent_evidence_warn_bite(monkeypatch):
706
+ """A zero-dispatch confident report is warned only while the lever is on."""
707
+ n = bite("subagent_evidence_warn")
708
+ on(monkeypatch)
709
+ assert guardrails.subagent_evidence_warning("found it in a.py:1", 0) is not None
710
+ off(monkeypatch, n)
711
+ assert guardrails.subagent_evidence_warning("found it in a.py:1", 0) is None
712
+
713
+
714
+ def test_workspace_map_bite(monkeypatch):
715
+ """The system prompt carries the ranked repo_map digest only while the lever is on;
716
+ off, it falls back to the flat file listing."""
717
+ from chad import prompt
718
+ n = bite("workspace_map")
719
+ on(monkeypatch)
720
+ assert "# Workspace map" in prompt.build_system_prompt("ornith")
721
+ off(monkeypatch, n)
722
+ p = prompt.build_system_prompt("ornith")
723
+ assert "# Workspace map" not in p and "# Workspace files" in p
724
+
725
+
677
726
  # === the coverage contract =================================================
678
727
 
679
728
  def test_every_registered_lever_has_a_bite_test():
@@ -77,30 +77,55 @@ def test_leaves_normal_text_unchanged():
77
77
  check(f"unchanged: {normal!r}", _redact(normal) == normal)
78
78
 
79
79
 
80
- def test_session_log_opt_out():
81
- # CHAD_NO_SESSION_LOG opts out of the diagnostic file log. diag installs its handler
82
- # at import time, so test in a fresh subprocess (reload would leave the first import's
83
- # RotatingFileHandler attached to the shared named logger). Assert only a NullHandler
84
- # is attached and that redact/args_preview still work without the file handler.
80
+ def _diag_handler_names(**env_overrides):
81
+ # diag installs its handler at import time, so probe in a fresh subprocess (reload
82
+ # would leave the first import's RotatingFileHandler attached to the shared named
83
+ # logger). Returns the list of handler type names on diag.log, and asserts redact/
84
+ # args_preview still work regardless of whether the file handler is installed.
85
85
  import os
86
86
  import subprocess
87
87
  import sys
88
+ import tempfile
88
89
 
89
90
  script = (
90
91
  "from chad import diag\n"
91
- "import logging\n"
92
92
  "diag.log.info('x')\n"
93
93
  "names = [type(h).__name__ for h in diag.log.handlers]\n"
94
- "assert names == ['NullHandler'], names\n"
95
94
  "assert diag.redact('a' * 32) == '<redacted:32>'\n"
96
95
  "assert diag.args_preview({'k': 'v'})\n"
97
- "print('OK', names)\n"
96
+ "print('NAMES', names)\n"
98
97
  )
99
- env = dict(os.environ, CHAD_NO_SESSION_LOG="1")
100
- out = subprocess.run([sys.executable, "-c", script], env=env,
101
- capture_output=True, text=True)
102
- check(f"opt-out subprocess OK (stderr={out.stderr})", out.returncode == 0)
103
- check("only NullHandler attached", "OK ['NullHandler']" in out.stdout)
98
+ # Point HOME at a throwaway dir so an *enabled* run can't touch the real ~/.chad.
99
+ with tempfile.TemporaryDirectory() as home:
100
+ env = dict(os.environ, HOME=home)
101
+ for k in ("CHAD_SESSION_LOG", "CHAD_NO_SESSION_LOG"):
102
+ env.pop(k, None)
103
+ env.update(env_overrides)
104
+ out = subprocess.run([sys.executable, "-c", script], env=env,
105
+ capture_output=True, text=True)
106
+ check(f"subprocess OK (stderr={out.stderr})", out.returncode == 0)
107
+ line = next(ln for ln in out.stdout.splitlines() if ln.startswith("NAMES "))
108
+ import ast
109
+ return ast.literal_eval(line[len("NAMES "):])
110
+
111
+
112
+ def test_session_log_off_by_default():
113
+ # Privacy-first: with neither env var set, the diagnostic file log is OFF — only a
114
+ # NullHandler is attached and ~/.chad is never created for the log's sake.
115
+ check("default off → only NullHandler", _diag_handler_names() == ["NullHandler"])
116
+
117
+
118
+ def test_session_log_opt_in():
119
+ # CHAD_SESSION_LOG opts in: the RotatingFileHandler is installed.
120
+ names = _diag_handler_names(CHAD_SESSION_LOG="1")
121
+ check(f"opt-in → RotatingFileHandler attached ({names})",
122
+ "RotatingFileHandler" in names)
123
+
124
+
125
+ def test_no_session_log_overrides_opt_in():
126
+ # CHAD_NO_SESSION_LOG is a hard kill switch: it wins even when the opt-in is also set.
127
+ names = _diag_handler_names(CHAD_SESSION_LOG="1", CHAD_NO_SESSION_LOG="1")
128
+ check(f"force-off wins over opt-in → NullHandler ({names})", names == ["NullHandler"])
104
129
 
105
130
 
106
131
  if __name__ == "__main__":
@@ -345,7 +345,14 @@ def test_normal_parent_never_spawns_mutating_subagent(monkeypatch):
345
345
  default read-only request stays silent. run_turn is stubbed class-level so no model
346
346
  or real sub-agent turn is needed."""
347
347
  from chad.agent import Agent
348
- monkeypatch.setattr(Agent, "run_turn", lambda self, prompt, stream=True: "ok")
348
+
349
+ def _stub_run_turn(self, prompt, stream=True):
350
+ # Simulate a sub-agent that did real work: without a dispatch the (on-topic
351
+ # elsewhere) zero-evidence warning would append to "ok" and muddy this check.
352
+ self.tool_dispatches += 1
353
+ return "ok"
354
+
355
+ monkeypatch.setattr(Agent, "run_turn", _stub_run_turn)
349
356
  agent = _mk_agent(mode="normal")
350
357
  agent.engine.push_cache = lambda: None
351
358
  agent.engine.pop_cache = lambda: None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes