localcode 0.4.0__py3-none-macosx_13_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Plateau detector — stop a model that has reached its ceiling.
|
|
2
|
+
|
|
3
|
+
Measured on HarnessBench 2026-09-08 (17 tasks, 27B model, 20 min cap): every
|
|
4
|
+
task that hit the cap had already reached the oracle score other frontends got
|
|
5
|
+
by STOPPING early. The model kept re-editing the same file and re-running the
|
|
6
|
+
same experiments instead of writing the remaining deliverables. None of the
|
|
7
|
+
existing breakers fire on that shape: each edit has different content, each
|
|
8
|
+
command a different output, and the calls all succeed.
|
|
9
|
+
|
|
10
|
+
"Progress" in a round is any of:
|
|
11
|
+
* a write/edit to a path not previously changed this turn (a NEW file or a
|
|
12
|
+
first touch of an existing one);
|
|
13
|
+
* a build/typecheck/test run that PASSED after the previous one this turn
|
|
14
|
+
failed, or the first pass this turn;
|
|
15
|
+
* a build/test run whose FAILURE SET moved on from the previous check this
|
|
16
|
+
turn: fewer failure lines, or the same count but a signature never seen
|
|
17
|
+
before this turn (a genuine debugging loop — the failure changes as the
|
|
18
|
+
model fixes things). A failure signature already seen this turn is NOT
|
|
19
|
+
progress: that is the true loop;
|
|
20
|
+
* a todo item transitioning to completed (or the list closing out).
|
|
21
|
+
|
|
22
|
+
Everything else — reads, greps, re-edits of already-edited files, identical
|
|
23
|
+
failing or repeat-passing test runs, throwaway experiments — is not progress.
|
|
24
|
+
|
|
25
|
+
Policy: after `nudge_after` consecutive no-progress rounds, ONE wrap-up nudge
|
|
26
|
+
naming the open plan items (finish the deliverables with what you have). After
|
|
27
|
+
`stop_after` more, the turn ends with an honest summary — unless the most
|
|
28
|
+
recent check this turn PASSED within the last `PLATEAU_PASS_WINDOW` rounds, in
|
|
29
|
+
which case the model is told ONCE to finish now and given
|
|
30
|
+
`PLATEAU_FINISH_ROUNDS` more rounds. Nothing fires in the first
|
|
31
|
+
`PLATEAU_GRACE_ROUNDS` rounds so a slow start is never mistaken for a plateau.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"PlateauTracker",
|
|
40
|
+
"PLATEAU_GRACE_ROUNDS",
|
|
41
|
+
"PLATEAU_NUDGE_AFTER",
|
|
42
|
+
"PLATEAU_STOP_AFTER",
|
|
43
|
+
"PLATEAU_PASS_WINDOW",
|
|
44
|
+
"PLATEAU_FINISH_ROUNDS",
|
|
45
|
+
"failure_signature",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Rounds (0-indexed) during which the detector never fires.
|
|
49
|
+
PLATEAU_GRACE_ROUNDS = 4
|
|
50
|
+
# Consecutive no-progress rounds before the wrap-up nudge.
|
|
51
|
+
PLATEAU_NUDGE_AFTER = 6
|
|
52
|
+
# Consecutive no-progress rounds AFTER the nudge before the turn ends.
|
|
53
|
+
PLATEAU_STOP_AFTER = 8
|
|
54
|
+
# A check that passed within this many rounds keeps the turn alive …
|
|
55
|
+
PLATEAU_PASS_WINDOW = 2
|
|
56
|
+
# … and the "finish now" nudge grants this many more rounds.
|
|
57
|
+
PLATEAU_FINISH_ROUNDS = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── failure-signature extraction ─────────────────────────────────────────
|
|
61
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
62
|
+
# A path with at least one directory component: keep only the basename.
|
|
63
|
+
_PATH_DIR_RE = re.compile(r"(?<![\w.])(?:[\w.~-]*/)+(?=[\w.-]+)")
|
|
64
|
+
_NUM_RE = re.compile(r"\d+(?:\.\d+)?")
|
|
65
|
+
_WS_RE = re.compile(r"\s+")
|
|
66
|
+
_FAILURE_LINE_RE = re.compile(
|
|
67
|
+
r"FAILED|AssertionError|error TS|\berror\b|Error\b|✕|●|\bFAIL\b|Traceback|panic:",
|
|
68
|
+
)
|
|
69
|
+
_NOISE_LINE_RE = re.compile(
|
|
70
|
+
# summaries that mention errors without BEING a failure line (numbers
|
|
71
|
+
# are already normalised to N when this runs)
|
|
72
|
+
r"^\s*(?:=+.*=+|-+.*-+|(?:N|\d+) (?:passed|failed|error)s?\b.*|found (?:N|\d+) errors?.*)\s*$",
|
|
73
|
+
re.IGNORECASE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalise_check_output(text: str) -> str:
|
|
78
|
+
"""Strip ANSI, path directories, numbers (durations, line numbers,
|
|
79
|
+
timestamps) and collapse whitespace so two runs of the same check with
|
|
80
|
+
the same *meaning* compare equal."""
|
|
81
|
+
text = _ANSI_RE.sub("", str(text or ""))
|
|
82
|
+
text = _PATH_DIR_RE.sub("", text)
|
|
83
|
+
text = _NUM_RE.sub("N", text)
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def failure_signature(output: str) -> frozenset[str]:
|
|
88
|
+
"""The set of normalised failure lines of a build/test output.
|
|
89
|
+
|
|
90
|
+
Recognises pytest ("FAILED …", "AssertionError", "Error"), tsc
|
|
91
|
+
("error TSNNNN"), jest ("✕", "●") and generic "FAIL"/"error" lines.
|
|
92
|
+
Count-style summary lines ("3 failed, 2 passed") are ignored so the set
|
|
93
|
+
reflects WHICH things fail, not how many the runner counted. A failed
|
|
94
|
+
run with no recognisable failure line falls back to its last non-empty
|
|
95
|
+
line so it still has a comparable identity."""
|
|
96
|
+
lines: set[str] = set()
|
|
97
|
+
last_nonempty = ""
|
|
98
|
+
for raw in normalise_check_output(output).splitlines():
|
|
99
|
+
line = _WS_RE.sub(" ", raw).strip()
|
|
100
|
+
if not line:
|
|
101
|
+
continue
|
|
102
|
+
last_nonempty = line
|
|
103
|
+
if line.startswith(("[exit code", "<UNTRUSTED_DATA", "</UNTRUSTED_DATA")):
|
|
104
|
+
# loop bookkeeping / the tool-result wrapper (whose `source=`
|
|
105
|
+
# attribute echoes the command) — not part of the check's output
|
|
106
|
+
continue
|
|
107
|
+
if _NOISE_LINE_RE.match(line):
|
|
108
|
+
continue
|
|
109
|
+
if _FAILURE_LINE_RE.search(line):
|
|
110
|
+
lines.add(line)
|
|
111
|
+
if not lines and last_nonempty:
|
|
112
|
+
lines.add(last_nonempty)
|
|
113
|
+
return frozenset(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class PlateauTracker:
|
|
118
|
+
nudge_after: int = PLATEAU_NUDGE_AFTER
|
|
119
|
+
stop_after: int = PLATEAU_STOP_AFTER
|
|
120
|
+
grace_rounds: int = PLATEAU_GRACE_ROUNDS
|
|
121
|
+
pass_window: int = PLATEAU_PASS_WINDOW
|
|
122
|
+
finish_rounds: int = PLATEAU_FINISH_ROUNDS
|
|
123
|
+
no_progress_rounds: int = 0
|
|
124
|
+
total_no_progress: int = 0
|
|
125
|
+
nudged: bool = False
|
|
126
|
+
finish_nudged: bool = False
|
|
127
|
+
# Outcome of the most recent build/test this turn (None = none yet).
|
|
128
|
+
last_build_passed: bool | None = None
|
|
129
|
+
# Rounds since the most recent PASSING check (None = none this turn).
|
|
130
|
+
rounds_since_pass: int | None = None
|
|
131
|
+
# Failure-set tracking for the "debugging loop" progress signal.
|
|
132
|
+
last_failure_sig: frozenset[str] | None = None
|
|
133
|
+
seen_failure_sigs: set[frozenset[str]] = field(default_factory=set)
|
|
134
|
+
progress_log: list[str] = field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
# ── progress classification ──────────────────────────────────────────
|
|
137
|
+
def round_made_progress(
|
|
138
|
+
self,
|
|
139
|
+
*,
|
|
140
|
+
changed_new_file: bool,
|
|
141
|
+
build_outcomes: list[bool],
|
|
142
|
+
todos_before: tuple[int, int],
|
|
143
|
+
todos_after: tuple[int, int],
|
|
144
|
+
build_outputs: list[str] | None = None,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Classify one round. Also advances `last_build_passed` and the
|
|
147
|
+
failure-signature history. `build_outputs`, when given, is parallel
|
|
148
|
+
to `build_outcomes` (the raw output of each check)."""
|
|
149
|
+
progress = False
|
|
150
|
+
if changed_new_file:
|
|
151
|
+
progress = True
|
|
152
|
+
self.progress_log.append("new_file")
|
|
153
|
+
outputs = list(build_outputs or [])
|
|
154
|
+
for i, passed in enumerate(build_outcomes):
|
|
155
|
+
if passed:
|
|
156
|
+
if not self.last_build_passed:
|
|
157
|
+
# first pass this turn, or a pass after a failure
|
|
158
|
+
progress = True
|
|
159
|
+
self.progress_log.append("build_pass")
|
|
160
|
+
self.rounds_since_pass = 0
|
|
161
|
+
# a pass is an empty failure set: the next failure compares to it
|
|
162
|
+
self.last_failure_sig = frozenset()
|
|
163
|
+
else:
|
|
164
|
+
out = outputs[i] if i < len(outputs) else ""
|
|
165
|
+
sig = failure_signature(out)
|
|
166
|
+
prev = self.last_failure_sig
|
|
167
|
+
if prev is not None and len(sig) < len(prev):
|
|
168
|
+
progress = True
|
|
169
|
+
self.progress_log.append("failures_shrank")
|
|
170
|
+
elif prev is not None and sig != prev and sig not in self.seen_failure_sigs:
|
|
171
|
+
progress = True
|
|
172
|
+
self.progress_log.append("new_failure_set")
|
|
173
|
+
self.seen_failure_sigs.add(sig)
|
|
174
|
+
self.last_failure_sig = sig
|
|
175
|
+
self.last_build_passed = passed
|
|
176
|
+
open_before, done_before = todos_before
|
|
177
|
+
open_after, done_after = todos_after
|
|
178
|
+
if done_after > done_before or (open_before > 0 and open_after == 0):
|
|
179
|
+
progress = True
|
|
180
|
+
self.progress_log.append("todo_completed")
|
|
181
|
+
return progress
|
|
182
|
+
|
|
183
|
+
def recent_pass(self) -> bool:
|
|
184
|
+
"""True if a check passed in this round or the last `pass_window`."""
|
|
185
|
+
return self.rounds_since_pass is not None and self.rounds_since_pass <= self.pass_window
|
|
186
|
+
|
|
187
|
+
def observe_round(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
round_idx: int,
|
|
191
|
+
changed_new_file: bool,
|
|
192
|
+
build_outcomes: list[bool],
|
|
193
|
+
todos_before: tuple[int, int],
|
|
194
|
+
todos_after: tuple[int, int],
|
|
195
|
+
build_outputs: list[str] | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Feed one finished tool round. Returns "" (nothing to do),
|
|
198
|
+
"wrap_up" (inject the wrap-up nudge), "finish_now" (a check just
|
|
199
|
+
passed — tell the model to finish instead of stopping it) or
|
|
200
|
+
"exhausted" (end the turn)."""
|
|
201
|
+
# Age the last pass BEFORE classifying so a pass in this round is 0.
|
|
202
|
+
if self.rounds_since_pass is not None:
|
|
203
|
+
self.rounds_since_pass += 1
|
|
204
|
+
if self.round_made_progress(
|
|
205
|
+
changed_new_file=changed_new_file,
|
|
206
|
+
build_outcomes=build_outcomes,
|
|
207
|
+
build_outputs=build_outputs,
|
|
208
|
+
todos_before=todos_before,
|
|
209
|
+
todos_after=todos_after,
|
|
210
|
+
):
|
|
211
|
+
self.no_progress_rounds = 0
|
|
212
|
+
return ""
|
|
213
|
+
self.no_progress_rounds += 1
|
|
214
|
+
self.total_no_progress += 1
|
|
215
|
+
if round_idx < self.grace_rounds:
|
|
216
|
+
return ""
|
|
217
|
+
if not self.nudged:
|
|
218
|
+
if self.no_progress_rounds < self.nudge_after:
|
|
219
|
+
return ""
|
|
220
|
+
self.nudged = True
|
|
221
|
+
self.no_progress_rounds = 0
|
|
222
|
+
return "wrap_up"
|
|
223
|
+
if self.no_progress_rounds < self.stop_after:
|
|
224
|
+
return ""
|
|
225
|
+
if self.recent_pass():
|
|
226
|
+
# Never end a turn right after a passing check: the model may be
|
|
227
|
+
# one write away from the deliverable. Say so once, grant a few
|
|
228
|
+
# rounds; if the pass is still fresh at the next threshold, keep
|
|
229
|
+
# waiting rather than cutting off a finishing model.
|
|
230
|
+
if not self.finish_nudged:
|
|
231
|
+
self.finish_nudged = True
|
|
232
|
+
# exactly `finish_rounds` more no-progress rounds before the
|
|
233
|
+
# next threshold (may go negative when stop_after is small)
|
|
234
|
+
self.no_progress_rounds = self.stop_after - self.finish_rounds
|
|
235
|
+
return "finish_now"
|
|
236
|
+
return ""
|
|
237
|
+
return "exhausted"
|
|
238
|
+
|
|
239
|
+
# ── text ─────────────────────────────────────────────────────────────
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _open_items(open_todos: list[str] | None) -> str:
|
|
242
|
+
items = [str(t).strip() for t in (open_todos or []) if str(t).strip()]
|
|
243
|
+
if not items:
|
|
244
|
+
return ""
|
|
245
|
+
shown = items[:8]
|
|
246
|
+
bullets = "\n".join(f"- {t}" for t in shown)
|
|
247
|
+
more = "" if len(items) <= 8 else f"\n- … (+{len(items) - 8} more)"
|
|
248
|
+
return f"Open plan items:\n{bullets}{more}"
|
|
249
|
+
|
|
250
|
+
def wrap_up_nudge(self, open_todos: list[str] | None = None) -> str:
|
|
251
|
+
items = self._open_items(open_todos)
|
|
252
|
+
deliverables = (
|
|
253
|
+
"(1) write every open plan item below that does not exist yet, "
|
|
254
|
+
"using the best approach you have"
|
|
255
|
+
if items
|
|
256
|
+
else "(1) write every deliverable the user named that does not exist "
|
|
257
|
+
"yet, using the best approach you have"
|
|
258
|
+
)
|
|
259
|
+
text = (
|
|
260
|
+
f"SYSTEM: No new progress for {self.nudge_after} steps — you have "
|
|
261
|
+
"reached the limit of this approach. Stop experimenting. Now: "
|
|
262
|
+
f"{deliverables}; (2) where a requirement "
|
|
263
|
+
"cannot be met, say so explicitly in the deliverable or README "
|
|
264
|
+
"instead of retrying; (3) run the project's own check ONCE; "
|
|
265
|
+
"(4) finish."
|
|
266
|
+
)
|
|
267
|
+
if items:
|
|
268
|
+
text += "\n" + items
|
|
269
|
+
return text
|
|
270
|
+
|
|
271
|
+
def finish_now_nudge(self) -> str:
|
|
272
|
+
return (
|
|
273
|
+
"SYSTEM: Your check passed — finish now: write any remaining "
|
|
274
|
+
"deliverable and stop."
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def exhausted_summary(self, *, changed_files: list[str], open_todos: list[str]) -> str:
|
|
278
|
+
delivered = (
|
|
279
|
+
"Delivered (files changed this turn): " + ", ".join(f"`{p}`" for p in changed_files[:20])
|
|
280
|
+
+ ("" if len(changed_files) <= 20 else f" (+{len(changed_files) - 20} more)")
|
|
281
|
+
if changed_files
|
|
282
|
+
else "Delivered: no files were changed this turn."
|
|
283
|
+
)
|
|
284
|
+
items = self._open_items(open_todos)
|
|
285
|
+
if items:
|
|
286
|
+
not_done = "Not finished. " + items
|
|
287
|
+
else:
|
|
288
|
+
not_done = (
|
|
289
|
+
"Not finished: any remaining requirements the model did not write "
|
|
290
|
+
"out — check the deliverables above against the request."
|
|
291
|
+
)
|
|
292
|
+
return (
|
|
293
|
+
"LocalCode stopped this turn: the model made no new progress for "
|
|
294
|
+
f"{self.total_no_progress} steps (kept re-editing the same files and "
|
|
295
|
+
"re-running the same checks) and did not wrap up when asked. The "
|
|
296
|
+
f"task is incomplete.\n\n{delivered}\n{not_done}"
|
|
297
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Turn-level state for the project-typecheck completion gate.
|
|
2
|
+
|
|
3
|
+
The gate has to answer one question honestly: *did a checker actually prove this
|
|
4
|
+
project builds?* Three states, not two — green, red, and NEVER ANSWERED. The
|
|
5
|
+
third is the dangerous one: a timeout, a checker that could not execute, or a
|
|
6
|
+
TypeScript reference graph that was only partially covered all used to look
|
|
7
|
+
exactly like green.
|
|
8
|
+
|
|
9
|
+
Kept out of `loop.py` because the decision spans the whole turn (a failure in
|
|
10
|
+
round 3 must still block completion in round 6) and because the loop itself has
|
|
11
|
+
no test seam — this does.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ..tools.project_check import CheckOutcome
|
|
16
|
+
|
|
17
|
+
__all__ = ["ProjectCheckGate", "RED", "UNVERIFIED", "OK"]
|
|
18
|
+
|
|
19
|
+
# What the caller should do with the round.
|
|
20
|
+
RED = "red" # real diagnostics — feed them back
|
|
21
|
+
UNVERIFIED = "unverified" # no verdict — retry, then block completion
|
|
22
|
+
OK = "ok" # a checker ran green, or none was applicable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProjectCheckGate:
|
|
26
|
+
"""Remembers, for the whole turn, whether the project check ever answered."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, max_retries: int = 2) -> None:
|
|
29
|
+
self.max_retries = max_retries
|
|
30
|
+
self.unverified = ""
|
|
31
|
+
self.red = ""
|
|
32
|
+
self.retries = 0
|
|
33
|
+
|
|
34
|
+
def observe(self, outcome: CheckOutcome) -> str:
|
|
35
|
+
"""Record a completed check. Returns RED / UNVERIFIED / OK."""
|
|
36
|
+
if outcome.is_red:
|
|
37
|
+
# Red is a verdict: the checker worked. Clear any earlier no-verdict.
|
|
38
|
+
self.unverified = ""
|
|
39
|
+
# ...but REMEMBER it. The loop feeds the diagnostics back and asks
|
|
40
|
+
# for a fix, and that nudge is bounded so an unfixable project can't
|
|
41
|
+
# spin. Running out of nudges is not the project turning green: it
|
|
42
|
+
# used to drop the red on the floor and let the turn complete as
|
|
43
|
+
# verified with a broken build. Same rule the no-verdict path
|
|
44
|
+
# already states, applied to the verdict we actually have.
|
|
45
|
+
self.red = outcome.detail or outcome.label or "the project typecheck reported errors"
|
|
46
|
+
return RED
|
|
47
|
+
if outcome.status in ("timed_out", "failed"):
|
|
48
|
+
self.unverified = outcome.detail or outcome.status
|
|
49
|
+
return UNVERIFIED
|
|
50
|
+
# Only a checker that actually ran to GREEN clears an earlier verdict.
|
|
51
|
+
# `unavailable` reaches here too, and must NOT: "no checker available"
|
|
52
|
+
# is the absence of evidence, so treating it as clearing would let a
|
|
53
|
+
# project that went red in round 2 launder itself clean in round 3 the
|
|
54
|
+
# moment the checker stopped being detectable (node_modules removed
|
|
55
|
+
# mid-turn, a tsconfig edited into an unparseable state).
|
|
56
|
+
if outcome.is_verified:
|
|
57
|
+
self.unverified = ""
|
|
58
|
+
self.red = ""
|
|
59
|
+
return OK
|
|
60
|
+
|
|
61
|
+
def observe_exception(self, exc: BaseException) -> str:
|
|
62
|
+
"""An unexpected failure is a FAILED verification, never a clean one."""
|
|
63
|
+
self.unverified = (
|
|
64
|
+
f"the project typecheck could not be run ({exc.__class__.__name__})")
|
|
65
|
+
return UNVERIFIED
|
|
66
|
+
|
|
67
|
+
def consume_retry(self) -> bool:
|
|
68
|
+
"""True if another round should be forced. Bounded, so an unfixable
|
|
69
|
+
environment can't spin — but running out of retries does NOT make the
|
|
70
|
+
project verified; `blocks_completion` stays True."""
|
|
71
|
+
if self.retries >= self.max_retries:
|
|
72
|
+
return False
|
|
73
|
+
self.retries += 1
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def blocks_completion(self) -> bool:
|
|
77
|
+
"""A check that never returned a verdict — or that returned errors
|
|
78
|
+
nobody fixed — is not evidence of a working build, whatever else the
|
|
79
|
+
verification registry happens to hold."""
|
|
80
|
+
return bool(self.unverified or self.red)
|
|
81
|
+
|
|
82
|
+
def result_note(self) -> str:
|
|
83
|
+
"""Text for the FINAL RESULT, so the reason reaches the TUI and `--json`
|
|
84
|
+
(unlike print_info, which is invisible in the TUI and suppressed under
|
|
85
|
+
--json)."""
|
|
86
|
+
if self.unverified:
|
|
87
|
+
return f"\n\nProject typecheck was not verified: {self.unverified}"
|
|
88
|
+
if self.red:
|
|
89
|
+
return ("\n\nThe project typecheck is still reporting errors:\n"
|
|
90
|
+
f"{self.red}")
|
|
91
|
+
return ""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Prompt assembly helpers for the agent loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .prompts import (
|
|
10
|
+
REASONING_RULES,
|
|
11
|
+
SYSTEM_PROMPT,
|
|
12
|
+
_load_project_instructions,
|
|
13
|
+
model_identity_line,
|
|
14
|
+
project_stack_line,
|
|
15
|
+
)
|
|
16
|
+
from .sections import Section, SectionContext, compose_system_prompt, default_sections
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PromptBuildResult",
|
|
19
|
+
"build_agent_system_prompt",
|
|
20
|
+
"build_task_goal_block",
|
|
21
|
+
"build_dynamic_skills_block",
|
|
22
|
+
"build_target_grounding_block",
|
|
23
|
+
"build_incremental_milestones_block",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PromptBuildResult:
|
|
29
|
+
system_prompt: str
|
|
30
|
+
selected_skills: list[str]
|
|
31
|
+
selected_skill_origins: list[str]
|
|
32
|
+
selected_skill_chars: int
|
|
33
|
+
skill_candidates: list[dict[str, str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_agent_system_prompt(
|
|
37
|
+
*,
|
|
38
|
+
app: Any,
|
|
39
|
+
user_text: str,
|
|
40
|
+
goal_state: Any,
|
|
41
|
+
task_state: Any,
|
|
42
|
+
base_system_prompt: str,
|
|
43
|
+
network_status: str,
|
|
44
|
+
use_thinking: bool,
|
|
45
|
+
) -> PromptBuildResult:
|
|
46
|
+
project_instructions = _load_project_instructions(app.repo_root)
|
|
47
|
+
task_goal_block = build_task_goal_block(user_text, goal_state, task_state)
|
|
48
|
+
target_grounding_block = build_target_grounding_block(app.repo_root, goal_state)
|
|
49
|
+
milestones_block = build_incremental_milestones_block(goal_state)
|
|
50
|
+
skills_block, skill_names, skill_origins, skill_chars, skill_candidates = (
|
|
51
|
+
build_dynamic_skills_block(app, user_text)
|
|
52
|
+
)
|
|
53
|
+
# Tell the model which local model/quant it's actually running on, so
|
|
54
|
+
# "which model are you using?" gets a correct answer instead of a guess.
|
|
55
|
+
# Derived per-session from the active model (config.runtime.model →
|
|
56
|
+
# catalog friendly name + quant). Folded into the front of the
|
|
57
|
+
# network_status slot so it renders right after the "Working directory"
|
|
58
|
+
# line, with no change to the section registry. Empty when no model is
|
|
59
|
+
# configured (keeps the prompt prefix byte-identical for that case).
|
|
60
|
+
identity_line = ""
|
|
61
|
+
try:
|
|
62
|
+
identity_line = model_identity_line(str(app.config.runtime.model or ""))
|
|
63
|
+
except Exception:
|
|
64
|
+
identity_line = ""
|
|
65
|
+
# Name the detected project stack (one line) so the model writes code
|
|
66
|
+
# in the project's actual language/conventions instead of falling back
|
|
67
|
+
# to Python idioms. Cheap, marker-file based, and best-effort: any
|
|
68
|
+
# failure leaves the slot empty so the cached prefix is unaffected.
|
|
69
|
+
stack_line = ""
|
|
70
|
+
try:
|
|
71
|
+
stack_line = project_stack_line(app.repo_root)
|
|
72
|
+
except Exception:
|
|
73
|
+
stack_line = ""
|
|
74
|
+
network_status_with_identity = identity_line + stack_line + network_status
|
|
75
|
+
|
|
76
|
+
def _render_caller_template(ctx: SectionContext) -> str:
|
|
77
|
+
return base_system_prompt.format(
|
|
78
|
+
cwd=ctx.cwd,
|
|
79
|
+
project_instructions=ctx.project_instructions,
|
|
80
|
+
network_status=ctx.network_status,
|
|
81
|
+
skills_block=ctx.skills_block,
|
|
82
|
+
reasoning_rules=ctx.reasoning_rules,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
section_ctx = SectionContext(
|
|
86
|
+
cwd=app.repo_root,
|
|
87
|
+
project_instructions=project_instructions,
|
|
88
|
+
network_status=network_status_with_identity,
|
|
89
|
+
skills_block=skills_block,
|
|
90
|
+
reasoning_rules=REASONING_RULES if use_thinking else "",
|
|
91
|
+
)
|
|
92
|
+
using_default_prompt = base_system_prompt == SYSTEM_PROMPT
|
|
93
|
+
system = compose_system_prompt(
|
|
94
|
+
section_ctx,
|
|
95
|
+
sections=default_sections()
|
|
96
|
+
if using_default_prompt
|
|
97
|
+
else [Section(id="caller_template", render=_render_caller_template, cacheable=False)],
|
|
98
|
+
emit_cache_marker=using_default_prompt,
|
|
99
|
+
)
|
|
100
|
+
return PromptBuildResult(
|
|
101
|
+
system_prompt=system + task_goal_block + target_grounding_block + milestones_block,
|
|
102
|
+
selected_skills=skill_names,
|
|
103
|
+
selected_skill_origins=skill_origins,
|
|
104
|
+
selected_skill_chars=skill_chars,
|
|
105
|
+
skill_candidates=skill_candidates,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_target_grounding_block(repo_root: Any, goal_state: Any) -> str:
|
|
110
|
+
"""One short block that pins the target directory for build-type turns.
|
|
111
|
+
|
|
112
|
+
Grounds the model so it stops thrashing between the repo root and a subdir.
|
|
113
|
+
Returned only for build_app-shaped goals; empty otherwise so the cached
|
|
114
|
+
prompt prefix is unaffected for ordinary turns.
|
|
115
|
+
"""
|
|
116
|
+
goal_type = str(getattr(goal_state, "goal_type", "") or "")
|
|
117
|
+
if goal_type != "build_app":
|
|
118
|
+
return ""
|
|
119
|
+
try:
|
|
120
|
+
root = str(Path(repo_root).resolve())
|
|
121
|
+
except Exception:
|
|
122
|
+
root = str(repo_root or "")
|
|
123
|
+
if not root:
|
|
124
|
+
return ""
|
|
125
|
+
# Don't anchor to $HOME. When localcode is launched outside any project
|
|
126
|
+
# (no .git/package.json/etc. marker found), repo_root falls back to the
|
|
127
|
+
# user's home dir. Telling the model "the project root is /Users/<name>,
|
|
128
|
+
# create all files under that root" then makes it try to build in $HOME —
|
|
129
|
+
# or conflict with the path the user actually named in the task ("work at
|
|
130
|
+
# root vs. the Anki/... path they gave"). In that unanchored case, emit no
|
|
131
|
+
# directive and let the model follow the location from the user's request.
|
|
132
|
+
try:
|
|
133
|
+
if Path(root) == Path.home():
|
|
134
|
+
return ""
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
return (
|
|
138
|
+
"\n\nTarget location (work here, do not drift):\n"
|
|
139
|
+
f"- The project root is: {root}\n"
|
|
140
|
+
"- Create and edit all files under that root. Do not switch between the "
|
|
141
|
+
"root and an unrelated subdirectory between steps; decide once and stay there.\n"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_incremental_milestones_block(goal_state: Any) -> str:
|
|
146
|
+
"""Lightweight guidance to build a large app in verifiable milestones.
|
|
147
|
+
|
|
148
|
+
For a build_app goal, instruct the model to make incremental, verified
|
|
149
|
+
progress — scaffold first, then one feature at a time, building/running
|
|
150
|
+
between steps — instead of emitting an entire app in a single giant
|
|
151
|
+
write (which is what hits the per-round token wall and lands unverified
|
|
152
|
+
code). This is intentionally a prompt-context guidance block rather than
|
|
153
|
+
a mechanism: cheap, no per-round bookkeeping, and the build-verification
|
|
154
|
+
nudge in the loop already enforces the "build before finishing" half.
|
|
155
|
+
|
|
156
|
+
Empty for non-build_app goals so the cached prompt prefix is unaffected.
|
|
157
|
+
"""
|
|
158
|
+
goal_type = str(getattr(goal_state, "goal_type", "") or "")
|
|
159
|
+
if goal_type != "build_app":
|
|
160
|
+
return ""
|
|
161
|
+
return (
|
|
162
|
+
"\n\nBuild in verifiable milestones (do not emit the whole app at once):\n"
|
|
163
|
+
"1. SCAFFOLD: create the minimal runnable skeleton first — entrypoint, "
|
|
164
|
+
"project/config file, and a placeholder that starts. Run it to confirm "
|
|
165
|
+
"it launches before adding features.\n"
|
|
166
|
+
"2. ONE FEATURE AT A TIME: add a single feature per step, in small files. "
|
|
167
|
+
"After each feature, build/typecheck or run the relevant check and FIX "
|
|
168
|
+
"any error before moving to the next.\n"
|
|
169
|
+
"3. KEEP IT RUNNABLE: never leave the project in a broken state between "
|
|
170
|
+
"steps. If a step is too large for one tool call, split it across calls; "
|
|
171
|
+
"don't drop scope.\n"
|
|
172
|
+
"4. FINISH: only report done after a final build/run passes with no "
|
|
173
|
+
"errors. Don't claim it works without having run it.\n"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_task_goal_block(user_text: str, goal_state: Any, task_state: Any) -> str:
|
|
178
|
+
# The general agentic goal block ("Current goal: X / Continue until
|
|
179
|
+
# complete and verified" plus stage guidance, port hints, build_app
|
|
180
|
+
# phase ladder, user-requested-features checklist) was removed
|
|
181
|
+
# 2026-04-29. None of it measurably prevented the failures it was
|
|
182
|
+
# added to mitigate (loops, over-verification, premature stops on
|
|
183
|
+
# Qwen3.6 IQ2_M); mainstream agentic CLIs don't do it; and the
|
|
184
|
+
# "Continue until complete" framing actively pushed the model into
|
|
185
|
+
# eager exploration on trivial inputs ("hi" → list_files + 3× read
|
|
186
|
+
# README). Only one piece survived: the question-type protective
|
|
187
|
+
# wording, which has a regression test and prevents auto-resume of
|
|
188
|
+
# a prior failed/blocked task on a clarifying question.
|
|
189
|
+
if goal_state.goal_type == "question":
|
|
190
|
+
block = (
|
|
191
|
+
"\n\nCurrent goal:\n"
|
|
192
|
+
f"- Answer this question directly: {user_text.strip()}\n"
|
|
193
|
+
"- This is a question/diagnostic turn, not permission to resume prior coding work.\n"
|
|
194
|
+
"- Do not continue, rebuild, edit, launch, or verify a previous task unless the user explicitly asks you to do that.\n"
|
|
195
|
+
"- If local evidence is needed, inspect only the smallest relevant files/logs, then answer.\n"
|
|
196
|
+
)
|
|
197
|
+
if task_state is not None:
|
|
198
|
+
status = getattr(task_state, "status", "") or ""
|
|
199
|
+
slug = getattr(task_state, "task_slug", "") or ""
|
|
200
|
+
if status or slug:
|
|
201
|
+
block += f"- Previous task context: {slug or 'task'} status={status or 'unknown'}.\n"
|
|
202
|
+
return block
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_dynamic_skills_block(
|
|
207
|
+
app: Any,
|
|
208
|
+
user_text: str,
|
|
209
|
+
) -> tuple[str, list[str], list[str], int, list[dict[str, str]]]:
|
|
210
|
+
try:
|
|
211
|
+
from ..skills import (
|
|
212
|
+
dynamic_skill_block,
|
|
213
|
+
dynamic_skill_candidates,
|
|
214
|
+
load_registry,
|
|
215
|
+
select_dynamic_skills,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
registry = load_registry(app.repo_root)
|
|
219
|
+
catalog = registry.listing()
|
|
220
|
+
catalog_block = (
|
|
221
|
+
"\n\n## Available Skills\n"
|
|
222
|
+
"Load a matching recipe with the skill tool; full bodies are deferred.\n"
|
|
223
|
+
+ catalog
|
|
224
|
+
if catalog else ""
|
|
225
|
+
)
|
|
226
|
+
recent_tools = list(getattr(app, "_recent_tool_names", []) or [])
|
|
227
|
+
last_failed_tool = str(getattr(app, "_last_failed_tool_name", "") or "")
|
|
228
|
+
candidates = dynamic_skill_candidates(
|
|
229
|
+
user_text,
|
|
230
|
+
recent_tools=recent_tools,
|
|
231
|
+
last_failed_tool=last_failed_tool,
|
|
232
|
+
)
|
|
233
|
+
selected = select_dynamic_skills(
|
|
234
|
+
user_text,
|
|
235
|
+
registry,
|
|
236
|
+
recent_tools=recent_tools,
|
|
237
|
+
last_failed_tool=last_failed_tool,
|
|
238
|
+
)
|
|
239
|
+
# Gate on selected count rather than letting dynamic_skill_block
|
|
240
|
+
# return its own placeholder/header for an empty list. With no
|
|
241
|
+
# selected skills (the steady-state today: events show
|
|
242
|
+
# selected_count=0 every turn), the prompt template's
|
|
243
|
+
# {skills_block} slot becomes an empty string — saves a few
|
|
244
|
+
# hundred bytes of header/scaffolding per round and, more
|
|
245
|
+
# importantly, keeps the prompt prefix byte-identical to other
|
|
246
|
+
# turns so llama.cpp's prefix-cache can hit.
|
|
247
|
+
if not selected:
|
|
248
|
+
return (
|
|
249
|
+
catalog_block,
|
|
250
|
+
[],
|
|
251
|
+
[],
|
|
252
|
+
0,
|
|
253
|
+
candidates,
|
|
254
|
+
)
|
|
255
|
+
block = catalog_block + dynamic_skill_block(selected)
|
|
256
|
+
return (
|
|
257
|
+
block,
|
|
258
|
+
[skill.name for skill in selected],
|
|
259
|
+
[skill.origin for skill in selected],
|
|
260
|
+
len(block),
|
|
261
|
+
candidates,
|
|
262
|
+
)
|
|
263
|
+
except Exception:
|
|
264
|
+
return "", [], [], 0, []
|