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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|