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,2855 @@
1
+ """The agent turn engine.
2
+
3
+ Final T0.1 slice. `run_agent_loop` used to live in agent/__init__.py
4
+ and everyone imported it as `from localcode.agent import run_agent_loop`.
5
+ That import path still works — `agent/__init__.py` now re-exports this
6
+ function from `agent/loop.py` so external callers don't notice the move.
7
+
8
+ The function itself is the core of LocalCode: one call per user turn,
9
+ drives the model / tool-dispatch loop until either (a) the model emits
10
+ a final response with no tool calls, (b) MAX_ROUNDS is hit, or (c) a
11
+ stall-recovery cap is exhausted.
12
+
13
+ It depends on every other submodule in agent/:
14
+ • constants — policy knobs, safety caps
15
+ • prompts — SYSTEM_PROMPT, REASONING_RULES
16
+ • context — _prepare_model_messages, _compact_messages, truncation
17
+ • recovery — detect_stall, nudge_for
18
+ • helpers — _execute_tool, _needs_confirmation, display helpers
19
+
20
+ If you're reading this to understand the agent behaviour, start with
21
+ `run_agent_loop` below and follow the imports into the submodules as
22
+ you hit them.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+ import time
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ if TYPE_CHECKING:
34
+ from ..app import LocalCodeApp
35
+ from ..output import OutputManager
36
+
37
+
38
+ __all__ = ["run_agent_loop"]
39
+
40
+
41
+ from .constants import (
42
+ MAX_ROUNDS,
43
+ CHURN_FILE_WRITE_LIMIT,
44
+ MAX_OUTPUT_TOKENS,
45
+ MAX_THINKING_SECONDS,
46
+ MAX_THINKING_CHARS,
47
+ MAX_AGGREGATE_PER_TURN,
48
+ CROSS_ROUND_REPEAT_LIMIT,
49
+ )
50
+ from .goal import GoalState, infer_goal_state, is_trivial_turn
51
+ from .context import (
52
+ _msg_bytes,
53
+ _prepare_model_messages,
54
+ _summarize_args,
55
+ _truncate_result,
56
+ build_progress_ledger,
57
+ )
58
+ from .helpers import (
59
+ _approval_display_command,
60
+ _request_approval_verdict,
61
+ _execute_tool,
62
+ _execute_tool_result,
63
+ _needs_confirmation,
64
+ _render_markdown,
65
+ _brief_result,
66
+ _grounded_file_summary,
67
+ _tool_stage_label,
68
+ _first_token,
69
+ )
70
+ from .prompts import (
71
+ SYSTEM_PROMPT,
72
+ )
73
+ from .prompt_context import build_agent_system_prompt
74
+ from .recovery import (
75
+ rewrite_hard_stop,
76
+ StallMode,
77
+ detect_stall,
78
+ nudge_for,
79
+ MAX_EMPTY_ROUND_RETRIES,
80
+ ChurnMode,
81
+ detect_churn,
82
+ churn_nudge_for,
83
+ command_token,
84
+ detect_reject_reread_loop,
85
+ reject_reread_nudge,
86
+ todo_close_verification_suffix,
87
+ )
88
+ from .tool_orchestration import prefetch_parallel_tool_calls
89
+ from .streaming import finish_thinking_display, stream_model_round
90
+ from .round_policy import NextRoundPolicy
91
+ from .recovery_controller import content_length_recovery
92
+ from .tool_execution import (
93
+ _HARD_STOP_THRESHOLD,
94
+ ToolExecutionState,
95
+ bash_cmd_key,
96
+ canonical_args,
97
+ dedup_stub_for_tool,
98
+ oversize_stub_for_tool,
99
+ repeat_stub_for_tool,
100
+ tool_result_is_error,
101
+ track_tool_result,
102
+ )
103
+ from .turn_finalization import finalize_turn, strip_ephemeral_nudges
104
+ from ..tools import schemas_for_goal
105
+ from ..tools.facts import extract_tool_facts, facts_suffix
106
+ from .hooks import (
107
+ TurnState,
108
+ after_tool as hook_after_tool,
109
+ before_model as hook_before_model,
110
+ before_turn as hook_before_turn,
111
+ completion_gate as hook_completion_gate,
112
+ quality_monitor as hook_quality_monitor,
113
+ ran_build_or_test,
114
+ _changed_code_files,
115
+ )
116
+ from .app_tasks import (
117
+ extract_port,
118
+ ground_run_or_launch_text,
119
+ has_runtime_verification_signal,
120
+ is_focused_blocking_question,
121
+ looks_like_partial_handoff,
122
+ )
123
+
124
+
125
+ def ephemeral_context_role(family: object) -> str:
126
+ """Role for the trailing per-round context block (ledger + fs-state + todo).
127
+
128
+ Gemma keeps ``system`` — as a user turn it re-greeted every round. But
129
+ Qwen 3.x's chat template raises ``System message must be at the beginning``
130
+ on ANY non-leading system message, which llama-server surfaces as HTTP 500
131
+ on every round once this block is non-empty (verified live: identical
132
+ content returns 500 as ``system``, 200 as ``user``). Qwen-family models
133
+ therefore receive it as a ``user`` turn.
134
+
135
+ `family` is the reasoning-caps family string (e.g. ``qwen35``,
136
+ ``qwen35moe``, ``gemma4-iswa``); anything falsy or non-qwen keeps the
137
+ historical ``system`` role.
138
+ """
139
+ fam = str(family or "").lower()
140
+ return "user" if fam.startswith("qwen") else "system"
141
+
142
+
143
+ def run_agent_loop(
144
+ app: "LocalCodeApp",
145
+ user_text: str,
146
+ composed_messages: list[dict],
147
+ out: "OutputManager",
148
+ *,
149
+ system_prompt: str | None = None,
150
+ ) -> str:
151
+ """Model-driven agent loop.
152
+
153
+ The model decides what to do via native Gemma 4 tool calls.
154
+ We execute tools and feed results back until the model is done.
155
+
156
+ Returns the final text response from the model.
157
+
158
+ Parameters
159
+ ----------
160
+ system_prompt : optional
161
+ The unformatted prompt template. When None (the default), the
162
+ module-level `SYSTEM_PROMPT` from `agent/prompts.py` is used
163
+ — which is what production and app.py always want. Eval and
164
+ tests pass an alternative here (e.g. the rendered minimal-core
165
+ variant) to exercise different prompts without monkey-patching
166
+ module globals. The string still goes through `.format(cwd=...,
167
+ project_instructions=..., network_status=..., skills_block=...,
168
+ reasoning_rules=...)` so any caller-supplied
169
+ override must expose the same placeholder slots (our variant
170
+ renderers all produce strings that have already had these
171
+ placeholders baked in to no-op values, so `.format()` is
172
+ effectively an identity pass when there are no placeholders
173
+ left — that's why the existing variant registry works here
174
+ too).
175
+
176
+ Telemetry: emits a `turn_start` lifecycle event so `tail -f
177
+ lifecycle.log` shows the new code is actually loaded and a turn
178
+ is in flight — without this, conversational turns (no tool calls,
179
+ no compactable history) leave the log silent for the entire turn
180
+ and the user can't tell whether the latest code is running.
181
+ """
182
+ # DI default — reach into module globals only if caller didn't
183
+ # pass an override. Means app.py keeps calling without worrying
184
+ # about prompts while eval can inject a variant per run.
185
+ if system_prompt is None:
186
+ system_prompt = SYSTEM_PROMPT
187
+ # Clear any leftover cancel from a PRIOR turn. The flag is set by the
188
+ # TUI when the user types "stop"; it's consumed at the round/tool
189
+ # boundaries below. If a previous turn broke out via an exception
190
+ # path between "set" and the TUI's own reset, the flag stayed True
191
+ # and poisoned THIS turn — the loop saw cancel_requested on round 0
192
+ # and bailed before a single model call. Resetting at entry makes
193
+ # each turn start from a clean slate regardless of how the last one
194
+ # ended.
195
+ app.cancel_requested = False
196
+ # Per-turn telemetry. Captures FULL user input so the events log
197
+ # is a complete record of "what was asked + what was done." The
198
+ # log is per-project, gitignored, never shipped to users — it's
199
+ # for the dev team to replay and debug sessions later.
200
+ import time as _time_mod
201
+ import uuid as _uuid
202
+ _turn_started_mono = _time_mod.monotonic()
203
+ _turn_id = _uuid.uuid4().hex[:12]
204
+
205
+ def _emit_internal_error(where: str, exc: BaseException) -> None:
206
+ try:
207
+ from ..events import emit as _emit_error
208
+ _emit_error(
209
+ "error",
210
+ turn_id=_turn_id,
211
+ where=where,
212
+ error_type=type(exc).__name__,
213
+ error=str(exc),
214
+ )
215
+ except Exception:
216
+ pass
217
+
218
+ _active_goal_payload = getattr(app, "_active_goal_state", None)
219
+ if isinstance(_active_goal_payload, dict):
220
+ try:
221
+ _payload = dict(_active_goal_payload)
222
+ _payload["success_criteria"] = tuple(_payload.get("success_criteria") or ())
223
+ _goal_state = GoalState(**_payload)
224
+ except Exception as exc:
225
+ _emit_internal_error("active_goal_state_parse", exc)
226
+ _goal_state = infer_goal_state(user_text)
227
+ else:
228
+ _goal_state = infer_goal_state(user_text)
229
+ _task_state = getattr(getattr(app, "session", None), "current_task", None)
230
+ _last_announced_task_stage = str(getattr(_task_state, "current_stage", "") or "")
231
+
232
+ def _announce_task_stage(stage: str, *, force: bool = False) -> None:
233
+ nonlocal _last_announced_task_stage
234
+ stage = (stage or "").strip()
235
+ if not stage:
236
+ return
237
+ if not force and stage == _last_announced_task_stage:
238
+ return
239
+ _last_announced_task_stage = stage
240
+ try:
241
+ out.set_stage(f"task: {stage}")
242
+ except Exception as exc:
243
+ _emit_internal_error("announce_task_stage_output", exc)
244
+ try:
245
+ if hasattr(app, "store") and getattr(app, "session", None) is not None:
246
+ app.store.update_task(
247
+ app.session,
248
+ status="in_progress",
249
+ current_stage=stage,
250
+ )
251
+ except Exception as exc:
252
+ _emit_internal_error("announce_task_stage_store", exc)
253
+ from ..thinking import should_use_thinking
254
+ _mode = getattr(app.config.runtime, "laptop_26b_runtime_mode", "") or ""
255
+ _reasoning = should_use_thinking(
256
+ _mode,
257
+ getattr(app.config.runtime, "internal_thinking_mode", "off"),
258
+ goal_type=_goal_state.goal_type,
259
+ task_stage=getattr(_task_state, "current_stage", "") if _task_state is not None else "",
260
+ user_text=user_text,
261
+ )
262
+ try:
263
+ from ..events import emit
264
+ emit("turn_start",
265
+ turn_id=_turn_id,
266
+ task_id=getattr(_task_state, "task_id", ""),
267
+ task_status=getattr(_task_state, "status", ""),
268
+ task_stage=getattr(_task_state, "current_stage", ""),
269
+ task_kind=getattr(_task_state, "task_kind", ""),
270
+ task_slug=getattr(_task_state, "task_slug", ""),
271
+ chars=len(user_text),
272
+ text=user_text, # FULL input
273
+ model=str(app.config.runtime.model or ""),
274
+ runtime_mode=_mode,
275
+ reasoning=_reasoning,
276
+ goal_type=_goal_state.goal_type,
277
+ goal_summary=_goal_state.goal_summary,
278
+ success_criteria=_goal_state.success_criteria)
279
+ except Exception as exc:
280
+ _emit_internal_error("turn_start_emit", exc)
281
+
282
+ # ── Build messages ──
283
+ # composed_messages already has system prompt + context + full conversation + current user msg
284
+ # Inject our tool-loop system prompt at the front
285
+ from ..app import is_online
286
+ online = is_online()
287
+ if online:
288
+ network_status = (
289
+ "Network: ONLINE — you can download files, install packages, fetch URLs. "
290
+ "PREFER your tools over writing from memory: web_search / web_fetch to "
291
+ "confirm current facts, versions, and library docs; your skills and other "
292
+ "tools for anything they cover. Don't hand-write what a tool can get right."
293
+ )
294
+ else:
295
+ network_status = (
296
+ "Network: OFFLINE — NO internet. Do NOT attempt downloads, pip install, curl, wget, or any network requests. "
297
+ "Use only local files, already-installed packages, and your LOCAL skills and "
298
+ "tools (read/grep/edit, installed CLIs). Generate sample/mock data locally "
299
+ "instead of downloading."
300
+ )
301
+ def _current_task_stage_for_thinking() -> str:
302
+ stage = str(_last_announced_task_stage or getattr(_task_state, "current_stage", "") or "").strip()
303
+ if stage:
304
+ return stage
305
+ if _goal_state.goal_type == "build_app":
306
+ return "planning"
307
+ return ""
308
+
309
+ use_thinking = _reasoning
310
+ if _goal_state.goal_type == "build_app":
311
+ _announce_task_stage(getattr(_task_state, "current_stage", "") or "planning", force=True)
312
+ prompt_result = build_agent_system_prompt(
313
+ app=app,
314
+ user_text=user_text,
315
+ goal_state=_goal_state,
316
+ task_state=_task_state,
317
+ base_system_prompt=system_prompt,
318
+ network_status=network_status,
319
+ use_thinking=use_thinking,
320
+ )
321
+ agent_system = prompt_result.system_prompt
322
+ try:
323
+ from ..events import emit as _emit_skills
324
+ _emit_skills(
325
+ "skill_selection",
326
+ turn_id=_turn_id,
327
+ candidates=prompt_result.skill_candidates,
328
+ candidate_count=len(prompt_result.skill_candidates),
329
+ selected=prompt_result.selected_skills,
330
+ selected_count=len(prompt_result.selected_skills),
331
+ selected_origins=prompt_result.selected_skill_origins,
332
+ selected_chars=prompt_result.selected_skill_chars,
333
+ )
334
+ _emit_skills(
335
+ "skill_injection",
336
+ turn_id=_turn_id,
337
+ skills=prompt_result.selected_skills,
338
+ origins=prompt_result.selected_skill_origins,
339
+ chars=prompt_result.selected_skill_chars,
340
+ count=len(prompt_result.selected_skills),
341
+ )
342
+ except Exception:
343
+ pass
344
+
345
+ messages: list[dict[str, Any]] = []
346
+
347
+ # Our agent prompt goes first — it's the most important
348
+ messages.append({"role": "system", "content": agent_system})
349
+
350
+ if _goal_state.goal_type == "question":
351
+ # Question/diagnostic turns should not replay old tool protocol.
352
+ # A one-character "?" after a failed build used to inherit the
353
+ # previous tool chain and the model resumed building instead of
354
+ # answering. Keep only recent conversational text; if evidence is
355
+ # needed, the model can inspect logs/files explicitly.
356
+ conversational = [
357
+ m
358
+ for m in composed_messages
359
+ if m.get("role") in {"user", "assistant"}
360
+ and not str(m.get("content", "")).lstrip().startswith("SYSTEM:")
361
+ ]
362
+ for m in conversational[-6:]:
363
+ messages.append(m)
364
+ elif use_thinking:
365
+ # Thinking mode: keep context shorter but preserve recent assistant responses
366
+ # Only drop very large blocks (repo structure dumps, huge tool results).
367
+ # The "large block" threshold scales with the model's real context window
368
+ # instead of a fixed 1500 chars — on a 256K window 1500 chars is ~1% of
369
+ # the budget, so the old fixed value silently stripped tool results the
370
+ # big machine had ample room to keep (and needs, to track its work).
371
+ try:
372
+ _tw = int(app.engine._target_num_ctx())
373
+ except Exception:
374
+ _tw = 0
375
+ _big_block_chars = max(1500, int(_tw * 3.5 * 0.02)) if _tw else 1500
376
+ for m in composed_messages:
377
+ if m.get("role") == "system":
378
+ continue
379
+ content = str(m.get("content", ""))
380
+ # Skip very large context blocks (repo structure, bulk retrieval results)
381
+ # but keep normal assistant responses so model remembers recent conversation
382
+ if len(content) > _big_block_chars and m.get("role") not in ("user", "assistant"):
383
+ continue
384
+ messages.append(m)
385
+ else:
386
+ for m in composed_messages:
387
+ if m.get("role") == "system":
388
+ continue # skip old system prompt — ours is authoritative
389
+ messages.append(m)
390
+
391
+ # Add current user message if not already there
392
+ if not messages or messages[-1].get("content") != user_text:
393
+ messages.append({"role": "user", "content": user_text})
394
+
395
+ # Attach any images pasted in the TUI to THIS turn's user message.
396
+ # app.ask()/compose_messages is text-only, so the base64 images ride
397
+ # on the app object (stashed by the chat screen). We rebuild the
398
+ # matching user message here — where the loop owns the message list —
399
+ # via the composer's OpenAI-compatible image_url parts. Cleared after
400
+ # so images send exactly once, and only when the model can see them.
401
+ _pending_imgs = getattr(app, "_pending_images", None)
402
+ if _pending_imgs:
403
+ app._pending_images = []
404
+ _profile = getattr(app, "profile", None)
405
+ if _profile is not None and getattr(_profile, "supports_vision", False):
406
+ try:
407
+ from ..composer import _build_user_message
408
+ _provider = getattr(getattr(app, "config", None).runtime, "provider", "llama_cpp")
409
+ for _i in range(len(messages) - 1, -1, -1):
410
+ if (messages[_i].get("role") == "user"
411
+ and messages[_i].get("content") == user_text):
412
+ messages[_i] = _build_user_message(
413
+ user_text, list(_pending_imgs), _profile, _provider
414
+ )
415
+ break
416
+ except Exception:
417
+ # Never let image plumbing break a normal text turn.
418
+ pass
419
+
420
+ full_response: list[str] = []
421
+ start_time = time.time()
422
+ tools_called: list[str] = []
423
+ changed_files: list[str] = []
424
+ bash_history: list[tuple[str, str]] = []
425
+ _turn_prompt_tokens = 0
426
+ _turn_completion_tokens = 0
427
+ _turn_total_tokens = 0
428
+ loop_detected = False
429
+ _hook_state = TurnState(
430
+ user_text=user_text,
431
+ goal_state=_goal_state,
432
+ task_state=_task_state,
433
+ changed_files=changed_files,
434
+ bash_history=bash_history,
435
+ tools_called=tools_called,
436
+ )
437
+ hook_before_turn(_hook_state)
438
+ # Per-turn tool-output budget (in chars) scales with the model's REAL
439
+ # context window instead of a fixed cap: allow tool results to fill up to
440
+ # ~35% of the window before further output this turn is truncated. Dynamic
441
+ # per RAM/model — a 256K window keeps far more than a 64K one. A single
442
+ # fixed cap starves big machines; the floor keeps small windows usable, and
443
+ # within-turn overflow is still caught by the window-aware compaction pass
444
+ # at the top of each round.
445
+ try:
446
+ _ctx_tokens_turn = int(app.engine._target_num_ctx())
447
+ except Exception:
448
+ _ctx_tokens_turn = 0
449
+ _aggregate_budget = (
450
+ max(MAX_AGGREGATE_PER_TURN, int(_ctx_tokens_turn * 3.5 * 0.35))
451
+ if _ctx_tokens_turn
452
+ else MAX_AGGREGATE_PER_TURN
453
+ )
454
+ _completion_gate_retries = 0
455
+ _MAX_COMPLETION_GATE_RETRIES = 1
456
+ # Open-todo completion discipline: don't let a long task end while the
457
+ # model's own todo list still has open items. Bounded overall, plus a
458
+ # diminishing-returns guard (stop nagging if the open-count stops falling).
459
+ _todo_continue_count = 0
460
+ _MAX_TODO_CONTINUATIONS = 15
461
+ _todo_stuck_count = 0
462
+ _last_todo_remaining = 10**9
463
+ _edit_recovery_nudges = 0
464
+ _next_round_policy: NextRoundPolicy | None = None
465
+ _content_length_recoveries = 0
466
+ _content_recovery_active = False
467
+ _MAX_CONTENT_LENGTH_RECOVERIES = 3
468
+ _MAX_CONSECUTIVE_CORRECTIONS = 2
469
+ _generic_correction_nudges = 0
470
+ # Fires at most once per turn when an identical failing call crosses the
471
+ # hard-stop threshold — bypasses the soft correction cap above.
472
+ _hard_stop_nudge_fired = False
473
+ # Per-tool caps removed 2026-04-26 (commits ce8a714 → this one).
474
+ # Telemetry showed `3-in-a-row` exact-repeat guard fired 0 times
475
+ # ever in observed sessions; `same-tool > 10` fired once and it
476
+ # was a false-positive on legitimate iterative data analysis;
477
+ # `file-edit > 3` never appeared. Keeping them was paying
478
+ # bookkeeping cost on every tool call to catch nothing real, and
479
+ # cutting legitimate work when they did fire. Matches agent /
480
+ # terminal coding tools pattern: rely on user Ctrl+C plus the targeted
481
+ # surviving guards (thinking caps, empty-round nudge,
482
+ # investigation-spin / looks-fine nudges) for loop termination.
483
+
484
+ # Per-turn counter of auto-recovered empty rounds. Caps how many
485
+ # times we'll nudge a stream that ended with just thinking (no
486
+ # content, no tool calls) before giving up and ending the turn
487
+ # with a visible message. Recovery logic + nudge text live in
488
+ # agent/recovery.py (T0.1-d split).
489
+ _empty_rounds_this_turn = 0
490
+ _MAX_EMPTY_ROUND_RETRIES = MAX_EMPTY_ROUND_RETRIES
491
+ # Loop-abort decode-recovery: how many times this turn a detected reasoning
492
+ # loop forced a no-thinking retry. Capped so a model that keeps degenerating
493
+ # even without the reasoning channel can't spin the turn forever.
494
+ _thinking_loop_recoveries = 0
495
+ _MAX_THINKING_LOOP_RECOVERIES = 2
496
+ _last_round_signature: tuple[int, str] | None = None
497
+ _same_round_signature_count = 0
498
+ _same_round_synthetic_rejections = 0
499
+ _MAX_SAME_ROUND_REJECTIONS = 6 # 3 rejections earn a nudge; 6 (3 ignored nudges) end the turn
500
+ _MUTATING_TOOLS = frozenset({
501
+ "write_file",
502
+ "append_file",
503
+ "edit_file",
504
+ "multi_edit",
505
+ "edit_diff",
506
+ })
507
+
508
+ # ── Investigation-spin detector ──
509
+ # The 20-round "let me check the CSS / let me check the JS / everything
510
+ # looks fine / let me check…" pathology, observed 2026-04-26 on a
511
+ # browser-rendering bug the agent could never see (no DOM access, no
512
+ # screenshots). The existing loop-breakers don't trip:
513
+ # - identical-3-in-a-row guard: each read_file is a different path
514
+ # - same-tool > N: read_file count was 7, under the 10 cap
515
+ # …so the agent kept reading until MAX_ROUNDS hit at round 20.
516
+ #
517
+ # New detector: count rounds where (a) only read-only tools were used
518
+ # AND (b) content matched a "looks fine"-shaped phrase. After 3 such
519
+ # rounds in a row (or 5 read-only rounds total, "looks fine" or not),
520
+ # inject ONE synthetic user message telling the model to either
521
+ # commit to a concrete change or ask the user a focused question, and
522
+ # forbid further read-only investigation this turn. One nudge per
523
+ # turn — if the model ignores it, the next round's natural exit
524
+ # path applies.
525
+ _READONLY_TOOLS = frozenset({
526
+ "read_file", "grep", "glob", "list_files", "web_fetch", "web_search",
527
+ })
528
+ _LOOKS_FINE_RE = re.compile(
529
+ r"\b(?:looks?\s+(?:fine|correct|good|ok)|seems?\s+(?:fine|correct|good)"
530
+ r"|works?\s+fine|(?:load|run|work|render)(?:s|ing)?\s+(?:fine|correctly)"
531
+ r"|checks?\s+out|all\s+(?:good|correct|fine)"
532
+ r"|everything\s+(?:looks?|seems?)\s+(?:fine|correct|good|ok))\b",
533
+ re.IGNORECASE,
534
+ )
535
+ _readonly_streak = 0
536
+ _looks_fine_streak = 0
537
+ _spin_nudge_done = False
538
+ # Why did the loop exit? Set right before each `break` so the
539
+ # turn_end event records the actual exit path. Without this, we
540
+ # had to guess from indirect signals (last round_end finish_reason,
541
+ # tool counts, etc.) and there was no way to tell whether a stall
542
+ # guard, exception, or user cancel ended it.
543
+ _loop_exit_reason: str = ""
544
+
545
+ # Read-dedup state: maps path → round_idx of last read so we can
546
+ # detect "model is re-reading the same file with no edit between."
547
+ # Modified-set tracks any path the model wrote / edited / multi-
548
+ # edited this turn — that invalidates a prior read (file changed).
549
+ # Both reset per turn (this loop body is per-turn).
550
+ _tool_exec_state = ToolExecutionState(changed_files=changed_files)
551
+ _read_file_chars_this_turn = 0
552
+ _edit_failures_this_turn = 0
553
+ _write_existing_rejections_this_turn = 0
554
+ _edit_context_seen = False
555
+ # ── Semantic-churn counters (see recovery.detect_churn) ──
556
+ # Per-turn signals the byte-identical breakers miss:
557
+ # _file_write_counts path → # write/edit/append calls (any content)
558
+ # _command_fail_counts cmd-family-token → # failed runs
559
+ # _readonly_streak (defined above) is reused as the spin signal.
560
+ # _churn_nudge_done caps the churn nudge to ONE per turn so we don't
561
+ # pile SYSTEM messages on a model that's already mid-recovery.
562
+ _file_write_counts: dict[str, int] = {}
563
+ _command_fail_counts: dict[str, int] = {}
564
+ _churn_nudge_done = False
565
+ _last_churn_mode = ""
566
+ # Planning-without-progress streak (recovery.detect_churn PLANNING_SPIN):
567
+ # consecutive rounds that changed no NEW file, ran no build/verify, and
568
+ # produced thinking/narration. Catches a model re-deriving the same plan
569
+ # round after round — which readonly-spin misses, because think-only
570
+ # rounds (zero tools) reset the read-only streak. Resets to 0 the moment
571
+ # a round changes a file or runs a build.
572
+ _planning_streak = 0
573
+ # ── Plateau detector (HarnessBench 2026-09-08: 043/086/087 hit the 20 min
574
+ # cap while the oracle score had already reached what other frontends got
575
+ # by STOPPING). A round makes progress only if it (a) changed a path not
576
+ # previously changed this turn, (b) turned a failing build/test into a
577
+ # passing one (or produced the first pass), or (c) completed a todo item.
578
+ # Re-edits of already-edited files, reads/greps, identical or failing test
579
+ # runs and /tmp experiments are NOT progress — but a failing check whose
580
+ # failure set SHRANK or CHANGED to something unseen this turn is (a real
581
+ # debugging loop). 6 no-progress rounds → one wrap-up nudge naming the open
582
+ # todo items; 8 more after that → end the turn honestly, unless a check
583
+ # passed in the last 2 rounds (then one "finish now" nudge + 3 rounds).
584
+ # Never fires in the first PLATEAU_GRACE_ROUNDS rounds. Bookkeeping lives
585
+ # in agent/plateau.py so this stays a few lines here.
586
+ from .plateau import PlateauTracker as _PlateauTracker
587
+ _plateau = _PlateauTracker()
588
+ _round_build_outcomes: list[bool] = []
589
+ _round_build_outputs: list[str] = []
590
+
591
+ def _open_todo_labels() -> list[str]:
592
+ labels = [
593
+ str((t or {}).get("content") or "").strip()
594
+ for t in list(getattr(getattr(app, "session", None), "todos", []) or [])
595
+ if str((t or {}).get("status", "")).lower() != "completed"
596
+ ]
597
+ return [t for t in labels if t]
598
+
599
+ def _todo_progress_snapshot() -> tuple[int, int]:
600
+ todos = list(getattr(getattr(app, "session", None), "todos", []) or [])
601
+ done = sum(1 for t in todos if str((t or {}).get("status", "")).lower() == "completed")
602
+ return len(todos) - done, done
603
+ # Build-verification STOP gate: a true completion gate (claude-code
604
+ # query.ts stop-hook pattern). When the model tries to END a build_app turn
605
+ # that changed code, we run the project's real typecheck/test and, if it
606
+ # reports errors, inject them and FORCE another round instead of accepting
607
+ # "done". Bounded by _MAX_BUILD_VERIFY_RETRIES so a genuinely unfixable
608
+ # project can't spin forever; each retry RE-RUNS the check so the gate keeps
609
+ # holding until the project is clean or the bound is hit.
610
+ _build_verify_nudges = 0
611
+ _reverify_runs = 0 # auto re-runs of the model's own last verification command
612
+ _MAX_BUILD_VERIFY_RETRIES = 2
613
+ # The project checker ran but could NOT produce a verdict (timeout, failed to
614
+ # execute, incomplete TypeScript coverage). Turn-level, not round-level: it
615
+ # survives into the final completion decision, because a checker that never
616
+ # answered is not evidence of a working build and an earlier unrelated
617
+ # passing command in the verification registry must not stand in for it.
618
+ from .project_check_gate import ProjectCheckGate as _ProjectCheckGate
619
+ _project_check_gate = _ProjectCheckGate()
620
+ from .hollow_module import HollowModuleGate as _HollowModuleGate
621
+ _hollow_gate = _HollowModuleGate()
622
+ # Reject → re-read → reject spin: ONE actionable redirect per turn
623
+ # (recovery.detect_reject_reread_loop, recomputed from the transcript).
624
+ _reject_reread_nudge_done = False
625
+ # Last synthetic-nudge kind we injected, so the recovery ladder never fires
626
+ # the SAME nag twice in a row (claude-code attachments.ts two-and-threshold
627
+ # discipline). Routed through `_append_nudge` below.
628
+ _last_nudge_kind = ""
629
+
630
+ def _append_nudge(content: str, kind: str, *, ephemeral: bool = True) -> bool:
631
+ """Append a synthetic SYSTEM nudge unless the SAME `kind` was the
632
+ immediately-previous nudge (never the same nag twice running — the
633
+ two-and-threshold discipline). Records the injected index for
634
+ end-of-turn ephemeral cleanup. Returns True when it actually appended.
635
+ """
636
+ nonlocal _last_nudge_kind
637
+ if kind and kind == _last_nudge_kind:
638
+ return False
639
+ messages.append({"role": "user", "content": content})
640
+ if ephemeral:
641
+ _ephemeral_nudge_indices.append(len(messages) - 1)
642
+ _last_nudge_kind = kind
643
+ return True
644
+ # Cross-round repeated-call breaker: count identical (tool, args) calls
645
+ # across the WHOLE turn (the in-round breaker only catches one round).
646
+ # NUDGE-only — never withholds a tool result (avoids the read-dedup-stub
647
+ # starvation regression). One nudge per turn.
648
+ _turn_call_sigs: dict[tuple, int] = {}
649
+ _xround_repeat_nudge_done = False
650
+ # Generalized dedup for cacheable read-only investigations:
651
+ # list_files / glob / grep with identical args within a turn return
652
+ # the same content (modulo external file-system changes we can't
653
+ # observe). Key is (tool_name, canonical_args_json) → round_num so
654
+ # the dedup-stub can name the round the model already ran this. Any
655
+ # write/edit invalidates the WHOLE map — cheap, safe, less granular
656
+ # than per-path tracking but list_files/glob/grep can target
657
+ # directories whose contents change after a write.
658
+ # Indices of nudge messages we inject into `messages` so we can
659
+ # strip them BEFORE this turn returns. Without this, every nudge
660
+ # ("STOP investigating, do NOT call read_file…", "your last
661
+ # response had no action — try again") becomes a PERMANENT
662
+ # SYSTEM-prefixed user message in the conversation history, and
663
+ # subsequent turns inherit those instructions. Real failure mode
664
+ # observed 2026-04-26: a session accumulated 6+ spin nudges and
665
+ # 4+ stall nudges, then "continue" produced 540 chars of "Now let
666
+ # me update X" with ZERO tool calls — the model was obeying the
667
+ # accumulated "do not call read tools" instructions, not the
668
+ # current user intent. Ephemeral cleanup at end-of-turn.
669
+ _ephemeral_nudge_indices: list[int] = []
670
+ # The PURE read-only streak threshold moved to
671
+ # constants.CHURN_READONLY_STREAK_LIMIT (6) and is enforced by the
672
+ # semantic-churn detector — tighter than the old local value of 10,
673
+ # which let an investigation spin run 5+ minutes before tripping.
674
+ # `_looks_fine_streak` keeps its own threshold here because it's a
675
+ # higher-confidence, distinctly-framed signal ("X looks fine, let me
676
+ # check Y" repeated) that warrants its specific runtime-bug nudge.
677
+ _MAX_LOOKS_FINE_STREAK = 3
678
+ _deterministic_launch_done = False
679
+ _verified_launch_summary = ""
680
+ _observed_ttft_ms = 0
681
+
682
+ # Open todos mean the model is mid-BUILD. It may start a preview dev server,
683
+ # but a running server is NOT task completion while the plan still has
684
+ # pending/in_progress items — taking the deterministic launch-and-finish
685
+ # fast-path here is exactly how the Anki build "completed" with 4 todos
686
+ # still open ("The app is now running" → done, at ~10% of the work). Only
687
+ # take the launcher fast-path when nothing is left on the list; otherwise
688
+ # fall through to normal agentic rounds and let the open-todo completion
689
+ # gate keep the model building until every item is done.
690
+ _open_todos_at_launch = [
691
+ t for t in list(getattr(getattr(app, "session", None), "todos", []) or [])
692
+ if str(t.get("status", "")).lower() != "completed"
693
+ ]
694
+ if _goal_state.goal_type == "run_or_launch" and not _open_todos_at_launch:
695
+ try:
696
+ from ..launcher import launch_project_app
697
+ preferred_port = int(getattr(_task_state, "active_port", 0) or 0)
698
+ launch = launch_project_app(
699
+ app.repo_root,
700
+ preferred_port=preferred_port,
701
+ open_browser=True,
702
+ )
703
+ if launch.port and hasattr(app, "store") and getattr(app, "session", None) is not None:
704
+ app.store.update_task(
705
+ app.session,
706
+ active_port=launch.port,
707
+ current_stage="verified" if launch.verified else "running",
708
+ )
709
+ try:
710
+ from ..events import emit as _emit_launch
711
+ _emit_launch(
712
+ "launcher_result",
713
+ turn_id=_turn_id,
714
+ ok=launch.ok,
715
+ verified=launch.verified,
716
+ kind=launch.kind,
717
+ root=launch.root,
718
+ command=launch.command,
719
+ port=launch.port,
720
+ url=launch.url,
721
+ log_path=launch.log_path,
722
+ message=launch.message,
723
+ browser_opened=launch.browser_opened,
724
+ browser_error=launch.browser_error,
725
+ )
726
+ except Exception:
727
+ pass
728
+ if launch.ok:
729
+ summary = (
730
+ f"The app is running and verified.\n\n"
731
+ f"URL: {launch.url}\n"
732
+ f"Command: `{launch.command}`\n"
733
+ f"Log: `{launch.log_path}`\n"
734
+ f"Browser opened: {'yes' if launch.browser_opened else 'no'}"
735
+ + (f"\nBrowser error: {launch.browser_error}" if launch.browser_error else "")
736
+ )
737
+ _render_markdown(summary, app.console if hasattr(app, 'console') else None)
738
+ full_response.append(summary)
739
+ _loop_exit_reason = "verified_run_or_launch"
740
+ _deterministic_launch_done = True
741
+ _verified_launch_summary = summary
742
+ messages.append({
743
+ "role": "user",
744
+ "content": (
745
+ "SYSTEM: Deterministic launcher could not complete this run_or_launch turn.\n"
746
+ f"Launcher result: {launch.message}\n"
747
+ "Inspect the project minimally, then run exactly one explicit launch command, "
748
+ "verify with one probe, and report the concrete URL. Do not loop on repeated "
749
+ "npm start / package edits."
750
+ ),
751
+ })
752
+ _ephemeral_nudge_indices.append(len(messages) - 1)
753
+ except Exception as _launcher_err:
754
+ try:
755
+ from ..events import emit as _emit_launch_err
756
+ _emit_launch_err("launcher_error", turn_id=_turn_id, error=str(_launcher_err))
757
+ except Exception:
758
+ pass
759
+
760
+ # ── Main loop ──
761
+ # MAX_ROUNDS=0 → no hard cap (agent pattern). The
762
+ # targeted safety nets above catch real failure modes; round
763
+ # counting was emergency insurance with no observed claims.
764
+ # `itertools.count()` gives an unbounded loop; positive
765
+ # MAX_ROUNDS still works for eval / batch use cases.
766
+ import itertools as _itertools
767
+ _configured_max_rounds = max(0, int(getattr(app.config.runtime, "max_rounds", 0) or 0))
768
+ _effective_max_rounds = _configured_max_rounds or MAX_ROUNDS
769
+ _round_iter = (
770
+ () if _deterministic_launch_done else
771
+ _itertools.count() if _effective_max_rounds <= 0 else range(_effective_max_rounds)
772
+ )
773
+ for round_num in _round_iter:
774
+ # Cancel check — user typed "stop" while the turn was running.
775
+ # Poll at round boundary so an in-flight stream can finish rather
776
+ # than leaving a partial corrupt response in history.
777
+ if getattr(app, "cancel_requested", False):
778
+ out.print_info("Stopped by user.")
779
+ _loop_exit_reason = "user_cancel"
780
+ break
781
+
782
+ # Signal TUI that we're starting generation.
783
+ out.start_thinking()
784
+
785
+ # Conversation compaction: if the running prompt is about to
786
+ # crowd out the context window, mutate `messages` in place with
787
+ # a summarized version before the next model call. See
788
+ # src/localcode/compaction.py for the minimal-agent-inspired design.
789
+ try:
790
+ from ..compaction import should_compact, compact, estimate_tokens
791
+ # Authoritative source: ask the runtime gateway what context
792
+ # size the server was actually launched with. The previous
793
+ # `getattr(..., "llama_cpp_ctx_size", 0) or 32768` fallback
794
+ # was wrong on every modern setup — turbo mode on a 16 GB
795
+ # Mac runs at 65536 (see runtime.py `_target_num_ctx`),
796
+ # which meant `should_compact` triggered at ~50% of real
797
+ # utilization and we summarized the conversation HALF AS
798
+ # OFTEN AS NEEDED, NO — TWICE AS OFTEN AS NEEDED, throwing
799
+ # away useful recent history. Read the real value.
800
+ try:
801
+ ctx_tokens = int(app.engine._target_num_ctx())
802
+ except Exception:
803
+ ctx_tokens = (
804
+ int(getattr(app.config.runtime, "llama_cpp_ctx_size", 0))
805
+ or 65536
806
+ )
807
+ if should_compact(messages, context_window=ctx_tokens):
808
+ # RAM-tier the summary: capable machines spend a model
809
+ # generation for a rich summary; small machines fall back
810
+ # to an instant deterministic one (compact() decides).
811
+ try:
812
+ _ram_gb = int(app.engine._system_ram_gb())
813
+ except Exception:
814
+ _ram_gb = None
815
+ out.print_info(
816
+ f"Compacting conversation (≈{estimate_tokens(messages)} tokens "
817
+ f"of {ctx_tokens} context → summary)..."
818
+ )
819
+ messages[:] = compact(
820
+ messages, app.engine, context_window=ctx_tokens, ram_gb=_ram_gb
821
+ )
822
+ except Exception as _compact_err:
823
+ # Never let compaction failure kill the agent loop — continue
824
+ # with the unchanged messages and let the user see the error.
825
+ out.print_info(f"(compaction skipped: {_compact_err})")
826
+
827
+ # Per-round diagnostic state. Populated as the stream runs;
828
+ # flushed via `round_end` after the round terminates so we can
829
+ # correlate "what the model did" with "what we then did" without
830
+ # guessing. Without this, debugging an agent-loop misbehaviour
831
+ # means re-running with `--debug` and watching live — these
832
+ # fields make the events.jsonl trace self-contained.
833
+ _round_finish_reason = ""
834
+ _round_raw_tail = ""
835
+ _round_content_chars = 0
836
+ _round_reasoning_chars = 0
837
+ _round_pending_tool_count = 0
838
+ # Per-round timing breakdown so events.jsonl can answer
839
+ # "where does the time go?" without guessing. Three buckets:
840
+ # ttft_ms — request-start → first token (prompt eval)
841
+ # decode_ms — first token → end of stream (token gen)
842
+ # tool_exec_ms — sum of wall-time for tools in this round
843
+ # Sum of these ≤ duration_ms; the remainder is bridge / agent-
844
+ # loop overhead. Captured from stream_done + the tool dispatch
845
+ # loop below.
846
+ _round_ttft_ms = 0
847
+ _round_decode_ms = 0
848
+ _round_tool_exec_ms = 0
849
+ _round_started_at = time.monotonic()
850
+ # Snapshots for the planning-without-progress signal: how many files
851
+ # were changed and how many bash commands had run BEFORE this round.
852
+ # Compared after the round's tools run to decide if this round made
853
+ # concrete progress (new file / build) or was pure (re-)planning.
854
+ _changed_files_at_round_start = len(changed_files)
855
+ _bash_history_at_round_start = len(bash_history)
856
+ _todos_at_round_start = _todo_progress_snapshot()
857
+ _round_build_outcomes = []
858
+ _round_build_outputs = []
859
+ round_task_stage = _current_task_stage_for_thinking()
860
+ round_use_thinking = should_use_thinking(
861
+ app.config.runtime.laptop_26b_runtime_mode,
862
+ app.config.runtime.internal_thinking_mode,
863
+ goal_type=_goal_state.goal_type,
864
+ task_stage=round_task_stage,
865
+ user_text=user_text,
866
+ )
867
+ from ..reasoning_capabilities import reasoning_capabilities
868
+ _reasoning_caps = reasoning_capabilities(
869
+ str(getattr(app.config.runtime, "model", "")),
870
+ str(getattr(app.config.runtime, "provider", "llama_cpp")),
871
+ )
872
+ _requested_thinking = bool(round_use_thinking)
873
+ round_use_thinking = bool(round_use_thinking and _reasoning_caps.supported)
874
+ # Single source of truth for this round's decode mode. Normal rounds
875
+ # carry the round's own thinking decision; a recovery round scheduled by
876
+ # the PREVIOUS round (a reasoning-loop abort) carries use_thinking=False,
877
+ # which decodes without the reasoning channel and breaks the attractor.
878
+ # A text nudge alone re-enters the same attractor (same prompt/samplers/
879
+ # template) — changing the decode is what actually escapes it. Fault
880
+ # recovery only; it does not reinterpret the user's `on` setting.
881
+ _round_policy = _next_round_policy or NextRoundPolicy(round_use_thinking)
882
+ _next_round_policy = None
883
+ round_use_thinking = _round_policy.use_thinking
884
+ try:
885
+ _ctx_chars = None
886
+ try:
887
+ _nctx = app.engine._target_num_ctx()
888
+ if _nctx:
889
+ _ctx_chars = int(_nctx * 3.5) # tokens → ~chars
890
+ except Exception:
891
+ _ctx_chars = None
892
+ # ── Append-only transcript between DISCRETE compactions ──
893
+ # (codex/opencode/pi/claude-code pattern). The old behavior ran the
894
+ # full shrink pass EVERY round with a moving age boundary: each
895
+ # round another message crossed "old" and got stubbed, changing
896
+ # bytes near the START of the prompt, so llama.cpp's prefix cache
897
+ # missed and the ENTIRE conversation re-prefilled every round. On a
898
+ # measured 55-min build that was 77% of wall-clock (TTFT grew from
899
+ # 3 s at round 3 to 95 s at round 53 — pure re-prefill). All four
900
+ # reference harnesses append-only within a turn and compact as a
901
+ # discrete event; on a LOCAL model, where prefill is the dominant
902
+ # cost, the prefix cache is the single biggest speed lever.
903
+ #
904
+ # Trigger is dynamic (window-derived, RAM-scaled upstream): compact
905
+ # when the serialized transcript passes ~55% of the context window
906
+ # (headroom for the trailing ephemeral block + generation). The
907
+ # compaction is DURABLE (messages[:] = shrunk) so subsequent rounds
908
+ # are again byte-stable prefixes — one cache miss per compaction,
909
+ # zero per round.
910
+ _trigger_bytes = int((_ctx_chars or 250_000) * 0.55)
911
+ if _msg_bytes(messages) > _trigger_bytes:
912
+ # Mid-turn nudges are per-round steering the model has already
913
+ # consumed; their stored INDICES are only valid on the
914
+ # uncompacted list, so strip them first and reset bookkeeping.
915
+ if _ephemeral_nudge_indices:
916
+ strip_ephemeral_nudges(messages, _ephemeral_nudge_indices)
917
+ _ephemeral_nudge_indices.clear()
918
+ messages[:] = _prepare_model_messages(
919
+ messages, ctx_window_chars=_ctx_chars,
920
+ observed_ttft_ms=_observed_ttft_ms,
921
+ )
922
+ try:
923
+ from ..events import emit as _emit_compact
924
+ _emit_compact("discrete_compaction", turn_id=_turn_id,
925
+ round_idx=round_num,
926
+ bytes_after=_msg_bytes(messages),
927
+ trigger_bytes=_trigger_bytes)
928
+ except Exception:
929
+ pass
930
+ model_messages = hook_before_model(list(messages), _hook_state)
931
+ # Inject a window-scaled progress ledger (Codex-style tool-state
932
+ # awareness): the model always sees what it has already done, so it
933
+ # stops re-reading files / restarting from scratch. Built from
934
+ # DURABLE loop state (not the compacted messages), so it survives
935
+ # compaction — which is exactly what a small-RAM machine needs.
936
+ # Appended LAST (after the cached history) to minimize prefix-cache
937
+ # invalidation; ephemeral (model_messages is a per-round copy).
938
+ try:
939
+ from ..model_config import progress_ledger_budget_chars
940
+ _win_tokens = int((_ctx_chars or 0) / 3.5)
941
+ _ledger = build_progress_ledger(
942
+ changed_files,
943
+ bash_history,
944
+ list(getattr(_tool_exec_state, "files_read", {}) or {}),
945
+ progress_ledger_budget_chars(_win_tokens),
946
+ )
947
+ except Exception:
948
+ _ledger = ""
949
+ # Working-memory checklist (todo_write): model sees its own plan.
950
+ _todo_note = ""
951
+ try:
952
+ from ..tools.todo_write import render_todo_reminder
953
+ _todo_note = render_todo_reminder(list(getattr(app.session, "todos", []) or []))
954
+ except Exception:
955
+ _todo_note = ""
956
+ # Filesystem reconciliation (pi/codex/opencode pattern): the actual
957
+ # files on disk, scanned in CODE each round. Ground truth the model
958
+ # can't forget or hallucinate — kills the "re-check what exists /
959
+ # restart" loop that memory-based tracking couldn't.
960
+ _fs_state = ""
961
+ try:
962
+ from .context import build_filesystem_state
963
+ _fs_state = build_filesystem_state(changed_files)
964
+ except Exception:
965
+ _fs_state = ""
966
+ # Inject ledger + fs-state + todo as ONE trailing ephemeral message.
967
+ # Role matters and is model-dependent:
968
+ # - Gemma keeps role:system — as a user turn it re-greeted every round.
969
+ # - Qwen 3.x's chat template raises "System message must be at the
970
+ # beginning" on ANY non-leading system message → HTTP 500 every
971
+ # round once this block is non-empty (verified live: identical
972
+ # content returns 500 as `system`, 200 as `user`). Those families
973
+ # must receive it as a user turn instead.
974
+ # TODO: replace the family gate with a real per-template capability
975
+ # probe if more families turn out to reject trailing system messages.
976
+ try:
977
+ _ctx_block = "\n\n".join(b for b in (_ledger, _fs_state, _todo_note) if b)
978
+ if _ctx_block:
979
+ _ctx_role = ephemeral_context_role(
980
+ getattr(_reasoning_caps, "family", "")
981
+ )
982
+ model_messages = list(model_messages) + [
983
+ {"role": _ctx_role, "content": _ctx_block}
984
+ ]
985
+ except Exception:
986
+ pass
987
+ from ..compaction import normalize_tool_protocol, protocol_errors
988
+ normalize_tool_protocol(model_messages)
989
+ _protocol_errors = protocol_errors(model_messages)
990
+ if _protocol_errors:
991
+ raise ValueError(
992
+ "invalid tool transcript before generation: "
993
+ + "; ".join(_protocol_errors[:5])
994
+ )
995
+ round_tool_schemas = schemas_for_goal(
996
+ _goal_state.goal_type,
997
+ user_text,
998
+ task_stage=round_task_stage,
999
+ )
1000
+ # Don't hand `todo_write` to a greeting. The prompt's carve-out is
1001
+ # phrased in task sizes and never says "this turn isn't a task", so
1002
+ # a small model opens a plan for "hi" - and once one exists, the
1003
+ # open-todo gate below refuses to let the turn end. Withholding the
1004
+ # tool is a mechanism rather than more persuasion. Still offered
1005
+ # when todos already exist, so a follow-up "ok" can close them out.
1006
+ if is_trivial_turn(user_text) and not (getattr(app.session, "todos", None) or []):
1007
+ round_tool_schemas = [
1008
+ _s for _s in round_tool_schemas
1009
+ if ((_s.get("function") or {}).get("name")) != "todo_write"
1010
+ ]
1011
+ try:
1012
+ app._tool_content_max_chars = None
1013
+ except Exception:
1014
+ pass
1015
+ round_tool_names = [
1016
+ str(((schema.get("function") or {}).get("name") or ""))
1017
+ for schema in round_tool_schemas
1018
+ ]
1019
+ # Pre-stream snapshot of what we're sending. Char count +
1020
+ # message count are cheap and tell us if the prompt was
1021
+ # truncated by upstream compaction in a way that would
1022
+ # explain a cold response.
1023
+ try:
1024
+ from ..events import emit as _emit_round
1025
+ _emit_round(
1026
+ "round_start",
1027
+ turn_id=_turn_id,
1028
+ round_idx=round_num,
1029
+ messages_count=len(model_messages),
1030
+ prompt_chars=sum(
1031
+ len(str(m.get("content") or "")) for m in model_messages
1032
+ ),
1033
+ use_thinking=bool(round_use_thinking),
1034
+ requested_thinking=_requested_thinking,
1035
+ effective_thinking=bool(round_use_thinking),
1036
+ reasoning_family=_reasoning_caps.family,
1037
+ reasoning_control=_reasoning_caps.control.value,
1038
+ thinking_budget_tokens=int(
1039
+ getattr(app.config.runtime, "thinking_budget_tokens", 0) or 0
1040
+ ),
1041
+ recovery_reason=_round_policy.recovery_reason,
1042
+ recovery_attempt=_round_policy.recovery_attempt,
1043
+ max_output_tokens=int(MAX_OUTPUT_TOKENS),
1044
+ tool_schema_count=len(round_tool_schemas),
1045
+ tool_schemas=round_tool_names,
1046
+ )
1047
+ _emit_round(
1048
+ "generation_started",
1049
+ turn_id=_turn_id,
1050
+ round_idx=round_num,
1051
+ requested_thinking=_requested_thinking,
1052
+ effective_thinking=bool(round_use_thinking),
1053
+ reasoning_family=_reasoning_caps.family,
1054
+ )
1055
+ except Exception:
1056
+ pass
1057
+ _stream_result = stream_model_round(
1058
+ app,
1059
+ out,
1060
+ model_messages,
1061
+ round_use_thinking=round_use_thinking,
1062
+ retry_messages=messages,
1063
+ tool_schemas=round_tool_schemas,
1064
+ recovery_mode="",
1065
+ stream_policy=_goal_state.goal_type,
1066
+ )
1067
+ _round_finish_reason = _stream_result.finish_reason
1068
+ _round_raw_tail = _stream_result.raw_tail
1069
+ _round_content_chars = _stream_result.content_chars
1070
+ _round_reasoning_chars = _stream_result.reasoning_chars
1071
+ _round_pending_tool_count = _stream_result.pending_tool_count
1072
+ _round_ttft_ms = _stream_result.ttft_ms
1073
+ _observed_ttft_ms = _round_ttft_ms
1074
+ _round_decode_ms = _stream_result.decode_ms
1075
+ _stream_tool_calls = _stream_result.tool_calls
1076
+ _primary_round_tool = (
1077
+ getattr(_stream_result, "limited_tool_name", "") or ""
1078
+ )
1079
+ if not _primary_round_tool and _stream_tool_calls:
1080
+ try:
1081
+ _primary_round_tool = str(
1082
+ ((_stream_tool_calls[0].get("function") or {}).get("name") or "")
1083
+ )
1084
+ except Exception:
1085
+ _primary_round_tool = ""
1086
+ _round_signature = (_round_content_chars, _primary_round_tool)
1087
+ if _primary_round_tool and _round_signature == _last_round_signature:
1088
+ _same_round_signature_count += 1
1089
+ else:
1090
+ _last_round_signature = _round_signature
1091
+ _same_round_signature_count = 1 if _primary_round_tool else 0
1092
+ _turn_prompt_tokens += int(_stream_result.prompt_tokens or 0)
1093
+ _turn_completion_tokens += int(_stream_result.completion_tokens or 0)
1094
+ _turn_total_tokens += int(
1095
+ _stream_result.total_tokens
1096
+ or ((_stream_result.prompt_tokens or 0) + (_stream_result.completion_tokens or 0))
1097
+ )
1098
+ if _round_policy.recovery_reason and not _stream_result.thinking_abort:
1099
+ try:
1100
+ from ..events import emit as _emit_recovered
1101
+ _emit_recovered(
1102
+ "recovery_completed",
1103
+ reason=_round_policy.recovery_reason,
1104
+ attempt=_round_policy.recovery_attempt,
1105
+ finish_reason=_stream_result.finish_reason,
1106
+ )
1107
+ except Exception:
1108
+ pass
1109
+ if _content_recovery_active and _stream_result.finish_reason != "length":
1110
+ try:
1111
+ from ..events import emit as _emit_content_recovered
1112
+ _emit_content_recovered(
1113
+ "recovery_completed",
1114
+ reason="max_output_tokens",
1115
+ attempts=_content_length_recoveries,
1116
+ finish_reason=_stream_result.finish_reason,
1117
+ )
1118
+ except Exception:
1119
+ pass
1120
+ _content_recovery_active = False
1121
+ except KeyboardInterrupt:
1122
+ out.print_info("Interrupted.")
1123
+ _loop_exit_reason = "stream_interrupt"
1124
+ break
1125
+ except Exception as exc:
1126
+ from ..errors import format_for_user
1127
+ out.set_error(format_for_user(exc, fallback_code="E3102"))
1128
+ _loop_exit_reason = f"stream_error:{type(exc).__name__}"
1129
+ break
1130
+
1131
+ # If the server-side reasoning budget could not transition the model,
1132
+ # recover a periodic loop automatically; report other cap aborts.
1133
+ if _stream_result.thinking_abort:
1134
+ try:
1135
+ from ..events import emit as _emit_ta
1136
+ _emit_ta(
1137
+ "thinking_abort",
1138
+ round_idx=round_num,
1139
+ reason=_stream_result.thinking_abort_reason or "unknown",
1140
+ )
1141
+ except Exception:
1142
+ pass
1143
+ _abort_reason = _stream_result.thinking_abort_reason or "unknown"
1144
+ _is_loop = _abort_reason == "loop"
1145
+ if _thinking_loop_recoveries < _MAX_THINKING_LOOP_RECOVERIES:
1146
+ # The model over-reasoned WITHOUT emitting a response — either a
1147
+ # detected periodic loop OR it blew the time/char cap. BOTH are
1148
+ # recoverable and BOTH used to be handled differently (loop
1149
+ # retried, cap hard-failed and threw the whole turn away). Now
1150
+ # they share one path: drop the aborted (empty) round entirely
1151
+ # (never append its assistant message) and re-run the SAME round
1152
+ # with thinking OFF (consumed at the top of the loop), which
1153
+ # forces the model to ACT instead of planning forever. This is
1154
+ # DECODE recovery, not an optional nudge — critical for slow
1155
+ # dense models (Qwen 3.8 27B Q8 ~17 tok/s) where legitimate
1156
+ # planning can brush the time cap without any real loop.
1157
+ _thinking_loop_recoveries += 1
1158
+ _rec_reason = "reasoning_loop" if _is_loop else "reasoning_cap"
1159
+ _next_round_policy = _round_policy.recover_without_thinking(_rec_reason)
1160
+ try:
1161
+ from ..events import emit as _emit_recovery
1162
+ _emit_recovery("generation_aborted", reason=_rec_reason, round_idx=round_num)
1163
+ _emit_recovery("recovery_scheduled", reason=_rec_reason, mode="no_thinking",
1164
+ attempt=_thinking_loop_recoveries)
1165
+ except Exception:
1166
+ pass
1167
+ out.notice(
1168
+ "Model reasoning started repeating itself — retrying this "
1169
+ "step without deep reasoning so it acts instead of looping."
1170
+ if _is_loop else
1171
+ "Model kept reasoning without acting — retrying this step "
1172
+ "without deep reasoning so it produces a response."
1173
+ )
1174
+ out._stop_indicator()
1175
+ sys.stdout.write("\r\033[K")
1176
+ sys.stdout.flush()
1177
+ continue # next round: decodes with think=False
1178
+ # Recovery budget exhausted — the model kept over-reasoning even
1179
+ # without the reasoning channel. End the turn honestly.
1180
+ out.notice(
1181
+ "Model kept repeating itself even without deep reasoning — "
1182
+ "ending the turn. Try `/model` to switch models or split the "
1183
+ "task into smaller steps."
1184
+ if _is_loop else
1185
+ "Model kept reasoning without producing a response even with "
1186
+ "deep reasoning disabled — ending the turn. Try `/model` for a "
1187
+ "faster model, `/thinking off`, or split the task into smaller steps."
1188
+ )
1189
+ _loop_exit_reason = "thinking_loop_exhausted"
1190
+ break
1191
+
1192
+ # Show thinking summary if present (collapsed, dim)
1193
+ # Skip if already emitted when content started streaming
1194
+ finish_thinking_display(_stream_result, out)
1195
+
1196
+ content = _stream_result.content
1197
+ tool_calls = _stream_result.tool_calls
1198
+
1199
+ # Recover tool calls the model emitted but the server failed to parse.
1200
+ # Quantized Qwen quants sometimes emit `<tool_call><function=…>` (XML)
1201
+ # or `<tool_call>{…}` (JSON) shapes --jinja doesn't recognize, so they
1202
+ # arrive as plain CONTENT with no structured tool_calls: the call never
1203
+ # runs (wasted turn) and leaked <think> reasoning makes the model narrate
1204
+ # "the user wants…". Parse them out of content, execute them, and strip
1205
+ # the raw XML + leaked thinking from what's stored/shown.
1206
+ if content and ("<tool_call>" in content or "</think>" in content):
1207
+ try:
1208
+ from ..tools.tool_call_repair import repair_tool_calls
1209
+ _cleaned, _recovered = repair_tool_calls(content)
1210
+ if _recovered and not tool_calls:
1211
+ tool_calls = _recovered
1212
+ _stream_result.tool_calls = _recovered
1213
+ if _cleaned != content:
1214
+ content = _cleaned
1215
+ except Exception:
1216
+ pass
1217
+
1218
+ # Clear the indicator before rendering output
1219
+ out._stop_indicator()
1220
+ sys.stdout.write("\r\033[K") # clear indicator line
1221
+ sys.stdout.flush()
1222
+
1223
+ # Build assistant message for history
1224
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": content}
1225
+ if tool_calls:
1226
+ assistant_msg["tool_calls"] = tool_calls
1227
+ messages.append(assistant_msg)
1228
+ _assistant_msg_idx = len(messages) - 1
1229
+
1230
+ # ── Round-end telemetry ──
1231
+ # Single self-contained record of what THIS round did. Has every
1232
+ # field needed to attribute "model bailed mid-turn" failures
1233
+ # (raw_tail + finish_reason + parse_markers_seen) without
1234
+ # re-running with --debug. Lives in events.jsonl bucket=round.
1235
+ try:
1236
+ from ..events import emit as _emit_round_end
1237
+ _round_total_ms = int((time.monotonic() - _round_started_at) * 1000)
1238
+ _emit_round_end(
1239
+ "round_end",
1240
+ round_idx=round_num,
1241
+ duration_ms=_round_total_ms,
1242
+ # Latency breakdown so optimization decisions can be
1243
+ # data-driven instead of guessing. Sum of these three
1244
+ # ≤ duration_ms; the residual (overhead_ms) is bridge,
1245
+ # event dispatch, dedup checks, history mutation, etc.
1246
+ ttft_ms=_round_ttft_ms,
1247
+ decode_ms=_round_decode_ms,
1248
+ tool_exec_ms=_round_tool_exec_ms,
1249
+ overhead_ms=max(
1250
+ 0,
1251
+ _round_total_ms - _round_ttft_ms - _round_decode_ms - _round_tool_exec_ms,
1252
+ ),
1253
+ finish_reason=_round_finish_reason,
1254
+ content_chars=_round_content_chars,
1255
+ reasoning_chars=_round_reasoning_chars,
1256
+ tool_count=len(tool_calls) if tool_calls else 0,
1257
+ tool_names=[
1258
+ (tc.get("function") or {}).get("name", "")
1259
+ for tc in (tool_calls or [])
1260
+ ][:8],
1261
+ pending_tool_count=_round_pending_tool_count,
1262
+ prompt_tokens=int(_stream_result.prompt_tokens or 0),
1263
+ completion_tokens=int(_stream_result.completion_tokens or 0),
1264
+ total_tokens=int(
1265
+ _stream_result.total_tokens
1266
+ or ((_stream_result.prompt_tokens or 0) + (_stream_result.completion_tokens or 0))
1267
+ ),
1268
+ usage_estimated=bool(getattr(_stream_result, "usage_estimated", False)),
1269
+ tool_args_limited=bool(getattr(_stream_result, "tool_args_limited", False)),
1270
+ limited_tool_name=getattr(_stream_result, "limited_tool_name", ""),
1271
+ limited_args_chars=int(getattr(_stream_result, "limited_args_chars", 0) or 0),
1272
+ # Tail of raw content stream — captured ONLY when the
1273
+ # round looks suspect (content but no tools, or zero
1274
+ # output entirely) so we don't bloat normal logs.
1275
+ raw_tail=(
1276
+ _round_raw_tail[-500:]
1277
+ if (not tool_calls and (content or _round_pending_tool_count == 0))
1278
+ else ""
1279
+ ),
1280
+ content_tail=content[-200:] if content else "",
1281
+ # ── Churn snapshot ──
1282
+ # Cumulative semantic-churn signals for the turn so we can
1283
+ # SEE thrashing in events.jsonl instead of inferring it.
1284
+ # Captured BEFORE this round's tools run, so values reflect
1285
+ # rounds 0..round_num-1; the next round_end includes this
1286
+ # round's calls. `max_file_writes` = the worst single path's
1287
+ # write count; `file_write_counts` = the full per-path map
1288
+ # (only paths written >1×, to keep the record small);
1289
+ # `command_fail_counts` = per-command-family failure counts;
1290
+ # `readonly_streak` = current pure-investigation run length;
1291
+ # `churn_nudge` = which churn nudge has fired this turn (if any).
1292
+ max_file_writes=(max(_file_write_counts.values()) if _file_write_counts else 0),
1293
+ file_write_counts={
1294
+ p: n for p, n in _file_write_counts.items() if n > 1
1295
+ },
1296
+ command_fail_counts=dict(_command_fail_counts),
1297
+ readonly_streak=_readonly_streak,
1298
+ planning_streak=_planning_streak,
1299
+ churn_nudge=_last_churn_mode,
1300
+ )
1301
+ except Exception:
1302
+ pass
1303
+
1304
+ # Claude-style bounded continuation for ordinary content truncation.
1305
+ # Keep valid visible content, but ask the model to resume directly from
1306
+ # the cutoff without recap. Partial tool calls are handled separately
1307
+ # below and are always discarded rather than executed.
1308
+ if (
1309
+ _round_finish_reason == "length"
1310
+ and not tool_calls
1311
+ and not _round_pending_tool_count
1312
+ ):
1313
+ _length_decision = content_length_recovery(
1314
+ _content_length_recoveries, _MAX_CONTENT_LENGTH_RECOVERIES
1315
+ )
1316
+ if _length_decision.action == "retry":
1317
+ _content_length_recoveries = _length_decision.attempt
1318
+ _content_recovery_active = True
1319
+ # This fragment is valid user-visible output. Preserve it in
1320
+ # the turn result before continuing; only partial tool calls
1321
+ # are discarded.
1322
+ if content:
1323
+ full_response.append(content)
1324
+ messages.append({
1325
+ "role": "user",
1326
+ "content": _length_decision.message,
1327
+ })
1328
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1329
+ try:
1330
+ from ..events import emit as _emit_continue
1331
+ _emit_continue("recovery_scheduled", reason="max_output_tokens",
1332
+ mode="continue_from_cutoff", attempt=_content_length_recoveries)
1333
+ except Exception:
1334
+ pass
1335
+ continue
1336
+ try:
1337
+ from ..events import emit as _emit_exhausted
1338
+ _emit_exhausted("recovery_exhausted", reason="max_output_tokens",
1339
+ attempts=_content_length_recoveries)
1340
+ except Exception:
1341
+ pass
1342
+ _loop_exit_reason = "max_output_recovery_exhausted"
1343
+ break
1344
+
1345
+ if _same_round_signature_count >= 5:
1346
+ try:
1347
+ from ..events import emit as _emit_same_signature_loop
1348
+
1349
+ _emit_same_signature_loop(
1350
+ "auto_nudge",
1351
+ signal="same_round_signature_loop",
1352
+ reason="same_content_chars_and_tool_repeated",
1353
+ repeat_count=_same_round_signature_count,
1354
+ content_chars=_round_content_chars,
1355
+ tool_name=_primary_round_tool,
1356
+ finish_reason=_round_finish_reason,
1357
+ )
1358
+ except Exception:
1359
+ pass
1360
+ if tool_calls:
1361
+ # This detector runs before normal tool dispatch. Previously it
1362
+ # only appended a user nudge and continued, so no tool_result was
1363
+ # ever produced and the failure ledger never advanced. Feed a
1364
+ # synthetic failed tool result into the conversation instead; the
1365
+ # next round sees a normal recoverable tool failure and the
1366
+ # schema selector can remove the repeated tool.
1367
+ _same_round_synthetic_rejections += 1
1368
+ for _tc in tool_calls:
1369
+ _fn = _tc.get("function") or {}
1370
+ _name = str(_fn.get("name") or _primary_round_tool or "tool")
1371
+ _args = _fn.get("arguments") or {}
1372
+ if isinstance(_args, str):
1373
+ try:
1374
+ _args_for_key = json.loads(_args)
1375
+ except Exception:
1376
+ _args_for_key = {"arguments": _args[:500]}
1377
+ elif isinstance(_args, dict):
1378
+ _args_for_key = _args
1379
+ else:
1380
+ _args_for_key = {"arguments": str(_args)[:500]}
1381
+ _tool_exec_state.failed_calls[(_name, canonical_args(_args_for_key))] = (
1382
+ _tool_exec_state.failed_calls.get((_name, canonical_args(_args_for_key)), 0) + 1
1383
+ )
1384
+ messages.append({
1385
+ "role": "tool",
1386
+ "content": (
1387
+ "REJECTED: repeated identical tool pattern. "
1388
+ "Use a materially different tool or arguments next; "
1389
+ "do not retry this exact action."
1390
+ ),
1391
+ "tool_call_id": _tc.get("id", ""),
1392
+ })
1393
+ if _same_round_synthetic_rejections >= _MAX_SAME_ROUND_REJECTIONS:
1394
+ # HARD STOP. Rejecting + nudging is bounded feedback, not a
1395
+ # loop breaker: observed 2026-09-08 a 27B model re-issued the
1396
+ # same bash call 26 times in a row, each rejected, each
1397
+ # followed by the strategy-change nudge, until the 20 min
1398
+ # task cap. Once the model has ignored several nudges the
1399
+ # turn has to END with an honest reason, not spin.
1400
+ _stuck_name = str(((tool_calls[0].get("function") or {}).get("name")) or _primary_round_tool or "tool")
1401
+ _stuck_args = (tool_calls[0].get("function") or {}).get("arguments") or ""
1402
+ _stuck_head = (_stuck_args if isinstance(_stuck_args, str) else json.dumps(_stuck_args))[:160]
1403
+ _stuck_msg = (
1404
+ "LocalCode stopped this turn: the model kept re-issuing the same "
1405
+ f"`{_stuck_name}` call after it was rejected {_same_round_synthetic_rejections} "
1406
+ "times and ignored every request to change approach. The task is "
1407
+ f"incomplete. Last attempted call: {_stuck_head}"
1408
+ )
1409
+ out.print_info("Stopping: the model repeated the same rejected action too many times.")
1410
+ _render_markdown(_stuck_msg, app.console if hasattr(app, 'console') else None)
1411
+ full_response.append(_stuck_msg)
1412
+ try:
1413
+ from ..events import emit as _emit_stuck
1414
+ _emit_stuck("loop_break", signal="same_round_rejections_exhausted",
1415
+ rejections=_same_round_synthetic_rejections,
1416
+ tool_name=_stuck_name, round_idx=round_num)
1417
+ except Exception:
1418
+ pass
1419
+ _loop_exit_reason = "repeat_loop_exhausted"
1420
+ break
1421
+ if _same_round_synthetic_rejections >= 3:
1422
+ messages.append({
1423
+ "role": "user",
1424
+ "content": (
1425
+ "SYSTEM: The last repeated action was rejected several "
1426
+ "times. Change strategy now: inspect current files, use "
1427
+ "a different write/edit path, or verify what exists. "
1428
+ "Do not call the same tool with the same shape again."
1429
+ ),
1430
+ })
1431
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1432
+ else:
1433
+ messages.append({
1434
+ "role": "user",
1435
+ "content": (
1436
+ "SYSTEM: You are repeating the same output pattern. "
1437
+ "Take a materially different next action now. Continue "
1438
+ "the task; do not summarize or ask the user."
1439
+ ),
1440
+ })
1441
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1442
+ _empty_rounds_this_turn = 0
1443
+ continue
1444
+
1445
+ # If generation hit the token cap while a tool call was being
1446
+ # produced, do not execute it. The args may be syntactically
1447
+ # salvageable but semantically truncated, which caused a real
1448
+ # refactor failure to run write_file with `{}` after a 6-minute
1449
+ # decode. Retry once with explicit chunking instructions.
1450
+ if (
1451
+ _round_finish_reason in ("length", "tool_args_limit")
1452
+ and (tool_calls or _round_pending_tool_count)
1453
+ ):
1454
+ try:
1455
+ if 0 <= _assistant_msg_idx < len(messages):
1456
+ del messages[_assistant_msg_idx]
1457
+ except Exception:
1458
+ pass
1459
+ _limited_tool_name = getattr(_stream_result, "limited_tool_name", "") or "tool"
1460
+ if _limited_tool_name == "tool" and _primary_round_tool:
1461
+ _limited_tool_name = _primary_round_tool
1462
+ _limited_args_chars = int(getattr(_stream_result, "limited_args_chars", 0) or 0)
1463
+ _limited_reason = getattr(_stream_result, "limited_reason", "") or "adaptive stream guard"
1464
+ _limited_args_snippet = getattr(_stream_result, "limited_args_snippet", "") or ""
1465
+ if _limited_tool_name:
1466
+ # A tool_args_limit round never reaches normal tool execution,
1467
+ # so record it explicitly in the same failure ledger used by
1468
+ # repeated executed tool errors. The key is intentionally
1469
+ # coarse: partial JSON snippets vary, but the failure mode is
1470
+ # the same tool repeatedly failing to finish an argument stream.
1471
+ _stream_limit_key = (
1472
+ _limited_tool_name,
1473
+ f"stream_limit:{_limited_reason or _round_finish_reason}",
1474
+ )
1475
+ _tool_exec_state.failed_calls[_stream_limit_key] = (
1476
+ _tool_exec_state.failed_calls.get(_stream_limit_key, 0) + 1
1477
+ )
1478
+ if _empty_rounds_this_turn < _MAX_EMPTY_ROUND_RETRIES:
1479
+ _empty_rounds_this_turn += 1
1480
+ try:
1481
+ from ..events import emit as _emit_truncated_tool
1482
+ _emit_truncated_tool(
1483
+ "auto_nudge",
1484
+ signal="tool_args_limited",
1485
+ reason="truncated_tool_call",
1486
+ stall_mode="truncated_tool_call",
1487
+ retry=_empty_rounds_this_turn,
1488
+ retry_cap=_MAX_EMPTY_ROUND_RETRIES,
1489
+ finish_reason=_round_finish_reason,
1490
+ pending_tool_count=_round_pending_tool_count,
1491
+ tool_count=len(tool_calls) if tool_calls else 0,
1492
+ limited_tool_name=getattr(_stream_result, "limited_tool_name", ""),
1493
+ limited_args_chars=int(getattr(_stream_result, "limited_args_chars", 0) or 0),
1494
+ limited_reason=getattr(_stream_result, "limited_reason", ""),
1495
+ )
1496
+ except Exception:
1497
+ pass
1498
+ _limited_args_snippet = (
1499
+ _limited_args_snippet
1500
+ ).replace("\n", " ")[:300]
1501
+ messages.append({
1502
+ "role": "user",
1503
+ "content": (
1504
+ f"SYSTEM: Your previous {_limited_tool_name} call was "
1505
+ f"truncated mid-args at {_limited_args_chars} chars — "
1506
+ "the output token budget ran out before the JSON "
1507
+ "closed, so the call was discarded.\n"
1508
+ "To recover, write the file in CHUNKS:\n"
1509
+ f" 1. Call write_file with the FIRST ~100 lines.\n"
1510
+ f" 2. Call append_file repeatedly to add the rest, "
1511
+ f"~100 lines per call.\n"
1512
+ "Each call's args must fit the per-round token "
1513
+ "budget. Do NOT retry write_file with the same large "
1514
+ "content — it will hit the same wall.\n"
1515
+ f"- Reason: {_limited_reason}\n"
1516
+ f"- Leading snippet: {_limited_args_snippet}"
1517
+ ),
1518
+ })
1519
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1520
+ continue
1521
+ _loop_exit_reason = "truncated_tool_call_exhausted"
1522
+ break
1523
+
1524
+ # ── Investigation-spin detector ──
1525
+ # After every round, decide if THIS round was pure investigation
1526
+ # ("looks fine" + read-only tools). Streak length drives whether
1527
+ # we inject a corrective nudge into the next round.
1528
+ _round_tool_names = {
1529
+ (tc.get("function") or {}).get("name", "") for tc in (tool_calls or [])
1530
+ }
1531
+ _round_was_readonly = bool(_round_tool_names) and _round_tool_names.issubset(_READONLY_TOOLS)
1532
+ _round_said_looks_fine = bool(content and _LOOKS_FINE_RE.search(content))
1533
+ if _round_was_readonly:
1534
+ _readonly_streak += 1
1535
+ else:
1536
+ _readonly_streak = 0
1537
+ if _round_said_looks_fine and _round_was_readonly:
1538
+ _looks_fine_streak += 1
1539
+ else:
1540
+ _looks_fine_streak = 0
1541
+ # Trip on the "looks fine" spin — a model that says "looks fine"
1542
+ # repeatedly while only reading. The PURE read-only streak (read
1543
+ # N files without commenting) is now owned by the semantic-churn
1544
+ # detector below at CHURN_READONLY_STREAK_LIMIT (6), which is
1545
+ # TIGHTER than the old _MAX_READONLY_STREAK of 10 — so we drop the
1546
+ # readonly-streak clause here to avoid a double nudge. This
1547
+ # detector keeps its distinctive "files are fine, bug is runtime"
1548
+ # framing for the looks_fine case; the churn detector handles the
1549
+ # generic "investigating in circles" case. `_churn_nudge_done` is
1550
+ # shared so only ONE spin/churn nudge fires per turn.
1551
+ if not _spin_nudge_done and not _churn_nudge_done and (
1552
+ _looks_fine_streak >= _MAX_LOOKS_FINE_STREAK
1553
+ ):
1554
+ _spin_nudge_done = True
1555
+ try:
1556
+ from ..events import emit as _emit_spin
1557
+ _emit_spin(
1558
+ "auto_nudge",
1559
+ kind="investigation_spin",
1560
+ readonly_streak=_readonly_streak,
1561
+ looks_fine_streak=_looks_fine_streak,
1562
+ round_idx=round_num,
1563
+ )
1564
+ except Exception:
1565
+ pass
1566
+ messages.append({
1567
+ "role": "user",
1568
+ "content": (
1569
+ "SYSTEM: You've spent several rounds investigating with "
1570
+ "read-only tools and concluding things look correct, but "
1571
+ "the bug the user reported is real. The files are fine; "
1572
+ "the failure is runtime/browser/environment-side and not "
1573
+ "visible from source. Stop investigating. Your next "
1574
+ "response MUST be one of:\n"
1575
+ " (a) ONE concrete change you think might fix it — add "
1576
+ "console.error hooks, add a try/catch with a visible "
1577
+ "error message in the UI, add defensive null checks, "
1578
+ "add a debug endpoint, or similar — then write/edit a "
1579
+ "file and verify.\n"
1580
+ " (b) ONE focused question to the user describing what "
1581
+ "you can't see — e.g. \"Is the page blank, unstyled, or "
1582
+ "showing data? Any errors in the browser console "
1583
+ "(Cmd+Opt+J)? What URL did you open?\"\n"
1584
+ "Do NOT call read_file, grep, list_files, glob, "
1585
+ "web_fetch, or web_search this turn. Pick (a) or (b) "
1586
+ "and respond now."
1587
+ ),
1588
+ })
1589
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1590
+
1591
+ # ── Stalled-round recovery ──
1592
+ # Three failure modes end a round with the model NOT done: (A) empty
1593
+ # round (long reasoning, stream closes with no content/tools); (B)
1594
+ # intent-without-action (one narration sentence, no tool call); (C)
1595
+ # gave-up-after-rejection (last tool returned REJECTED/Error, round
1596
+ # ended with no retry). Detection + nudge live in agent/recovery.py;
1597
+ # `stall` is None for a productive round else a StallMode. Gated on
1598
+ # Feature.AUTO_NUDGE_RECOVERY (off → stalls end the turn silently).
1599
+ from ..features import Feature, is_enabled as _is_enabled
1600
+ stall = detect_stall(
1601
+ tool_calls=tool_calls,
1602
+ content=content,
1603
+ tools_called_prior=tools_called,
1604
+ messages=messages,
1605
+ thinking_abort=_stream_result.thinking_abort,
1606
+ ) if _is_enabled(Feature.AUTO_NUDGE_RECOVERY) else None
1607
+
1608
+ if stall is not None:
1609
+ # Narration-only / empty stalled assistant messages should not
1610
+ # stay in history. Keeping them conditions the next round on the
1611
+ # same failed "about to act" text and wastes context.
1612
+ if stall in {StallMode.EMPTY, StallMode.NARRATION}:
1613
+ try:
1614
+ if 0 <= _assistant_msg_idx < len(messages):
1615
+ del messages[_assistant_msg_idx]
1616
+ except Exception:
1617
+ pass
1618
+ if _empty_rounds_this_turn < _MAX_EMPTY_ROUND_RETRIES:
1619
+ _empty_rounds_this_turn += 1
1620
+ mode_label = stall.value
1621
+ # Telemetry event for every auto-nudge. Lets us answer
1622
+ # "how often does the model stall + recover via nudge
1623
+ # vs stall and truly give up?" from events.jsonl alone.
1624
+ try:
1625
+ from ..events import emit as _emit_ev
1626
+ _emit_ev("auto_nudge",
1627
+ stall_mode=mode_label,
1628
+ retry=_empty_rounds_this_turn,
1629
+ retry_cap=_MAX_EMPTY_ROUND_RETRIES)
1630
+ except Exception:
1631
+ pass
1632
+ out.print_info(
1633
+ f"Model round ended with {mode_label} — nudging it to "
1634
+ f"continue… (auto-retry {_empty_rounds_this_turn}/"
1635
+ f"{_MAX_EMPTY_ROUND_RETRIES})"
1636
+ )
1637
+ # Route through the guard so the SAME stall nudge never fires
1638
+ # two rounds running (two-and-threshold discipline). If it's
1639
+ # suppressed we still continue — the retry counter advanced and
1640
+ # the assistant's dead "about to act" message was already
1641
+ # dropped, so the model gets another clean shot without the
1642
+ # self-conditioning duplicate nag.
1643
+ _append_nudge(nudge_for(stall), kind=f"stall:{stall.name}")
1644
+ continue # next round of the main loop
1645
+ else:
1646
+ out.print_info(
1647
+ f"Model ended {_MAX_EMPTY_ROUND_RETRIES + 1} rounds in a "
1648
+ "row without acting. Ending turn. Try rephrasing, "
1649
+ "splitting the task, or `/model` to switch models."
1650
+ )
1651
+ _loop_exit_reason = "stall_exhausted"
1652
+ break
1653
+
1654
+ # ── No tool calls = model is done ──
1655
+ # Also require no pending tool call: the runtime promotes pending
1656
+ # tools into `tool_calls`, but if a future path doesn't, we keep
1657
+ # looping instead of ending early (small models often emit a stop
1658
+ # token with a call still pending).
1659
+ if not tool_calls and not _round_pending_tool_count:
1660
+ # Match v0.2.12's no-tool-calls path exactly: render content,
1661
+ # render any grounded file summary, break. Nothing else.
1662
+ #
1663
+ # Removed 2026-04-29:
1664
+ # • completion-gate retry loop — caused multi-round
1665
+ # paraphrase loops on Q&A turns by deleting the assistant
1666
+ # answer and forcing more rounds.
1667
+ # • quality-monitor retry path — same shape (delete msg +
1668
+ # re-run with a "SYSTEM: Do NOT stop…" injection). Even
1669
+ # with the gate gone, this path could still re-fire on
1670
+ # a subset of phrasings ("would you like me to…",
1671
+ # "should I…") and reproduce the multi-round artefact.
1672
+ # The model's final answer stands as written; if it asks a
1673
+ # permission question, the user gets to answer — the runtime
1674
+ # does not silently force a retry.
1675
+ _blocking_question = is_focused_blocking_question(content)
1676
+ # ── Open-todo completion gate (GENERAL — the goal-typed gates below
1677
+ # are dead because infer_goal_state always returns general_task). If
1678
+ # the model tries to END its turn while its OWN todo list still has
1679
+ # open items, it stopped early: force it to continue to the next item.
1680
+ # `session.todos` is cleared to [] by todo_write only when ALL are
1681
+ # completed, so a non-empty list is real unfinished work. Bounded by
1682
+ # _MAX_TODO_CONTINUATIONS, with a diminishing-returns guard so a model
1683
+ # that can't make progress on its plan isn't nagged forever.
1684
+ _open_todos = list(getattr(app.session, "todos", []) or [])
1685
+ _todo_remaining = sum(
1686
+ 1 for t in _open_todos if str(t.get("status", "")).lower() != "completed"
1687
+ )
1688
+ if (
1689
+ not _blocking_question
1690
+ and _todo_remaining > 0
1691
+ and _todo_continue_count < _MAX_TODO_CONTINUATIONS
1692
+ and _todo_stuck_count < 3
1693
+ and _is_enabled(Feature.AUTO_NUDGE_RECOVERY)
1694
+ ):
1695
+ # Diminishing returns: if the open-count didn't fall since the
1696
+ # last continuation, the model isn't advancing its plan.
1697
+ if _todo_remaining >= _last_todo_remaining:
1698
+ _todo_stuck_count += 1
1699
+ else:
1700
+ _todo_stuck_count = 0
1701
+ _last_todo_remaining = _todo_remaining
1702
+ if _todo_stuck_count < 3:
1703
+ _todo_continue_count += 1
1704
+ _nxt = next(
1705
+ (t for t in _open_todos if str(t.get("status", "")).lower() == "in_progress"),
1706
+ None,
1707
+ ) or next(
1708
+ (t for t in _open_todos if str(t.get("status", "")).lower() != "completed"),
1709
+ None,
1710
+ )
1711
+ _nxt_label = (_nxt or {}).get("content", "the next item")
1712
+ out.print_info(
1713
+ f"{_todo_remaining} todo(s) still open — continuing (not stopping early)."
1714
+ )
1715
+ messages.append({"role": "user", "content": (
1716
+ f"SYSTEM: You still have {_todo_remaining} unfinished todo(s). "
1717
+ f"The task is NOT complete — do not stop. Continue now with: "
1718
+ f"{_nxt_label}. Mark a todo completed via todo_write only when it "
1719
+ f"is genuinely done, and keep going until every item is completed."
1720
+ )})
1721
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1722
+ _last_nudge_kind = "todo_continue"
1723
+ continue # force another round — reject the early completion
1724
+ # ── Build-verification STOP gate (claude-code query.ts stop-hook) ──
1725
+ # The model wants to END a build_app turn that changed CODE. This is
1726
+ # a TRUE completion gate, not a one-shot nudge: we RUN the project's
1727
+ # own typecheck/test ourselves and, if it reports errors, inject them
1728
+ # and FORCE another round — the model cannot declare "done" while the
1729
+ # gate is red. Each completion attempt RE-RUNS the check, so the gate
1730
+ # keeps holding until the project is clean or _MAX_BUILD_VERIFY_RETRIES
1731
+ # is hit (a genuinely unfixable project can't spin forever).
1732
+ #
1733
+ # NOTE: gated on build_app. `infer_goal_state` DOES set that for a
1734
+ # build-shaped request (see the classifier note in agent/goal.py), so
1735
+ # this is live — it is deliberately NOT general, because firing a
1736
+ # full project typecheck on every one-line edit is too slow. A
1737
+ # narrower general trigger (a multi-file / new-file build completion,
1738
+ # not a small edit) is still the follow-up.
1739
+ #
1740
+ # `_MAX_BUILD_VERIFY_RETRIES` bounds how many times the model is
1741
+ # NUDGED, and must not bound whether the project is CHECKED. Gating
1742
+ # the check itself meant that after the last nudge the typecheck
1743
+ # simply stopped running, so a project that was still red went into
1744
+ # the completion decision with no verdict attached to it at all.
1745
+ if (
1746
+ not _blocking_question
1747
+ and _goal_state.goal_type == "build_app"
1748
+ and _is_enabled(Feature.AUTO_NUDGE_RECOVERY)
1749
+ and _changed_code_files(changed_files)
1750
+ ):
1751
+ _self_verified = (
1752
+ has_runtime_verification_signal(bash_history)
1753
+ or ran_build_or_test(bash_history)
1754
+ )
1755
+ # TIER-2 VERIFICATION: don't just NUDGE the model to typecheck
1756
+ # (a small model skips it or mishandles output) — RUN the
1757
+ # project's real typecheck/lint ourselves and feed the concrete
1758
+ # errors back. Catches semantic errors (wrong names, missing
1759
+ # imports) the per-write syntax check can't. See project_check.
1760
+ _proj_errors = None
1761
+ try:
1762
+ from ..tools.project_check import run_project_check_result
1763
+ out.print_info("Verifying — running the project's typecheck…")
1764
+ _check = run_project_check_result(
1765
+ str(getattr(app, "check_root", None) or app.repo_root),
1766
+ ctx_tokens=_ctx_tokens_turn)
1767
+ if _project_check_gate.observe(_check) == "red":
1768
+ _proj_errors = _check.detail
1769
+ except Exception as _check_exc:
1770
+ # An unexpected failure is a FAILED verification, not a clean
1771
+ # one — swallowing it silently is the same false clean.
1772
+ _proj_errors = None
1773
+ _project_check_gate.observe_exception(_check_exc)
1774
+ try:
1775
+ from ..events import emit as _emit_bg
1776
+ _emit_bg("auto_nudge", signal="build_verify_gate",
1777
+ round_idx=round_num, attempt=_build_verify_nudges,
1778
+ had_errors=bool(_proj_errors),
1779
+ self_verified=bool(_self_verified))
1780
+ except Exception:
1781
+ pass
1782
+ if _proj_errors and _build_verify_nudges < _MAX_BUILD_VERIFY_RETRIES:
1783
+ # STOP: cannot finish while the gate is red. Errors are fresh
1784
+ # actionable feedback (they change as the model fixes them),
1785
+ # so this is NOT routed through the "not twice in a row" nag
1786
+ # guard — but we record the kind so a following reminder-style
1787
+ # nudge doesn't stack on top of it.
1788
+ _build_verify_nudges += 1
1789
+ out.print_info("Typecheck found errors — sending them back to fix.")
1790
+ messages.append({"role": "user", "content": (
1791
+ "SYSTEM: the project's typecheck/build was run for you and "
1792
+ "reported errors. FIX each one (targeted edit_file changes), "
1793
+ "then finish. Do not claim it works until these are gone:\n\n"
1794
+ + _proj_errors
1795
+ )})
1796
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1797
+ _last_nudge_kind = "build_verify_errors"
1798
+ continue # don't accept completion — the gate is red
1799
+ if _proj_errors:
1800
+ # Out of nudges and STILL red. Stop asking the model to fix it
1801
+ # (that's what the bound is for) but do not let the turn call
1802
+ # itself done: the gate remembers the red, so the completion
1803
+ # decision below reports the build as unfinished rather than
1804
+ # verified. Falling through here silently is what let a build
1805
+ # with three real tsc errors complete as "verified".
1806
+ _self_verified = False
1807
+ out.print_info(
1808
+ "Typecheck still reporting errors after "
1809
+ f"{_build_verify_nudges} attempts — build stays unverified.")
1810
+ if _project_check_gate.blocks_completion():
1811
+ # Not red, but not green either. Ask the model to verify by
1812
+ # hand — EVERY time, up to its own bound, so a second and
1813
+ # third failure don't just fall through to "done".
1814
+ _self_verified = False
1815
+ out.print_info(
1816
+ f"Typecheck did not complete "
1817
+ f"({_project_check_gate.unverified}) — build stays unverified.")
1818
+ if _project_check_gate.consume_retry():
1819
+ messages.append({"role": "user", "content": (
1820
+ "SYSTEM: LocalCode could not verify this project — "
1821
+ f"{_project_check_gate.unverified}. Run the project's own "
1822
+ "build/typecheck yourself (npm run build / npx tsc "
1823
+ "--noEmit for TS; tests or an import smoke-check for "
1824
+ "Python), fix anything it reports, and say what it "
1825
+ "printed. Do not claim the build works until it passes."
1826
+ )})
1827
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1828
+ _last_nudge_kind = "build_verify_unverified"
1829
+ continue # don't accept completion — nothing is verified
1830
+ # A typecheck can be green over a module that was imported and
1831
+ # left empty — TypeScript is happy with `import './fsrs'` when
1832
+ # fsrs.ts has no exports at all, and Python always is. This is
1833
+ # the "// placeholder at the centre of the app" failure, so it
1834
+ # gets the same treatment as red: feed it back, bounded, and
1835
+ # don't let the turn call itself done while it stands.
1836
+ _hollow: list[str] = []
1837
+ try:
1838
+ from .hollow_module import hollow_imported_modules
1839
+ # `changed_files` itself — `_changed_code_files` is a bool
1840
+ # predicate, not a filter, and passing it here silently made
1841
+ # this whole gate dead code.
1842
+ _hollow = hollow_imported_modules(
1843
+ str(app.repo_root), changed_files)
1844
+ except Exception:
1845
+ _hollow = [] # never invent a defect out of a scan failure
1846
+ if _hollow:
1847
+ _self_verified = False
1848
+ _hollow_gate.mark(_hollow)
1849
+ out.print_info(
1850
+ f"Imported but empty: {', '.join(_hollow)} — not complete.")
1851
+ if _hollow_gate.consume_retry():
1852
+ messages.append({"role": "user", "content": (
1853
+ "SYSTEM: these modules are imported by other files but "
1854
+ "contain no code at all — they were left as placeholders:"
1855
+ "\n\n" + "\n".join(f" - {h}" for h in _hollow) +
1856
+ "\n\nImplement each one for real, or delete it and remove "
1857
+ "the imports. Do not describe the feature as working while "
1858
+ "its module is empty."
1859
+ )})
1860
+ _ephemeral_nudge_indices.append(len(messages) - 1)
1861
+ _last_nudge_kind = "hollow_module"
1862
+ continue # don't accept completion — the app is hollow
1863
+ else:
1864
+ _hollow_gate.clear()
1865
+ if not _self_verified and _build_verify_nudges < 1:
1866
+ # Gate is clean (checker passed OR none configured) AND the
1867
+ # model never ran a build/test itself — advise ONCE to verify.
1868
+ # This one IS a recurring-style nag → routed through the guard.
1869
+ _build_verify_nudges += 1
1870
+ out.print_info("Changed code but never built/ran it — nudging to verify.")
1871
+ if _append_nudge(
1872
+ "SYSTEM: You changed code but never built, type-checked, or ran "
1873
+ "it. Run the project's build/typecheck (npm run build / npx tsc "
1874
+ "--noEmit for TS; tests or an import smoke-check for Python), fix "
1875
+ "every error, then finish. Don't claim it works without building.",
1876
+ kind="build_verify_advise",
1877
+ ):
1878
+ continue # let it verify before finishing
1879
+ # else: gate clean and either self-verified or already advised →
1880
+ # fall through and accept completion.
1881
+ if (not _blocking_question and _goal_state.goal_type == "edit_existing"
1882
+ and _changed_code_files(changed_files)
1883
+ and not ran_build_or_test(bash_history)
1884
+ and _completion_gate_retries < 1):
1885
+ _completion_gate_retries += 1
1886
+ if _append_nudge(
1887
+ "SYSTEM: The requested edit exists, but no relevant test, build, "
1888
+ "typecheck, or import check has passed. Run the narrowest "
1889
+ "deterministic verification now, fix failures, then finish.",
1890
+ kind="edit_verify_advise",
1891
+ ):
1892
+ continue
1893
+ # ── Stale-evidence re-verification ──────────────────────────────
1894
+ # The model DID run a relevant build/test earlier, then edited again
1895
+ # and declared done without re-running it. The registry keys its
1896
+ # evidence on the changed files' hashes, so that record is now stale
1897
+ # and the turn would end as `completion_gate:unverified` (exit 1
1898
+ # under --json) even when the work is right. Observed 3x in one
1899
+ # HarnessBench night on runs the oracle scored 0.93-0.98. Rather than
1900
+ # fail the user for the model's omission, re-run the model's OWN last
1901
+ # verification command ourselves (same guards as a model bash call),
1902
+ # record the fresh verdict, and only then decide. A failure is fed
1903
+ # back and forces one more round; bounded so it cannot spin.
1904
+ if (not _blocking_question
1905
+ and _goal_state.goal_type in {"build_app", "edit_existing"}
1906
+ and _changed_code_files(changed_files)
1907
+ and "relevant-verification" in _hook_state.verification_registry.requirements
1908
+ and not _hook_state.verification_registry.satisfied("relevant-verification", os.environ)
1909
+ and _reverify_runs < 2):
1910
+ _reverify_runs += 1
1911
+ _req = _hook_state.verification_registry.requirements["relevant-verification"]
1912
+ _recmd = _req.command if isinstance(_req.command, str) else " ".join(_req.command)
1913
+ out.print_info(f"Re-running your last verification after the final edits: {_recmd[:80]}")
1914
+ try:
1915
+ _re_obj = _execute_tool_result(app, "bash", {"command": _recmd}, out)
1916
+ _re_text = str(_re_obj)
1917
+ _re_facts = dict(getattr(_re_obj, "facts", {}) or {})
1918
+ from ..execution_policy import assess_shell_execution as _assess_re
1919
+ _re_ok = bool(getattr(_re_obj, "ok", True))
1920
+ _re_exec = _assess_re(_recmd, _re_text, int(_re_facts.get("exit_code", 0 if _re_ok else 1)))
1921
+ from pathlib import Path as _ReP
1922
+ from ..evidence import EvidenceRequirement as _ReReq
1923
+ _hook_state.verification_registry.require(_ReReq(
1924
+ "relevant-verification",
1925
+ tuple(_ReP(f) if _ReP(f).is_absolute() else _ReP(app.repo_root) / f for f in changed_files),
1926
+ _recmd, ("PATH", "NODE_ENV", "PYTHONPATH"),
1927
+ ))
1928
+ _hook_state.verification_registry.record(
1929
+ "relevant-verification", environment=os.environ,
1930
+ passed=_re_exec.task_succeeded, output=_re_text,
1931
+ )
1932
+ bash_history.append((_recmd, _re_text))
1933
+ try:
1934
+ from ..events import emit as _emit_rv
1935
+ _emit_rv("auto_reverify", command=_recmd[:200], passed=_re_exec.task_succeeded,
1936
+ attempt=_reverify_runs, round_idx=round_num)
1937
+ except Exception:
1938
+ pass
1939
+ if not _re_exec.task_succeeded:
1940
+ _snippet = _truncate_result(_re_text, "bash", ctx_tokens=_ctx_tokens_turn)
1941
+ if _append_nudge(
1942
+ "SYSTEM: Your last verification command was re-run after your final "
1943
+ f"edits and FAILED:\n$ {_recmd}\n{_snippet}\nFix the failure, re-run it, "
1944
+ "then finish. Do not claim it works while this fails.",
1945
+ kind="reverify_failed",
1946
+ ):
1947
+ continue
1948
+ except Exception as _re_exc:
1949
+ out.print_info(f"Re-verification could not run: {type(_re_exc).__name__}")
1950
+ if (not _blocking_question
1951
+ and _goal_state.goal_type in {"build_app", "edit_existing"}
1952
+ and changed_files
1953
+ and "relevant-verification" in _hook_state.verification_registry.requirements
1954
+ and _hook_state.verification_registry.satisfied("relevant-verification", os.environ)):
1955
+ from .state_machine import TaskEvent, transition
1956
+ _done_transition = transition("verify", TaskEvent.REQUIREMENTS_SATISFIED)
1957
+ _announce_task_stage(_done_transition.after.value)
1958
+ _completion_blocked = (
1959
+ not _blocking_question
1960
+ and _goal_state.goal_type in {"build_app", "edit_existing"}
1961
+ and bool(_changed_code_files(changed_files))
1962
+ and (
1963
+ "relevant-verification" not in _hook_state.verification_registry.requirements
1964
+ or not _hook_state.verification_registry.satisfied(
1965
+ "relevant-verification", os.environ
1966
+ )
1967
+ # A project typecheck that never returned a verdict blocks
1968
+ # completion on its own. Otherwise an earlier, unrelated
1969
+ # command sitting in the verification registry lets the turn
1970
+ # finish as "verified" while the real check keeps failing.
1971
+ or _project_check_gate.blocks_completion()
1972
+ or _hollow_gate.blocks_completion()
1973
+ )
1974
+ )
1975
+ if _completion_blocked:
1976
+ content = (
1977
+ "Implementation changes were made, but LocalCode could not record "
1978
+ "a passing build, test, typecheck, or import check for the current "
1979
+ "file hashes. The task remains incomplete rather than claiming success."
1980
+ )
1981
+ # Goes through the FINAL RESULT text, so the reason lands in the
1982
+ # TUI and in `--json` — unlike print_info, which is invisible in
1983
+ # the TUI and suppressed under --json.
1984
+ content += _project_check_gate.result_note()
1985
+ content += _hollow_gate.result_note()
1986
+ if _goal_state.goal_type == "run_or_launch":
1987
+ _task_port = int(getattr(_task_state, "active_port", 0) or 0)
1988
+ content = ground_run_or_launch_text(content, _task_port)
1989
+ if content:
1990
+ _render_markdown(content, app.console if hasattr(app, 'console') else None)
1991
+ full_response.append(content)
1992
+ # Always show grounded file summary after model response
1993
+ if changed_files:
1994
+ grounded = _grounded_file_summary(app.repo_root, changed_files)
1995
+ if grounded:
1996
+ _render_markdown(grounded, app.console if hasattr(app, 'console') else None)
1997
+ full_response.append(grounded)
1998
+ if _goal_state.goal_type == "run_or_launch":
1999
+ _task_port = int(getattr(_task_state, "active_port", 0) or 0)
2000
+ if _task_port:
2001
+ grounded_access = (
2002
+ f"Running app URL: http://localhost:{_task_port}\n"
2003
+ f"Use that exact port if you open the browser or re-run the server."
2004
+ )
2005
+ _render_markdown(grounded_access, app.console if hasattr(app, 'console') else None)
2006
+ full_response.append(grounded_access)
2007
+ _loop_exit_reason = (
2008
+ "blocked_question" if _blocking_question
2009
+ else "completion_gate:unverified" if _completion_blocked
2010
+ else "model_done"
2011
+ )
2012
+ break
2013
+
2014
+ # ── Execute tools ──
2015
+ aggregate_size = 0
2016
+
2017
+ _parallel_futures: dict[int, "object"] = {}
2018
+ _parallel_pool = None
2019
+
2020
+ def _skip_parallel(_idx: int, _name: str, _args: dict) -> bool:
2021
+ if _name == "read_file":
2022
+ _p = _args.get("path") or _args.get("file_path") or ""
2023
+ if (isinstance(_p, str) and _p
2024
+ and _p in _tool_exec_state.files_read
2025
+ and _p not in _tool_exec_state.files_modified):
2026
+ return True
2027
+ return False
2028
+
2029
+ _parallel_futures, _parallel_pool = prefetch_parallel_tool_calls(
2030
+ tool_calls,
2031
+ should_skip=_skip_parallel,
2032
+ execute_tool=lambda _name, _args: _execute_tool_result(app, _name, _args, out),
2033
+ )
2034
+ if _parallel_futures:
2035
+ try:
2036
+ from ..events import emit as _emit_par
2037
+ _emit_par("parallel_tool_dispatch",
2038
+ turn_id=_turn_id,
2039
+ round=round_num,
2040
+ count=len(_parallel_futures),
2041
+ names=[tool_calls[_i].get("function", {}).get("name", "")
2042
+ for _i in _parallel_futures])
2043
+ except Exception:
2044
+ pass
2045
+
2046
+ for _tc_idx, tc in enumerate(tool_calls):
2047
+ # Cancel check — poll before EACH tool so a long tool chain
2048
+ # stops the moment the user asks, not after all queued calls
2049
+ # have run. We can't interrupt a tool mid-execution
2050
+ # (subprocess would need SIGTERM); per-call granularity is
2051
+ # the honest trade-off.
2052
+ if getattr(app, "cancel_requested", False):
2053
+ out.print_info("Stopped by user.")
2054
+ loop_detected = True
2055
+ _loop_exit_reason = "user_cancel_mid_tool"
2056
+ break
2057
+ fn = tc.get("function", {})
2058
+ tool_name = fn.get("name", "unknown")
2059
+ try:
2060
+ _raw_args = fn.get("arguments", "{}")
2061
+ # `arguments` is normally a JSON string, but some providers/
2062
+ # parsers hand back an already-decoded dict — json.loads on a
2063
+ # dict raises TypeError, which used to escape and crash the loop.
2064
+ args = _raw_args if isinstance(_raw_args, dict) else json.loads(_raw_args)
2065
+ except (json.JSONDecodeError, TypeError, ValueError):
2066
+ args = {}
2067
+ out.print_info(f"Warning: malformed args for {tool_name}")
2068
+
2069
+ # Update indicator immediately
2070
+ stage = _tool_stage_label(tool_name, args)
2071
+ out.set_stage(stage)
2072
+ idx = out.log_tool(tool_name, _summarize_args(args))
2073
+ # RL trace fidelity: the normal `tool_start` event carries only a
2074
+ # summarized + 200-char-truncated arg preview. When capturing
2075
+ # trajectories for fine-tuning we need the FULL arguments (e.g. the
2076
+ # whole write_file body). Env-gated so normal TUI/headless runs are
2077
+ # unaffected; dev/rl/collect sets LOCALCODE_TRACE_FULL_ARGS=1.
2078
+ if os.environ.get("LOCALCODE_TRACE_FULL_ARGS"):
2079
+ out._emit_event(
2080
+ "tool_call_full", index=str(idx),
2081
+ name=tool_name, arguments=args,
2082
+ )
2083
+
2084
+ # Safety: confirm destructive commands (honors current autonomy
2085
+ # level so /permissions toggles take effect immediately).
2086
+ # The approval returns one of "once" / "always" / "deny".
2087
+ # "always" adds the command's first token (e.g. "git") to the
2088
+ # session allowlist so we stop asking for that family.
2089
+ if _needs_confirmation(tool_name, args, app):
2090
+ # `cmd` drives the approval prompt display and the "always
2091
+ # allow `<token>`" key. Shell tools carry a command; file-write
2092
+ # tools carry a path; launch_app resolves the repo-controlled
2093
+ # command it would actually run; mcp_* shows an args preview.
2094
+ cmd = _approval_display_command(app, tool_name, args)
2095
+ verdict = _request_approval_verdict(app, out, tool_name, cmd)
2096
+
2097
+ # Act on the verdict.
2098
+ if verdict == "always":
2099
+ allow_set = getattr(app, "_session_allow", None)
2100
+ if allow_set is None:
2101
+ app._session_allow = set()
2102
+ allow_set = app._session_allow
2103
+ allow_set.add(_first_token(cmd))
2104
+ sys.stdout.write(
2105
+ f"\033[2m └ always allowing `{_first_token(cmd)}` for this session\033[0m\n"
2106
+ )
2107
+ elif verdict == "once":
2108
+ sys.stdout.write("\033[2m └ approved command\033[0m\n")
2109
+ else: # deny
2110
+ sys.stdout.write("\033[2m └ denied\033[0m\n")
2111
+ messages.append({"role": "tool", "content": "Denied by user.", "tool_call_id": tc.get("id", "")})
2112
+ continue
2113
+
2114
+ # Per-tool loop guards REMOVED 2026-04-26 (option C).
2115
+ # Telemetry showed:
2116
+ # - 3-in-a-row exact-repeat guard: fired 0× ever
2117
+ # - same-tool > 10 guard: fired 1×, false-positive on
2118
+ # legitimate iterative data analysis (11 different
2119
+ # python3 probes against an 11K-record JSON)
2120
+ # - file-edit > 3 guard: fired 0× ever
2121
+ # These guards were paying bookkeeping cost on every tool
2122
+ # dispatch to catch loops that don't happen, and cutting
2123
+ # legitimate iteration when they did fire. agent (`maxTurns`
2124
+ # opt-in) and terminal coding tools (no limit) ship without these too.
2125
+ #
2126
+ # Loop termination now comes from:
2127
+ # - User Ctrl+C / cancel_requested (always fires)
2128
+ # - thinking-time / thinking-char caps (per-round)
2129
+ # - empty-round nudge (3 strikes)
2130
+ # - investigation-spin nudge (10+ read-only rounds)
2131
+ # - looks-fine streak nudge (3 rounds)
2132
+ # If a residual failure pattern emerges in production we
2133
+ # add a TARGETED guard for it then. No more coarse counters.
2134
+
2135
+ # Read-dedup: if this is `read_file` for a path we already
2136
+ # read THIS TURN AND nobody edited it since, skip the actual
2137
+ # dispatch and inject a tiny stub. Real-world heat cause
2138
+ # observed 2026-04-26: model re-reads `app.py` (445 lines),
2139
+ # `index.html` (76), `app.js` (220) two-three times each
2140
+ # while investigating. Each duplicate adds 5-10K chars to
2141
+ # the prompt; by round 13 the prompt was 66K chars =
2142
+ # 70-90s TTFT/round = full GPU = laptop heat.
2143
+ _dedup_stub = dedup_stub_for_tool(tool_name, args, _tool_exec_state)
2144
+ # Same-call 3× guard for tools without their own dedup
2145
+ # (bash / web_fetch / web_search / launch_app). Took the
2146
+ # place of the broader exact-repeat counter that was retired
2147
+ # 2026-04-26 — re-added narrowly after an info-fetch turn
2148
+ # fired the same read-only `curl` command 4× in a
2149
+ # row. dedup_stub wins ties so list_files/glob/grep keep
2150
+ # their existing message.
2151
+ if _dedup_stub is None:
2152
+ _repeat_stub = repeat_stub_for_tool(tool_name, args, _tool_exec_state)
2153
+ if _repeat_stub is not None:
2154
+ _dedup_stub = _repeat_stub
2155
+
2156
+ # Tool-arg size guard. Blocks the runaway-edit class
2157
+ # (model emitting 100K+ JSON in a single edit_file/
2158
+ # write_file/multi_edit). Real failure 2026-04-26: model
2159
+ # tried to stuff hundreds of common English words into
2160
+ # one edit; the args reached 112,964 chars before
2161
+ # truncating mid-string, llama-server's tool-call parser
2162
+ # returned HTTP 500, the turn died with E3102. We REJECT
2163
+ # with a specific instruction that tells the model HOW
2164
+ # to recover (split into multiple edits) so the existing
2165
+ # POST_REJECTION stall path nudges it to retry — model
2166
+ # doesn't just stop, it actually splits the work.
2167
+ _oversize_stub = oversize_stub_for_tool(tool_name, args, 1_000_000)
2168
+ # edit_file already carries the exact old text as grounded context.
2169
+ # Requiring a separate read first rejected valid one-shot edits and
2170
+ # let small models falsely narrate success after the rejection.
2171
+ _edit_sequence_stub = None
2172
+
2173
+ # HARD rewrite-stop: the churn NUDGE (limit 3) only advises — logs
2174
+ # showed a model rewrite one file 16x while 25-34 nudges fired.
2175
+ # Past 2x the nudge limit, REJECT further full rewrites (see
2176
+ # recovery.rewrite_hard_stop). Key on the RAW path like the counter.
2177
+ _rewrite_limit_stub = None
2178
+ if tool_name in ("write_file", "append_file", "multi_edit") and isinstance(args, dict):
2179
+ _rw_path = args.get("path") or args.get("file_path") or ""
2180
+ _rewrite_limit_stub = rewrite_hard_stop(_rw_path, _file_write_counts)
2181
+ # Execute (timed — wall-clock added to _round_tool_exec_ms
2182
+ # so round_end can show the model what fraction of its
2183
+ # round time was spent waiting on tools vs LLM).
2184
+ _tool_started_at = time.monotonic()
2185
+ if _rewrite_limit_stub is not None:
2186
+ from ..tools import ToolResult as _ToolResult
2187
+ _tool_result_obj = _ToolResult(text=_rewrite_limit_stub, ok=False, facts={"tool": tool_name, "ok": False, "repeated_failed_call": True, "rewrite_hard_stop": True})
2188
+ elif _edit_sequence_stub is not None:
2189
+ from ..tools import ToolResult as _ToolResult
2190
+ _tool_result_obj = _ToolResult(text=_edit_sequence_stub, ok=False, facts={"tool": tool_name, "ok": False, "edit_sequence": "missing_context"})
2191
+ elif _oversize_stub is not None:
2192
+ from ..tools import ToolResult as _ToolResult
2193
+ _tool_result_obj = _ToolResult(text=_oversize_stub, ok=False, facts={"tool": tool_name, "ok": False, "oversize": True})
2194
+ elif _dedup_stub is not None:
2195
+ from ..tools import ToolResult as _ToolResult
2196
+ # Match BOTH the soft "REJECTED:" and the hard
2197
+ # "REJECTED — HARD STOP:" stubs (same as tool_result_is_error).
2198
+ # The old colon-only check mislabeled hard-stop rejections as
2199
+ # ok=True / repeated_failed_call=False, corrupting the regression
2200
+ # telemetry used to tune these thresholds.
2201
+ _dedup_is_error = str(_dedup_stub).startswith("REJECTED")
2202
+ _tool_result_obj = _ToolResult(
2203
+ text=_dedup_stub,
2204
+ ok=not _dedup_is_error,
2205
+ facts={
2206
+ "tool": tool_name,
2207
+ "ok": not _dedup_is_error,
2208
+ "dedup": not _dedup_is_error,
2209
+ "repeated_failed_call": _dedup_is_error,
2210
+ },
2211
+ )
2212
+ elif _tc_idx in _parallel_futures:
2213
+ # Already running in the prefetch ThreadPool above; .result()
2214
+ # blocks until done. By the time we reach the Nth tool in
2215
+ # the serial loop, the first N-1 have usually finished.
2216
+ try:
2217
+ _tool_result_obj = _parallel_futures[_tc_idx].result()
2218
+ except Exception as _e:
2219
+ from ..tools import ToolResult as _ToolResult
2220
+ _tool_result_obj = _ToolResult(
2221
+ text=f"Error in {tool_name}: {type(_e).__name__}: {_e}",
2222
+ ok=False,
2223
+ facts={"tool": tool_name, "ok": False, "error_type": type(_e).__name__},
2224
+ )
2225
+ else:
2226
+ _tool_result_obj = _execute_tool_result(app, tool_name, args, out)
2227
+ tool_result = hook_after_tool(tool_name, args, str(_tool_result_obj), _hook_state)
2228
+ _tool_facts = dict(getattr(_tool_result_obj, "facts", {}) or {})
2229
+ if tool_result != str(_tool_result_obj):
2230
+ _tool_facts = extract_tool_facts(tool_name, args, str(tool_result))
2231
+ _hook_state.evidence.add_tool_result(
2232
+ tool_name,
2233
+ args,
2234
+ str(tool_result),
2235
+ _tool_facts,
2236
+ )
2237
+ if tool_name in {"read_file", "grep", "glob", "list_files"} and not tool_result_is_error(str(tool_result)):
2238
+ _edit_context_seen = True
2239
+ try:
2240
+ from ..events import emit as _emit_tool_facts
2241
+ _emit_tool_facts(
2242
+ "tool_facts",
2243
+ turn_id=_turn_id,
2244
+ name=tool_name,
2245
+ facts=_tool_facts,
2246
+ )
2247
+ except Exception:
2248
+ pass
2249
+ try:
2250
+ from ..events import emit as _emit_tool_result
2251
+
2252
+ _result_text_for_event = str(tool_result)
2253
+ _emit_tool_result(
2254
+ "tool_result",
2255
+ name=tool_name,
2256
+ error=str(
2257
+ bool(not _tool_facts.get("ok", True))
2258
+ or tool_result_is_error(_result_text_for_event)
2259
+ ).lower(),
2260
+ chars=len(_result_text_for_event),
2261
+ preview=_result_text_for_event[:160].replace("\n", " "),
2262
+ )
2263
+ except Exception:
2264
+ pass
2265
+ _round_tool_exec_ms += int(
2266
+ (time.monotonic() - _tool_started_at) * 1000
2267
+ )
2268
+ _tool_succeeded = bool(_tool_facts.get("ok", True)) and not tool_result_is_error(str(tool_result))
2269
+ if _goal_state.goal_type in {"build_app", "edit_existing"}:
2270
+ from .state_machine import event_for_tool, transition
2271
+ _stage_event = event_for_tool(tool_name, succeeded=_tool_succeeded)
2272
+ if _stage_event is not None:
2273
+ _stage_transition = transition(_current_task_stage_for_thinking(), _stage_event)
2274
+ if _stage_transition.changed:
2275
+ _announce_task_stage(_stage_transition.after.value)
2276
+ if tool_name == "bash":
2277
+ _bash_cmd = str(args.get("command", ""))
2278
+ bash_history.append((_bash_cmd, str(tool_result)))
2279
+ from ..execution_policy import assess_shell_execution
2280
+ _execution = assess_shell_execution(_bash_cmd, str(tool_result), int(_tool_facts.get("exit_code", 0 if _tool_succeeded else 1)))
2281
+ if ran_build_or_test([(_bash_cmd, str(tool_result))]) and not str(tool_result).startswith("REJECTED"):
2282
+ # A guard's synthetic REJECTED stub is not a check run: it
2283
+ # must neither reset nor advance the plateau failure history.
2284
+ _round_build_outcomes.append(bool(_execution.task_succeeded))
2285
+ _round_build_outputs.append(str(tool_result))
2286
+ if not _execution.task_succeeded:
2287
+ app._last_failed_tool_name = tool_name
2288
+ else:
2289
+ app._last_failed_tool_name = ""
2290
+ if _goal_state.goal_type in {"build_app", "edit_existing"} and ran_build_or_test([(_bash_cmd, str(tool_result))]):
2291
+ from pathlib import Path as _EvidencePath
2292
+ from ..evidence import EvidenceRequirement
2293
+ _verification_files = tuple(
2294
+ _EvidencePath(path) if _EvidencePath(path).is_absolute()
2295
+ else _EvidencePath(app.repo_root) / path
2296
+ for path in changed_files
2297
+ )
2298
+ _hook_state.verification_registry.require(EvidenceRequirement(
2299
+ "relevant-verification", _verification_files, _bash_cmd,
2300
+ ("PATH", "NODE_ENV", "PYTHONPATH"),
2301
+ ))
2302
+ _hook_state.verification_registry.record(
2303
+ "relevant-verification", environment=os.environ,
2304
+ passed=_execution.task_succeeded, output=str(tool_result),
2305
+ )
2306
+ from .state_machine import TaskEvent, transition
2307
+ _verify_event = TaskEvent.VERIFICATION_PASSED if _execution.task_succeeded else TaskEvent.VERIFICATION_FAILED
2308
+ _verify_transition = transition(_current_task_stage_for_thinking(), _verify_event)
2309
+ if _verify_transition.changed:
2310
+ _announce_task_stage(_verify_transition.after.value)
2311
+ if _goal_state.goal_type in {"build_app", "run_or_launch"}:
2312
+ port = extract_port(f"{args.get('command', '')}\n{tool_result}")
2313
+ if port:
2314
+ try:
2315
+ if hasattr(app, "store") and getattr(app, "session", None) is not None:
2316
+ app.store.update_task(
2317
+ app.session,
2318
+ active_port=port,
2319
+ )
2320
+ except Exception:
2321
+ pass
2322
+ if (
2323
+ tool_name == "launch_app"
2324
+ and _goal_state.goal_type in {"build_app", "run_or_launch"}
2325
+ and "App launched and verified." in str(tool_result)
2326
+ ):
2327
+ _launch_url = ""
2328
+ _launch_port = 0
2329
+ _url_match = re.search(r"URL:\s*(http://[^\s]+)", str(tool_result))
2330
+ if _url_match:
2331
+ _launch_url = _url_match.group(1).strip()
2332
+ _launch_port = extract_port(_launch_url)
2333
+ if _launch_port:
2334
+ try:
2335
+ if hasattr(app, "store") and getattr(app, "session", None) is not None:
2336
+ app.store.update_task(
2337
+ app.session,
2338
+ active_port=_launch_port,
2339
+ current_stage="verified",
2340
+ )
2341
+ except Exception:
2342
+ pass
2343
+ _verified_launch_summary = (
2344
+ f"The app is running and verified.\n\n"
2345
+ f"URL: {_launch_url or 'verified by launch_app'}"
2346
+ )
2347
+ if _goal_state.goal_type == "run_or_launch":
2348
+ _loop_exit_reason = "verified_run_or_launch"
2349
+ tools_called.append(tool_name)
2350
+ try:
2351
+ _recent = list(getattr(app, "_recent_tool_names", []) or [])
2352
+ if tool_name in _recent:
2353
+ _recent.remove(tool_name)
2354
+ _recent.insert(0, tool_name)
2355
+ app._recent_tool_names = _recent[:8]
2356
+ except Exception:
2357
+ pass
2358
+ track_tool_result(
2359
+ tool_name=tool_name,
2360
+ args=args,
2361
+ tool_result=str(tool_result),
2362
+ round_num=round_num,
2363
+ state=_tool_exec_state,
2364
+ dedup_stub=_dedup_stub,
2365
+ )
2366
+ # ── Semantic-churn counters ──
2367
+ # Count EVERY write/edit to a path (content-agnostic) and
2368
+ # every FAILED command by its family token. These feed
2369
+ # recovery.detect_churn after the round's tools all run.
2370
+ if tool_name in {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}:
2371
+ _churn_path = args.get("path") or args.get("file_path") or ""
2372
+ if isinstance(_churn_path, str) and _churn_path:
2373
+ _file_write_counts[_churn_path] = _file_write_counts.get(_churn_path, 0) + 1
2374
+ elif tool_name == "bash" and tool_result_is_error(str(tool_result)):
2375
+ _cmd_tok = command_token(str(args.get("command", "")))
2376
+ if _cmd_tok:
2377
+ _command_fail_counts[_cmd_tok] = _command_fail_counts.get(_cmd_tok, 0) + 1
2378
+ # Cross-round repeated-call tracking. A write/edit to a path resets
2379
+ # that path's read counts (read-after-edit is legitimate, not a
2380
+ # repeat). Idempotent read-only tools + bash/web are counted.
2381
+ if tool_name in {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}:
2382
+ _wp = args.get("path") or args.get("file_path")
2383
+ if _wp:
2384
+ for _k in [k for k in _turn_call_sigs if k[0] == "read_file" and str(_wp) in k[1]]:
2385
+ del _turn_call_sigs[_k]
2386
+ if tool_name in {"read_file", "grep", "glob", "list_files", "bash", "web_fetch", "web_search"}:
2387
+ _sig = (tool_name, canonical_args(args))
2388
+ _turn_call_sigs[_sig] = _turn_call_sigs.get(_sig, 0) + 1
2389
+ _failure_count = 0
2390
+ if tool_result_is_error(str(tool_result)):
2391
+ # For bash use the whitespace-normalized cmd_key so this
2392
+ # count agrees with the breaker (`dedup_stub_for_tool` keys
2393
+ # on `bash_failures`). Keying on raw `failed_calls` here let
2394
+ # whitespace-varying re-emissions slip the steering nudge
2395
+ # while the breaker still hard-stopped — F2.
2396
+ if tool_name == "bash":
2397
+ _failure_count = _tool_exec_state.bash_failures.get(
2398
+ bash_cmd_key(args),
2399
+ 0,
2400
+ )
2401
+ else:
2402
+ _failure_count = _tool_exec_state.failed_calls.get(
2403
+ (tool_name, canonical_args(args)),
2404
+ 0,
2405
+ )
2406
+ # Hard-stop nudge: once an identical failing call crosses the
2407
+ # backstop threshold, inject ONE strong corrective that bypasses
2408
+ # the soft nudge cap. Without this, the cap (2) is exhausted and
2409
+ # the loop emits the same REJECTED stub with no steering — the
2410
+ # exact 6→11× spin observed 2026-06-19.
2411
+ if (
2412
+ _failure_count >= _HARD_STOP_THRESHOLD
2413
+ and tool_name in {"bash", "write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}
2414
+ and not _hard_stop_nudge_fired
2415
+ ):
2416
+ _hard_stop_nudge_fired = True
2417
+ messages.append({
2418
+ "role": "user",
2419
+ "content": (
2420
+ f"SYSTEM — HARD STOP: you have sent the identical {tool_name} "
2421
+ f"call {_failure_count} times this turn and it is now permanently "
2422
+ "blocked. It will NOT run again. STOP narrating that you will retry. "
2423
+ "Pick a concrete DIFFERENT action right now: fix the actual cause "
2424
+ "named in the rejection (e.g. create the missing directory, fix "
2425
+ "quoting), or use a different tool. If a project directory does not "
2426
+ "exist, create files with write_file (it makes parent directories) "
2427
+ "instead of cd-ing into a path that isn't there."
2428
+ ),
2429
+ })
2430
+ _ephemeral_nudge_indices.append(len(messages) - 1)
2431
+ if _failure_count >= 2 and tool_name in {
2432
+ "write_file",
2433
+ "append_file",
2434
+ "edit_file",
2435
+ "multi_edit",
2436
+ "edit_diff",
2437
+ "bash",
2438
+ }:
2439
+ if _generic_correction_nudges < _MAX_CONSECUTIVE_CORRECTIONS:
2440
+ _generic_correction_nudges += 1
2441
+ messages.append({
2442
+ "role": "user",
2443
+ "content": (
2444
+ f"SYSTEM: The last {tool_name} call failed repeatedly. "
2445
+ "Try a materially different approach now. Do not retry "
2446
+ "the same tool arguments."
2447
+ ),
2448
+ })
2449
+ _ephemeral_nudge_indices.append(len(messages) - 1)
2450
+ try:
2451
+ from ..events import emit as _emit_repeat_failure
2452
+
2453
+ _emit_repeat_failure(
2454
+ "auto_nudge",
2455
+ stall_mode="repeated_failed_tool_call",
2456
+ retry=_failure_count,
2457
+ failed_tool=tool_name,
2458
+ )
2459
+ except Exception:
2460
+ pass
2461
+ if tool_name == "read_file":
2462
+ _read_file_chars_this_turn += len(str(tool_result))
2463
+ if tool_name in {"edit_file", "multi_edit", "edit_diff"} and (
2464
+ "old_string not found" in str(tool_result)
2465
+ or "applied 0/" in str(tool_result).lower()
2466
+ or str(tool_result).startswith("Error:")
2467
+ or str(tool_result).startswith("REJECTED:")
2468
+ or bool(_tool_facts.get("reverted"))
2469
+ ):
2470
+ _edit_failures_this_turn += 1
2471
+ if tool_name == "write_file" and "already exists" in str(tool_result):
2472
+ _write_existing_rejections_this_turn += 1
2473
+ # Show result to user — send full result for write/edit so TUI can render diff
2474
+ is_error = tool_result_is_error(tool_result)
2475
+ # Match BOTH "REJECTED:" and "REJECTED — HARD STOP:" (em-dash) — the
2476
+ # latter used to slip past this colon-only check and render as a raw
2477
+ # red protocol line to the user.
2478
+ is_rejected = tool_result.startswith("REJECTED")
2479
+ if is_rejected:
2480
+ # Internal tool-routing redirect — the model needs the full
2481
+ # REJECTED payload (kept in `messages` below), but the user sees
2482
+ # only a short neutral note, not a red error block.
2483
+ out.tool_result(_brief_result(tool_name, tool_result), error=False, idx=idx)
2484
+ elif tool_name in ("write_file", "append_file", "edit_file") and not is_error:
2485
+ out.tool_result(tool_result, error=False, idx=idx)
2486
+ else:
2487
+ out.tool_result(_brief_result(tool_name, tool_result), error=is_error, idx=idx)
2488
+
2489
+ # Truncate per-tool
2490
+ _facts_note = facts_suffix(_tool_facts)
2491
+ if _facts_note and _facts_note not in tool_result:
2492
+ tool_result = f"{tool_result}{_facts_note}"
2493
+ tool_result = _truncate_result(tool_result, tool_name, ctx_tokens=_ctx_tokens_turn)
2494
+ aggregate_size += len(tool_result)
2495
+
2496
+ # Aggregate budget. The post-budget clamp scales with the window too:
2497
+ # once a turn's cumulative tool output crosses the (dynamic) budget,
2498
+ # a big machine still keeps a proportionally larger stub than a 16 GB
2499
+ # one instead of a brutal fixed 500 chars.
2500
+ if aggregate_size > _aggregate_budget:
2501
+ _stub_chars = max(500, int(_ctx_tokens_turn * 3.5 * 0.01)) if _ctx_tokens_turn else 500
2502
+ tool_result = tool_result[:_stub_chars] + "\n[Truncated — context budget exceeded this turn]"
2503
+
2504
+ # ── Todo-close verification nudge (claude-code TodoWriteTool) ──
2505
+ # When the model just marked a 3+ item todo list fully done and none
2506
+ # of the items was a verification step, append a one-line "verify
2507
+ # before your final summary" reminder that RIDES ON this tool result
2508
+ # — no extra round. Appended after truncation so the short reminder
2509
+ # can't be clipped. General + cheap (pure fn of the todos it sent).
2510
+ if tool_name == "todo_write" and isinstance(args, dict):
2511
+ try:
2512
+ _todo_suffix = todo_close_verification_suffix(args.get("todos"))
2513
+ except Exception:
2514
+ _todo_suffix = ""
2515
+ if _todo_suffix:
2516
+ tool_result = f"{tool_result}{_todo_suffix}"
2517
+ try:
2518
+ from ..events import emit as _emit_todo_verify
2519
+ _emit_todo_verify(
2520
+ "todo_close_verify_nudge",
2521
+ turn_id=_turn_id,
2522
+ round_idx=round_num,
2523
+ item_count=len(args.get("todos") or []),
2524
+ )
2525
+ except Exception:
2526
+ pass
2527
+
2528
+ # Add to history
2529
+ messages.append({
2530
+ "role": "tool",
2531
+ "content": tool_result,
2532
+ "tool_call_id": tc.get("id", ""),
2533
+ })
2534
+ if (
2535
+ tool_name in {"edit_file", "multi_edit", "edit_diff"}
2536
+ and (not bool(_tool_facts.get("ok", True)) or bool(_tool_facts.get("reverted")))
2537
+ and _edit_recovery_nudges < 2
2538
+ ):
2539
+ _edit_recovery_nudges += 1
2540
+ path_hint = args.get("path") or args.get("file_path") or "the target file"
2541
+ messages.append({
2542
+ "role": "user",
2543
+ "content": (
2544
+ "SYSTEM: Edit recovery required. Do not retry the same large or fuzzy edit. "
2545
+ f"Read a focused range around {path_hint!r}, copy a smaller exact old_string "
2546
+ "from that read output, then use edit_file/multi_edit with the minimal targeted "
2547
+ "change. If the file is syntactically broken, fix the nearest syntax error first "
2548
+ "and run the smallest verification command."
2549
+ ),
2550
+ })
2551
+ _ephemeral_nudge_indices.append(len(messages) - 1)
2552
+ if _loop_exit_reason == "verified_run_or_launch":
2553
+ break
2554
+
2555
+ # Tear down the prefetch pool. By this point every future has
2556
+ # been .result()'d in the serial loop above (or the loop broke
2557
+ # early on cancel — orphaned futures are allowed to drain
2558
+ # because their underlying file/network ops are short-lived).
2559
+ if _parallel_pool is not None:
2560
+ _parallel_pool.shutdown(wait=False)
2561
+
2562
+ # Content was already streamed live via out.stream(); just record it
2563
+ # for the return value (don't re-render — that produced duplicate text
2564
+ # below tool results in CLI mode).
2565
+ if content:
2566
+ full_response.append(content)
2567
+
2568
+ if _loop_exit_reason == "verified_run_or_launch":
2569
+ if _verified_launch_summary:
2570
+ _render_markdown(
2571
+ _verified_launch_summary,
2572
+ app.console if hasattr(app, 'console') else None,
2573
+ )
2574
+ full_response.append(_verified_launch_summary)
2575
+ break
2576
+
2577
+ # NOTE: there used to be a "run_or_launch + a port opened ⇒ the task is
2578
+ # done" break here. It was an anti-pattern — none of the reference
2579
+ # harnesses (codex, opencode, pi, claude-code) infer completion from a
2580
+ # detected port, a launched process, or output patterns. All of them end
2581
+ # a turn on ONE structural signal: the model returned a message with no
2582
+ # tool calls. That block let `npm run dev` opening a socket count as a
2583
+ # finished app at ~10% of the work. Removed for good. Completion is now
2584
+ # model-driven (the no-tool-calls exit below), backstopped only by the
2585
+ # open-todo gate for the small local models that stop early — the harness
2586
+ # nudges AGAINST stopping, it never invents a "done" signal. If a run
2587
+ # task launches a server, the model reports the URL in its own reply,
2588
+ # exactly like the reference tools do.
2589
+
2590
+ # ── Cross-round repeated-call nudge ──
2591
+ # The dominant waste in the logs: the model calls the SAME (tool, args)
2592
+ # over and over across rounds (read_file same path 53x; pkill->curl->read
2593
+ # spins). The in-round breaker misses it and these calls SUCCEED so the
2594
+ # failure breakers don't trip. NUDGE only — the model already has every
2595
+ # result, so we never withhold content (no read-dedup-stub starvation).
2596
+ if (
2597
+ not _xround_repeat_nudge_done
2598
+ and not _churn_nudge_done
2599
+ and not _spin_nudge_done
2600
+ and _is_enabled(Feature.AUTO_NUDGE_RECOVERY)
2601
+ ):
2602
+ _hot = [(k, c) for k, c in _turn_call_sigs.items() if c >= CROSS_ROUND_REPEAT_LIMIT]
2603
+ if _hot:
2604
+ _hk, _hc = max(_hot, key=lambda kc: kc[1])
2605
+ _xround_repeat_nudge_done = True
2606
+ try:
2607
+ from ..events import emit as _emit_xr
2608
+ _emit_xr("auto_nudge", signal="cross_round_repeat",
2609
+ tool=_hk[0], count=_hc, round_idx=round_num)
2610
+ except Exception:
2611
+ pass
2612
+ out.print_info(
2613
+ f"Detected a cross-round loop ({_hk[0]} called {_hc}x with the "
2614
+ "same args) — nudging it to use the result it has."
2615
+ )
2616
+ messages.append({"role": "user", "content": (
2617
+ f"SYSTEM: You've already called {_hk[0]} with the SAME arguments "
2618
+ f"{_hc} times this turn — its result is unchanged and already "
2619
+ "above. Do NOT call it again. Use the result you have and take "
2620
+ "the next concrete step (make an edit, run the build, or finish). "
2621
+ "If a server/port check keeps failing, the server is on a "
2622
+ "different port or not running — read its startup log instead of "
2623
+ "re-checking."
2624
+ )})
2625
+ _ephemeral_nudge_indices.append(len(messages) - 1)
2626
+
2627
+ # ── Semantic-churn nudge ──
2628
+ # After the round's tools all ran, check whether the turn-so-far
2629
+ # is thrashing: same file rewritten N times, same command failing
2630
+ # N times, or a long pure read-only spin. The byte-identical-call
2631
+ # breakers miss these (different content / different output each
2632
+ # time). ONE nudge per turn — if the model ignores it, the normal
2633
+ # exit paths (repeated-failure guard, stall, completion gate)
2634
+ # still apply. Gated on the same recovery feature flag as stalls.
2635
+ # Update the planning-without-progress streak for THIS round. Disjoint
2636
+ # from the read-only-spin signal: a pure read-only round feeds
2637
+ # `_readonly_streak` (INVESTIGATION_SPIN) and is deliberately NOT
2638
+ # counted here, so this signal targets exactly what that one misses —
2639
+ # rounds that re-derive the plan via thinking/narration without
2640
+ # reading or doing anything. A round makes "progress" if it changed a
2641
+ # new file or ran a build/verify this round; either resets the streak.
2642
+ # Otherwise, a non-read-only round that produced thinking/narration
2643
+ # counts as another re-planning round. Quiet rounds (no progress, no
2644
+ # narration) leave the streak unchanged.
2645
+ _round_changed_new_file = len(changed_files) > _changed_files_at_round_start
2646
+ _round_bash = bash_history[_bash_history_at_round_start:]
2647
+ _round_ran_build = ran_build_or_test(_round_bash)
2648
+ # Successful setup/scaffolding is concrete progress too. The live trace
2649
+ # incorrectly called mkdir + npm create "re-planning without progress".
2650
+ _round_ran_successful_bash = any(
2651
+ result and not str(result).startswith(("[exit code ", "Error:", "REJECTED:"))
2652
+ for _cmd, result in _round_bash
2653
+ )
2654
+ _round_had_reasoning = bool(
2655
+ (content and content.strip()) or _round_reasoning_chars > 0
2656
+ )
2657
+ if _round_changed_new_file or _round_ran_build or _round_ran_successful_bash:
2658
+ _planning_streak = 0
2659
+ elif _round_had_reasoning and not _round_was_readonly:
2660
+ _planning_streak += 1
2661
+
2662
+ # ── Plateau detector: nudge at 6 no-progress rounds, stop 8 later ──
2663
+ if _is_enabled(Feature.AUTO_NUDGE_RECOVERY):
2664
+ _plateau_action = _plateau.observe_round(
2665
+ round_idx=round_num,
2666
+ changed_new_file=_round_changed_new_file,
2667
+ build_outcomes=_round_build_outcomes,
2668
+ build_outputs=_round_build_outputs,
2669
+ todos_before=_todos_at_round_start,
2670
+ todos_after=_todo_progress_snapshot(),
2671
+ )
2672
+ if _plateau_action == "wrap_up":
2673
+ if _append_nudge(_plateau.wrap_up_nudge(_open_todo_labels()), kind="plateau_wrap_up"):
2674
+ out.print_info(
2675
+ f"No new progress for {_plateau.nudge_after} steps — asking the "
2676
+ "model to stop experimenting and finish the deliverables."
2677
+ )
2678
+ try:
2679
+ from ..events import emit as _emit_plateau
2680
+ _emit_plateau("auto_nudge", signal="plateau_wrap_up",
2681
+ no_progress_rounds=_plateau.nudge_after,
2682
+ round_idx=round_num,
2683
+ changed_files=len(changed_files))
2684
+ except Exception:
2685
+ pass
2686
+ elif _plateau_action == "finish_now":
2687
+ if _append_nudge(_plateau.finish_now_nudge(), kind="plateau_finish_now"):
2688
+ out.print_info(
2689
+ "The model's check passed — asking it to finish now instead of stopping."
2690
+ )
2691
+ try:
2692
+ from ..events import emit as _emit_plateau_finish
2693
+ _emit_plateau_finish("auto_nudge", signal="plateau_finish_now",
2694
+ no_progress_rounds=_plateau.total_no_progress,
2695
+ round_idx=round_num,
2696
+ changed_files=len(changed_files))
2697
+ except Exception:
2698
+ pass
2699
+ elif _plateau_action == "exhausted":
2700
+ _plateau_msg = _plateau.exhausted_summary(
2701
+ changed_files=changed_files,
2702
+ open_todos=_open_todo_labels(),
2703
+ )
2704
+ out.print_info("Stopping: the model stopped making progress and ignored the wrap-up request.")
2705
+ _render_markdown(_plateau_msg, app.console if hasattr(app, 'console') else None)
2706
+ full_response.append(_plateau_msg)
2707
+ try:
2708
+ from ..events import emit as _emit_plateau_break
2709
+ _emit_plateau_break("loop_break", signal="plateau_exhausted",
2710
+ no_progress_rounds=_plateau.total_no_progress,
2711
+ round_idx=round_num,
2712
+ changed_files=len(changed_files))
2713
+ except Exception:
2714
+ pass
2715
+ _loop_exit_reason = "plateau_exhausted"
2716
+ break
2717
+
2718
+ if not _churn_nudge_done and not _spin_nudge_done and _is_enabled(Feature.AUTO_NUDGE_RECOVERY):
2719
+ _churn = detect_churn(
2720
+ file_write_counts=_file_write_counts,
2721
+ command_fail_counts=_command_fail_counts,
2722
+ readonly_streak=_readonly_streak,
2723
+ planning_streak=_planning_streak,
2724
+ )
2725
+ if _churn is not None:
2726
+ _churn_nudge_done = True
2727
+ _last_churn_mode = _churn.mode.value
2728
+ try:
2729
+ from ..events import emit as _emit_churn
2730
+ _emit_churn(
2731
+ "auto_nudge",
2732
+ signal="semantic_churn",
2733
+ churn_mode=_churn.mode.value,
2734
+ subject=_churn.subject,
2735
+ count=_churn.count,
2736
+ round_idx=round_num,
2737
+ )
2738
+ except Exception:
2739
+ pass
2740
+ out.print_info(
2741
+ f"Detected churn ({_churn.mode.value}) — nudging it to "
2742
+ "stop thrashing and make a targeted fix."
2743
+ )
2744
+ messages.append({"role": "user", "content": churn_nudge_for(_churn)})
2745
+ _ephemeral_nudge_indices.append(len(messages) - 1)
2746
+ _last_nudge_kind = "churn"
2747
+
2748
+ # ── Reject → re-read → reject recovery rung (bounded, single-shot) ──
2749
+ # The remaining log loop the other detectors miss: a mutation keeps
2750
+ # coming back REJECTED (dedup / "already modified" / old_string-not-found)
2751
+ # and the model reacts by re-reading the file and retrying the same
2752
+ # shape. detect_reject_reread_loop RECOMPUTES the signal from the
2753
+ # transcript tail (survives compaction). We give ONE actionable redirect
2754
+ # per turn, then stop nagging (the guard boolean). Composes with the
2755
+ # existing breakers: gated so it won't fire in a round another nudge
2756
+ # already fired, and `_append_nudge` blocks the same nag twice running.
2757
+ if (
2758
+ not _reject_reread_nudge_done
2759
+ and not _churn_nudge_done
2760
+ and not _spin_nudge_done
2761
+ and not _xround_repeat_nudge_done
2762
+ and not _hard_stop_nudge_fired
2763
+ and _is_enabled(Feature.AUTO_NUDGE_RECOVERY)
2764
+ ):
2765
+ _rr_count = detect_reject_reread_loop(messages)
2766
+ if _rr_count is not None:
2767
+ if _append_nudge(reject_reread_nudge(), kind="reject_reread"):
2768
+ _reject_reread_nudge_done = True
2769
+ out.print_info(
2770
+ "Detected a reject → re-read loop — giving it one "
2771
+ "targeted redirect."
2772
+ )
2773
+ try:
2774
+ from ..events import emit as _emit_rr
2775
+ _emit_rr("auto_nudge", signal="reject_reread_loop",
2776
+ count=_rr_count, round_idx=round_num)
2777
+ except Exception:
2778
+ pass
2779
+
2780
+ # Break outer loop if loop was detected
2781
+ if loop_detected:
2782
+ break
2783
+
2784
+ # Compaction is handled ONCE per round at the TOP of the loop by the
2785
+ # window-aware `should_compact`/`compact` path (see ~line 612), whose
2786
+ # threshold scales with the model's real context window and the user's
2787
+ # RAM. The old fixed-12K/27K second pass that used to live here fired at
2788
+ # <5% of a 256K window, crushed all but 8 messages into a 5-line note,
2789
+ # and made the model lose provenance of its own writes → the re-read /
2790
+ # "fix systematically" churn loop. Removed: one dynamic system only.
2791
+
2792
+ else:
2793
+ # `for…else` only fires when the loop exhausted naturally
2794
+ # (i.e. `range(MAX_ROUNDS)` was consumed without a `break`).
2795
+ # When MAX_ROUNDS<=0 we use `itertools.count()` which never
2796
+ # exhausts, so this branch only fires for a positive cap.
2797
+ if _effective_max_rounds > 0:
2798
+ out.print_info(f"Reached max rounds ({_effective_max_rounds})")
2799
+ _loop_exit_reason = "max_rounds"
2800
+
2801
+ # Fallback: if we somehow exited without setting a reason, mark
2802
+ # so it's visible in telemetry rather than blank. Covers any
2803
+ # break path I haven't yet annotated.
2804
+ if not _loop_exit_reason:
2805
+ _loop_exit_reason = "unknown"
2806
+
2807
+ # Strip ephemeral nudges from `messages` BEFORE persisting the
2808
+ # turn. These were synthetic SYSTEM-prefixed user messages we
2809
+ # injected mid-loop to push the model out of a spin or stall;
2810
+ # they served their purpose during this turn. Leaving them in
2811
+ # `messages` poisons future turns — the model continues honoring
2812
+ # "STOP investigating, do NOT call read_file…" instructions long
2813
+ # after the spin is over. Pop in reverse so earlier indices stay
2814
+ # valid.
2815
+ if _ephemeral_nudge_indices:
2816
+ strip_ephemeral_nudges(messages, _ephemeral_nudge_indices)
2817
+
2818
+ # End-of-turn telemetry. Captures the complete picture: full
2819
+ # user input was in turn_start; full final assistant response
2820
+ # goes here along with tool counts, duration, and success flag.
2821
+ # Together turn_start + turn_end form a replayable record of
2822
+ # the turn for offline debugging.
2823
+ final_text = "".join(full_response).strip()
2824
+ try:
2825
+ from ..events import emit as _emit_edit_quality
2826
+ _emit_edit_quality(
2827
+ "edit_quality",
2828
+ turn_id=_turn_id,
2829
+ read_file_chars=_read_file_chars_this_turn,
2830
+ edit_failures=_edit_failures_this_turn,
2831
+ write_existing_rejections=_write_existing_rejections_this_turn,
2832
+ changed_files_count=len(changed_files),
2833
+ changed_files=changed_files[:20],
2834
+ )
2835
+ except Exception:
2836
+ pass
2837
+ _final_task_stage = getattr(_task_state, "current_stage", "")
2838
+ finalize_turn(
2839
+ app=app,
2840
+ turn_id=_turn_id,
2841
+ task_state=_task_state,
2842
+ goal_state=_goal_state,
2843
+ prompt_result=prompt_result,
2844
+ final_text=final_text,
2845
+ loop_exit_reason=_loop_exit_reason,
2846
+ final_task_stage=_final_task_stage,
2847
+ started_mono=_turn_started_mono,
2848
+ time_module=_time_mod,
2849
+ tools_called=tools_called,
2850
+ round_num=round_num if "round_num" in dir() else None,
2851
+ tokens_in=_turn_prompt_tokens,
2852
+ tokens_out=_turn_completion_tokens,
2853
+ tokens_total=_turn_total_tokens,
2854
+ )
2855
+ return final_text