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