localcode 0.4.0__py3-none-macosx_13_0_arm64.whl

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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,606 @@
1
+ """Stall detection + auto-nudge recovery.
2
+
3
+ Extracted from agent/__init__.py during T0.1-d. The loop used to have
4
+ ~100 lines of inline stall-detection + nudge-message-composition
5
+ mixed in with the main control flow; pulling it behind three pure
6
+ functions + an enum makes the loop read as "generate → detect → nudge
7
+ if stalled → else proceed" instead of a forest of booleans.
8
+
9
+ Three recovery failure modes we handle:
10
+
11
+ EMPTY — round produced only internal reasoning: no user-
12
+ visible content, no tool calls.
13
+ NARRATION — model DESCRIBED what it was about to do ("I'll
14
+ create the file now…") but never emitted the tool
15
+ call. Classic IQ2/IQ3 decode-ends-early failure.
16
+ POST_REJECTION — previous tool_call was REJECTED / errored and the
17
+ model silently gave up instead of reading the
18
+ actionable feedback and retrying.
19
+
20
+ All three get the same recovery: append a synthetic user message
21
+ ("SYSTEM: Your previous round…") that instructs the model to act,
22
+ then loop. Each mode has a distinct nudge phrasing because IQ2/IQ3
23
+ quants respond better to specific guidance matching the failure
24
+ than to a generic "please continue."
25
+
26
+ Capped by MAX_EMPTY_ROUND_RETRIES per turn — a truly broken model
27
+ doesn't get to loop forever.
28
+
29
+ Pure helpers. No side effects. `run_agent_loop` wires them into its
30
+ control flow.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from enum import Enum
35
+
36
+
37
+ __all__ = [
38
+ "StallMode",
39
+ "detect_stall",
40
+ "nudge_for",
41
+ "MAX_EMPTY_ROUND_RETRIES",
42
+ "ChurnMode",
43
+ "ChurnSignal",
44
+ "detect_churn",
45
+ "churn_nudge_for",
46
+ "NOT_FOUND_LOOP_LIMIT",
47
+ "not_found_key",
48
+ "detect_not_found_loop",
49
+ "not_found_nudge",
50
+ "REJECT_REREAD_LOOP_LIMIT",
51
+ "detect_reject_reread_loop",
52
+ "reject_reread_nudge",
53
+ "todo_close_verification_suffix",
54
+ ]
55
+
56
+
57
+ # Maximum number of auto-nudge retries per turn. After this many
58
+ # consecutive stalled rounds we end the turn cleanly with a user-
59
+ # visible message rather than spinning. 2 chosen empirically: 1
60
+ # catches typical IQ3 narration-without-action; 2 recovers from one
61
+ # bad retry after a tool rejection. 3+ means the model is
62
+ # fundamentally confused and more nudging won't help.
63
+ MAX_EMPTY_ROUND_RETRIES = 2
64
+
65
+
66
+ class StallMode(str, Enum):
67
+ """Which recovery failure mode the latest round exhibits.
68
+
69
+ String values are the human-readable labels shown in telemetry
70
+ and in the user-visible "nudging it to continue…" line.
71
+ """
72
+ EMPTY = "only reasoning"
73
+ NARRATION = "intent without action"
74
+ POST_REJECTION = "no retry after a tool rejection"
75
+
76
+
77
+ def _last_tool_failed(messages: list[dict]) -> bool:
78
+ """True if the most recent tool-role message is a rejection or
79
+ error result. Walks from the tail until we hit either a tool
80
+ message (inspected) or an assistant message (means no tool
81
+ result is waiting — not a post-rejection stall).
82
+ """
83
+ for m in reversed(messages):
84
+ if m.get("role") == "tool":
85
+ tres = str(m.get("content", ""))
86
+ if (tres.startswith("Error") or tres.startswith("REJECTED")
87
+ or tres.startswith("[exit code ")
88
+ or "[E3" in tres[:6] or "[E4" in tres[:6]
89
+ or "[E5" in tres[:6]):
90
+ return True
91
+ return False
92
+ if m.get("role") == "assistant":
93
+ # Hit an assistant before any tool result → no recent
94
+ # rejection to react to.
95
+ return False
96
+ return False
97
+
98
+
99
+ def detect_stall(
100
+ tool_calls: list,
101
+ content: str,
102
+ tools_called_prior: list,
103
+ messages: list[dict],
104
+ thinking_abort: bool,
105
+ ) -> StallMode | None:
106
+ """Classify whether the latest round stalled, and how.
107
+
108
+ Returns None if the round was productive (tool call emitted, or
109
+ content present with no prior tool use this turn, or a thinking-
110
+ abort already handled). Otherwise returns the `StallMode` we
111
+ should nudge for.
112
+
113
+ Precedence when multiple apply:
114
+ POST_REJECTION > EMPTY > NARRATION
115
+
116
+ POST_REJECTION wins because the nudge text specifically
117
+ references the rejection's actionable feedback — losing that
118
+ framing to a generic "empty" nudge would drop the signal the
119
+ model needs to recover.
120
+ """
121
+ if thinking_abort:
122
+ # Thinking-cap abort already surfaces its own "I was
123
+ # thinking too long" message; don't double-nudge.
124
+ return None
125
+
126
+ if tool_calls:
127
+ # Round was productive — no stall.
128
+ return None
129
+
130
+ # POST_REJECTION first (most specific).
131
+ if _last_tool_failed(messages):
132
+ return StallMode.POST_REJECTION
133
+
134
+ # EMPTY: no tool calls, no content.
135
+ if not content:
136
+ return StallMode.EMPTY
137
+
138
+ # NARRATION detection. Only INTENT phrases count — not every short answer
139
+ # after a tool call (a "weather in ny?" answer legitimately runs one tool
140
+ # then replies in ~200 chars; the old broad rule deleted such answers and
141
+ # looped). Two detectors: _NARRATION_INTENT_RE ("let me X"/"I'll Y" anywhere)
142
+ # and _NARRATION_PRESENT_PARTICIPLE_RE (last sentence "Updating X/Now writing
143
+ # Z" + target; past tense "Updated X" excluded).
144
+ stripped = content.strip()
145
+ if bool(tools_called_prior) and stripped:
146
+ if _NARRATION_INTENT_RE.search(stripped):
147
+ return StallMode.NARRATION
148
+ # Last-sentence check for present-participle commitments.
149
+ # Use sentence-boundary regex (terminal punctuation FOLLOWED
150
+ # BY whitespace) so "app.js" doesn't get split on its file
151
+ # extension dot. Picks the last non-empty piece after splitting.
152
+ sentences = [s for s in _SENTENCE_BOUNDARY_RE.split(stripped) if s.strip()]
153
+ last_sentence = sentences[-1].strip() if sentences else ""
154
+ # Strip any terminal punctuation from the last sentence so the
155
+ # ^ anchor in the participle regex hits the first real word.
156
+ last_sentence = last_sentence.rstrip(".!? \n")
157
+ if last_sentence and _NARRATION_PRESENT_PARTICIPLE_RE.search(
158
+ last_sentence[:200]
159
+ ):
160
+ return StallMode.NARRATION
161
+
162
+ # Content present, not clearly a stall — probably the final
163
+ # answer of a CHAT-style turn.
164
+ return None
165
+
166
+
167
+ import re as _re_recovery
168
+
169
+ # Sentence boundary: terminal punctuation followed by whitespace OR
170
+ # end-of-string. The whitespace lookahead is what prevents "app.js"
171
+ # from being split on its filename dot.
172
+ _SENTENCE_BOUNDARY_RE = _re_recovery.compile(r"(?<=[.!?])\s+")
173
+
174
+ # Forward-looking commitment phrase + action verb. Past-tense
175
+ # wrap-ups ("changed", "added") naturally don't match because they
176
+ # don't have the commitment-phrase prefix.
177
+ _NARRATION_INTENT_RE = _re_recovery.compile(
178
+ r"\b(?:let me|let's start|i'?ll|i will|i'?m going to|now (?:let me|i'?ll|i will)|going to)\b"
179
+ r"[^.]{0,120}?\b"
180
+ # Verb stems WITHOUT trailing \b so -ing/-ed forms match too.
181
+ r"(?:modify|chang|updat|fix|add|remov|writ|edit|run|start|"
182
+ r"restart|install|check|verify|test|creat|build|deploy|read|"
183
+ r"search|list|open|launch|patch|implement)",
184
+ _re_recovery.IGNORECASE,
185
+ )
186
+
187
+ # Present-participle action verb at the START of the last sentence,
188
+ # followed by what looks like a target (file name, path, or function
189
+ # reference). Catches "Updating <target> ..." patterns that lack any
190
+ # explicit "I'll/let me" framing. We anchor
191
+ # to the start of the last sentence so a mid-content "Updating the
192
+ # state was the bug" (analytical, not commit) doesn't trip.
193
+ _NARRATION_PRESENT_PARTICIPLE_RE = _re_recovery.compile(
194
+ # Optional "now" / "next" prefix so "Now writing X" / "Next, adding Y"
195
+ # also match. Allows trailing comma and whitespace flexibly.
196
+ r"^(?:(?:now|next)[,\s]+)?"
197
+ r"(?:updating|changing|adding|removing|writing|editing|modifying|"
198
+ r"fixing|installing|launching|opening|building|deploying|verifying|"
199
+ r"testing|running|starting|restarting|patching|implementing|reading|"
200
+ r"creating)\s+\S",
201
+ _re_recovery.IGNORECASE,
202
+ )
203
+
204
+
205
+ _NUDGE_TEXT: dict[StallMode, str] = {
206
+ StallMode.POST_REJECTION: (
207
+ "SYSTEM: Your previous tool call was REJECTED or "
208
+ "returned an Error. The rejection message contained "
209
+ "specific actionable feedback (e.g. line number of "
210
+ "a syntax error, missing required argument, the "
211
+ "closest matching lines for a missed old_string). "
212
+ "READ THE ERROR carefully, then RETRY with the "
213
+ "specific fix it suggested. The user wanted this "
214
+ "task done — do not give up after one rejection. "
215
+ "If write_file rejected because the edit broke "
216
+ "syntax, RE-READ the file with read_file, then use "
217
+ "edit_file with a small targeted change instead of "
218
+ "another full overwrite."
219
+ ),
220
+ StallMode.EMPTY: (
221
+ "SYSTEM: Your previous round produced only internal "
222
+ "reasoning, no user-visible response and no tool call. "
223
+ "Stop reasoning further. Emit your answer to the user "
224
+ "NOW, or call a tool if you need more information. Do "
225
+ "not think more — act."
226
+ ),
227
+ StallMode.NARRATION: (
228
+ "SYSTEM: Your previous round described what you were "
229
+ "ABOUT to do but did not actually do it (no tool call "
230
+ "was emitted). Stop narrating. Emit the tool call "
231
+ "RIGHT NOW to perform the action you just described. "
232
+ "Do NOT repeat the description — just call the tool."
233
+ ),
234
+ }
235
+
236
+
237
+ def nudge_for(mode: StallMode) -> str:
238
+ """Return the synthetic user message to append for this stall
239
+ mode. Phrased as an instruction so IQ2/IQ3 quants don't confuse
240
+ it with a new user ask; different wording per mode so the
241
+ guidance matches what actually went wrong.
242
+ """
243
+ return _NUDGE_TEXT[mode]
244
+
245
+
246
+ # ── Semantic churn (thrashing without converging) ───────────────────
247
+ #
248
+ # The stall detectors above catch rounds that produced NO action. Churn
249
+ # is the opposite: the model keeps ACTING — rewriting the same file,
250
+ # re-running the same failing command — but never converges because it
251
+ # isn't reading the error and making a targeted fix. The byte-identical
252
+ # breakers miss this (different content/output each time); we key on the
253
+ # KEY ARG — (write_file, path) / (bash, first-token) — and count repeats.
254
+
255
+ from .constants import ( # noqa: E402 (kept near the churn logic it parameterizes)
256
+ CHURN_FILE_WRITE_LIMIT,
257
+ CHURN_COMMAND_FAIL_LIMIT,
258
+ CHURN_READONLY_STREAK_LIMIT,
259
+ CHURN_PLANNING_STREAK_LIMIT,
260
+ )
261
+
262
+
263
+ class ChurnMode(str, Enum):
264
+ """Which churn pattern the turn-so-far exhibits. String values double
265
+ as the telemetry label and a piece of the user-visible nudge."""
266
+ FILE_REWRITE = "repeated file rewrite"
267
+ COMMAND_FAILURE = "repeated failing command"
268
+ INVESTIGATION_SPIN = "read-only investigation spin"
269
+ PLANNING_SPIN = "re-planning without progress"
270
+
271
+
272
+ class ChurnSignal:
273
+ """The chosen churn nudge plus the raw counters behind it.
274
+
275
+ Returned by `detect_churn` so the loop can both inject the nudge
276
+ AND emit the counters in round_end telemetry. Not a dataclass to
277
+ keep recovery.py dependency-free; three plain attrs is enough.
278
+ """
279
+
280
+ __slots__ = ("mode", "subject", "count")
281
+
282
+ def __init__(self, mode: ChurnMode, subject: str, count: int) -> None:
283
+ self.mode = mode
284
+ self.subject = subject # the file path / command token / "" for spin
285
+ self.count = count
286
+
287
+
288
+ def command_token(command: str) -> str:
289
+ """Normalize a shell command to the family key we count by — its
290
+ first meaningful token (e.g. `npm`, `node`, `pytest`). Strips a
291
+ leading env-assignment prefix (`FOO=bar npm i` → `npm`) and a
292
+ `sudo` prefix so `sudo npm i` and `npm i` count as the same
293
+ family. Returns "" for an empty command.
294
+ """
295
+ parts = str(command or "").strip().split()
296
+ for tok in parts:
297
+ if "=" in tok and not tok.startswith("-"):
298
+ # env assignment prefix, skip
299
+ continue
300
+ if tok in {"sudo", "command", "exec", "time", "nohup"}:
301
+ continue
302
+ return tok
303
+ return ""
304
+
305
+
306
+ def detect_churn(
307
+ file_write_counts: dict[str, int],
308
+ command_fail_counts: dict[str, int],
309
+ readonly_streak: int,
310
+ planning_streak: int = 0,
311
+ ) -> ChurnSignal | None:
312
+ """Decide whether the turn-so-far is churning, and how.
313
+
314
+ Pure function. Caller maintains across the turn: file_write_counts
315
+ (path → write/edit/append calls), command_fail_counts (command-family
316
+ token → FAILED runs), readonly_streak (consecutive pure read-only
317
+ rounds), planning_streak (consecutive rounds that changed no new file,
318
+ ran no build/verify, but produced thinking/narration — re-planning
319
+ without progress; defaults to 0 so legacy callers keep prior behaviour).
320
+
321
+ Precedence (most→least actionable, ties lose rightward):
322
+ COMMAND_FAILURE > FILE_REWRITE > INVESTIGATION_SPIN > PLANNING_SPIN.
323
+
324
+ Returns None when nothing crosses its threshold.
325
+ """
326
+ # COMMAND_FAILURE — most specific / actionable.
327
+ worst_cmd = ""
328
+ worst_cmd_n = 0
329
+ for tok, n in command_fail_counts.items():
330
+ if tok and n > worst_cmd_n:
331
+ worst_cmd, worst_cmd_n = tok, n
332
+ if worst_cmd_n >= CHURN_COMMAND_FAIL_LIMIT:
333
+ return ChurnSignal(ChurnMode.COMMAND_FAILURE, worst_cmd, worst_cmd_n)
334
+
335
+ # FILE_REWRITE.
336
+ worst_file = ""
337
+ worst_file_n = 0
338
+ for path, n in file_write_counts.items():
339
+ if path and n > worst_file_n:
340
+ worst_file, worst_file_n = path, n
341
+ if worst_file_n >= CHURN_FILE_WRITE_LIMIT:
342
+ return ChurnSignal(ChurnMode.FILE_REWRITE, worst_file, worst_file_n)
343
+
344
+ # INVESTIGATION_SPIN — pure read-only tool spin.
345
+ if readonly_streak >= CHURN_READONLY_STREAK_LIMIT:
346
+ return ChurnSignal(ChurnMode.INVESTIGATION_SPIN, "", readonly_streak)
347
+
348
+ # PLANNING_SPIN — softest. Re-planning (think/narrate) across rounds
349
+ # with no file change and no build/verify; the read-only streak misses
350
+ # this because think-only rounds (zero tools) reset it.
351
+ if planning_streak >= CHURN_PLANNING_STREAK_LIMIT:
352
+ return ChurnSignal(ChurnMode.PLANNING_SPIN, "", planning_streak)
353
+
354
+ return None
355
+
356
+
357
+ def churn_nudge_for(signal: ChurnSignal) -> str:
358
+ """Short, actionable synthetic user message for a churn signal.
359
+
360
+ Names the concrete subject (file path / command) + count so the model
361
+ sees what it's thrashing on, then tells it the ONE thing to do instead.
362
+ """
363
+ # NOTE: these are FORWARD-ONLY imperatives. They deliberately contain NO
364
+ # self-referential loop language ("you're going in circles", "you've
365
+ # rewritten X N times", "thrashing", "stop planning"). A small model
366
+ # parrots whatever we inject: injecting "you're going in circles" as a
367
+ # user-role message makes it reply "You're right, I've been going in
368
+ # circles" — which then sits in its own context and self-conditions the
369
+ # loop deeper (arXiv:2509.09677). So we tell it the NEXT concrete action
370
+ # and nothing about the failure it just had.
371
+ if signal.mode is ChurnMode.FILE_REWRITE:
372
+ return (
373
+ f"SYSTEM: {signal.subject} is written — move on to the next file. "
374
+ "If it has a specific error, make ONE targeted edit_file change to "
375
+ "the exact line; otherwise create the next file the task needs."
376
+ )
377
+ if signal.mode is ChurnMode.COMMAND_FAILURE:
378
+ return (
379
+ f"SYSTEM: `{signal.subject}` won't succeed as-is. Read its error "
380
+ "output, fix the root cause (missing dependency, a syntax error in a "
381
+ "config/source file, a wrong path) with an edit, then run it once."
382
+ )
383
+ if signal.mode is ChurnMode.PLANNING_SPIN:
384
+ return (
385
+ "SYSTEM: Take the next concrete action now — create or edit the next "
386
+ "file the task needs, then run the build to verify. Write code, not "
387
+ "a plan."
388
+ )
389
+ # INVESTIGATION_SPIN
390
+ return (
391
+ "SYSTEM: Take a concrete action now — create the next file the task "
392
+ "needs and write real code with write_file/edit_file. Files already on "
393
+ "disk that you did not create are not part of your task. If you truly "
394
+ "lack information only the user has, ask ONE focused question."
395
+ )
396
+
397
+
398
+ def rewrite_hard_stop(path, file_write_counts) -> str | None:
399
+ """Circuit-breaker for rewrite drift: reject once a path is rewritten past
400
+ 2x the nudge limit (the nudge only advises; logs showed 16x on one file)."""
401
+ if not path or not isinstance(path, str):
402
+ return None
403
+ n = file_write_counts.get(path, 0)
404
+ if n < CHURN_FILE_WRITE_LIMIT * 2:
405
+ return None
406
+ return (
407
+ f"REJECTED — HARD STOP: you have rewritten {path} {n} times this turn "
408
+ "and it's still not right — full rewrites are drifting. Make ONE "
409
+ "targeted edit_file change to the broken line, or leave this file and "
410
+ "move on. Do NOT call write_file on this path again."
411
+ )
412
+
413
+
414
+ # ── Repeated file-not-found (typo death loop) ───────────────────────
415
+ #
416
+ # read_file's own _confident_match auto-reads a real file when the typo
417
+ # is unambiguous, which breaks MOST of these loops at the source. But
418
+ # when the guessed paths are too far off to match (or point at a dir
419
+ # that doesn't exist), the model can still bounce "file not found" 20+
420
+ # times, each time with a slightly different wrong spelling/case. The
421
+ # byte-identical breakers miss it (each path differs); the churn
422
+ # detectors miss it (read_file is read-only, so it just extends the
423
+ # investigation streak). This is the read-side analogue of the bash
424
+ # command-failure guard: key on the PARENT DIRECTORY (differing typos
425
+ # of one filename share a parent) and count not-found failures.
426
+
427
+ NOT_FOUND_LOOP_LIMIT = 3
428
+ """How many 'file not found' read_file failures against the SAME parent
429
+ directory may occur in one turn before we pin the correct path. 3 (like
430
+ CHURN_COMMAND_FAIL_LIMIT): a genuine "read A, oops try B" correction is 1-2
431
+ misses; a 3rd not-found in the same dir means the model is spraying spelling
432
+ variants of a real filename it should just list_files for."""
433
+
434
+
435
+ def not_found_key(path: str) -> str:
436
+ """Normalize a missing read path to the family we count not-found errors
437
+ by: its parent directory. Different typos of the same file
438
+ (notes.md / Notes.md / note.md) all share a parent, so keying on the parent
439
+ collapses them into one repeat count. Returns "." for a bare filename."""
440
+ from pathlib import PurePosixPath
441
+ p = PurePosixPath(str(path or "").strip().replace("\\", "/"))
442
+ return str(p.parent)
443
+
444
+
445
+ def detect_not_found_loop(not_found_counts: dict[str, int]) -> tuple[str, int] | None:
446
+ """Return (parent_dir, count) when read_file has failed 'file not found'
447
+ NOT_FOUND_LOOP_LIMIT+ times against the same parent directory this turn —
448
+ the model is guessing typo/case variants of a real filename instead of
449
+ listing the directory once.
450
+
451
+ Pure function. Caller maintains `not_found_counts` across the turn
452
+ (parent-dir key via `not_found_key` → count of not-found read failures).
453
+ Returns None when nothing crosses the threshold.
454
+ """
455
+ worst = ""
456
+ worst_n = 0
457
+ for key, n in not_found_counts.items():
458
+ if n > worst_n:
459
+ worst, worst_n = key, n
460
+ if worst_n >= NOT_FOUND_LOOP_LIMIT:
461
+ return worst, worst_n
462
+ return None
463
+
464
+
465
+ def not_found_nudge(parent_dir: str, real_names: list[str] | None = None) -> str:
466
+ """Hard, forward-only nudge pinning the correct recovery path for a
467
+ not-found loop: list the directory ONCE, then read an EXACT name from it.
468
+
469
+ Like `churn_nudge_for`, this is a FORWARD-ONLY imperative with NO
470
+ echo-able self-critical loop language ("you keep failing", "stop
471
+ guessing in circles") that a small model would parrot back into its own
472
+ context. When `real_names` is supplied (caller listed the dir), the exact
473
+ names are pinned so the model can't invent another variant.
474
+ """
475
+ listing = ""
476
+ if real_names:
477
+ shown = ", ".join(sorted(real_names)[:20])
478
+ listing = (
479
+ f" The files that actually exist in {parent_dir} are: {shown}. "
480
+ "Read one of those EXACT names."
481
+ )
482
+ return (
483
+ f"SYSTEM: read_file cannot find that file in {parent_dir}. Do not try "
484
+ "another spelling or capitalization. Call list_files on "
485
+ f"{parent_dir} ONCE, then read_file with an EXACT name from that "
486
+ "listing." + listing
487
+ )
488
+
489
+
490
+ # ── Reject → re-read → reject spin (the "already modified" / dedup death loop) ─
491
+ #
492
+ # A mutation (edit_file/write_file/multi_edit) keeps returning REJECTED — a
493
+ # read-dedup stub, a "file was modified since read" rejection, an
494
+ # old_string-not-found rejection — and the model reacts by re-reading the file
495
+ # and retrying the SAME shape, over and over. The byte-identical breakers can
496
+ # miss it (the read paths / rejection reasons vary each round) and the churn
497
+ # detector can miss it (the interleaved reads keep looking like fresh activity).
498
+ #
499
+ # Recomputed from the TRANSCRIPT TAIL each call rather than a stored counter, so
500
+ # it survives compaction — the claude-code attachments.ts discipline: a nag's
501
+ # fire condition is derived from message history, not a per-turn integer a
502
+ # compaction pass would silently reset. Single-shot per turn at the loop layer
503
+ # (a guard boolean) so we give ONE actionable redirect, then stop nagging.
504
+
505
+ REJECT_REREAD_LOOP_LIMIT = 3
506
+ """How many REJECTED tool results (interleaved with a read_file re-read) may
507
+ accumulate in the recent transcript before we fire the one-shot redirect. 3
508
+ matches CHURN_COMMAND_FAIL_LIMIT / NOT_FOUND_LOOP_LIMIT: 1-2 rejections are a
509
+ normal "oops, re-read, fix" correction; a 3rd with the model still re-reading
510
+ means it's circling, not converging."""
511
+
512
+
513
+ def detect_reject_reread_loop(messages: list[dict], window: int = 24) -> int | None:
514
+ """Return the count of recent REJECTED tool results when the model is stuck
515
+ in a reject → re-read → reject spin (count >= REJECT_REREAD_LOOP_LIMIT AND at
516
+ least one read_file was issued in the same window — the re-read tell), else
517
+ None.
518
+
519
+ Pure function. Reads only the last `window` messages so the cost is bounded
520
+ and the signal is recomputed from the transcript (survives compaction). The
521
+ read_file requirement is what distinguishes this from a plain
522
+ repeated-failure (handled by the failure breakers): here the model KEEPS
523
+ re-reading between rejections instead of changing strategy.
524
+ """
525
+ tail = messages[-window:] if (window and window > 0) else list(messages)
526
+ rejections = 0
527
+ saw_read = False
528
+ for m in tail:
529
+ role = m.get("role")
530
+ if role == "tool":
531
+ if str(m.get("content", "")).startswith("REJECTED"):
532
+ rejections += 1
533
+ elif role == "assistant":
534
+ for tc in (m.get("tool_calls") or []):
535
+ name = str(((tc.get("function") or {}).get("name")) or "")
536
+ if name == "read_file":
537
+ saw_read = True
538
+ if rejections >= REJECT_REREAD_LOOP_LIMIT and saw_read:
539
+ return rejections
540
+ return None
541
+
542
+
543
+ def reject_reread_nudge() -> str:
544
+ """One-shot, forward-only redirect for a reject → re-read → reject spin.
545
+
546
+ Like the churn / not-found nudges, this carries NO echo-able self-critical
547
+ loop language ("you keep failing", "you're going in circles") that a small
548
+ model would parrot back into its own context and self-condition on
549
+ (arXiv:2509.09677). It names the ONE concrete recovery path and an explicit
550
+ escape hatch (leave the file, move on) so the model doesn't keep circling.
551
+ """
552
+ return (
553
+ "SYSTEM: This edit keeps being rejected and re-reading the file is not "
554
+ "getting past it. Do this exactly once: read a SMALL focused range "
555
+ "around the target line, copy one SHORT exact snippet from that output, "
556
+ "and make ONE minimal edit_file change to that snippet. If it is "
557
+ "rejected again, leave this file as it is and move on to the next step "
558
+ "of the task — do not read it again."
559
+ )
560
+
561
+
562
+ # ── Todo-close verification nudge (rides on the todo_write tool result) ───────
563
+ #
564
+ # When the model marks the LAST item of a 3+ item todo list completed and NONE
565
+ # of the items was itself a verification step, append a one-line reminder to the
566
+ # tool result telling it to verify before the final summary. Cheap and general:
567
+ # it rides on the tool result the model already gets back (no extra turn), and
568
+ # it fires at the exact loop-exit moment where a small model tends to declare
569
+ # victory and stop. Mirrors claude-code's TodoWriteTool verification nudge,
570
+ # generalized for a single-agent tool: there is no verifier subagent here, so we
571
+ # point at the project's own build/typecheck/tests.
572
+
573
+ _VERIFY_MENTION_RE = _re_recovery.compile(
574
+ r"verif|typecheck|type-check|\btest|\blint|\bbuild|compile|smoke",
575
+ _re_recovery.IGNORECASE,
576
+ )
577
+
578
+
579
+ def todo_close_verification_suffix(todos) -> str:
580
+ """Return a one-line "verify before you finish" reminder to append to the
581
+ todo_write tool result — but only when the model just closed out a 3+ item
582
+ list (all completed) and NONE of the item texts mentions a verification
583
+ step. Returns "" when the nudge doesn't apply.
584
+
585
+ Pure function of the todos the model just sent, so it's recomputed from the
586
+ tool call itself — no stored state to drift out of sync with compaction.
587
+ """
588
+ try:
589
+ items = [t for t in (todos or []) if isinstance(t, dict)]
590
+ except TypeError:
591
+ return ""
592
+ if len(items) < 3:
593
+ return ""
594
+ statuses = [str(t.get("status", "") or "").strip().lower() for t in items]
595
+ if not statuses or not all(s == "completed" for s in statuses):
596
+ return ""
597
+ contents = " ".join(str(t.get("content", "") or "") for t in items)
598
+ if _VERIFY_MENTION_RE.search(contents):
599
+ return ""
600
+ return (
601
+ "\n\nNOTE: you just marked a 3+ item task list fully done and none of "
602
+ "the items was a verification step. Before you write your final "
603
+ "summary, run the project's build/typecheck/tests (or a focused smoke "
604
+ "check) to confirm the work actually runs. Do not claim it works "
605
+ "without verifying."
606
+ )
@@ -0,0 +1,25 @@
1
+ """Bounded model-generation recovery decisions, separate from loop plumbing."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ __all__ = ["RecoveryDecision", "content_length_recovery"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class RecoveryDecision:
11
+ action: str
12
+ reason: str
13
+ attempt: int
14
+ message: str = ""
15
+
16
+
17
+ def content_length_recovery(attempts: int, cap: int = 3) -> RecoveryDecision:
18
+ if attempts < cap:
19
+ return RecoveryDecision(
20
+ "retry", "max_output_tokens", attempts + 1,
21
+ "SYSTEM: The previous response ended at the output limit. "
22
+ "Continue directly from the exact cutoff. Do not recap, repeat "
23
+ "completed text, or restart the answer.",
24
+ )
25
+ return RecoveryDecision("exhaust", "max_output_tokens", attempts)
@@ -0,0 +1,23 @@
1
+ """Explicit policy snapshot for the next model round (Pi-style transition)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, replace
5
+
6
+ __all__ = ["NextRoundPolicy"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class NextRoundPolicy:
11
+ use_thinking: bool
12
+ recovery_reason: str = ""
13
+ recovery_attempt: int = 0
14
+ commit_response: bool = True
15
+
16
+ def recover_without_thinking(self, reason: str) -> "NextRoundPolicy":
17
+ return replace(
18
+ self,
19
+ use_thinking=False,
20
+ recovery_reason=reason,
21
+ recovery_attempt=self.recovery_attempt + 1,
22
+ commit_response=False,
23
+ )