localcode 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.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 +1 -1
- localcode/agent/context.py +54 -13
- localcode/agent/loop.py +18 -1
- localcode/agent/prompts.py +4 -1
- localcode/agent/recovery.py +7 -5
- localcode/entrypoint.py +61 -5
- localcode/runtime.py +71 -19
- localcode/server_manager.py +12 -15
- localcode/session.py +6 -0
- localcode/tools/__init__.py +7 -0
- localcode/tools/bash.py +52 -0
- localcode/tools/todo_write.py +140 -0
- localcode/tools/write_file.py +21 -0
- localcode/tui/app.py +23 -7
- localcode/tui/screens/chat.py +83 -23
- localcode/tui/screens/setup.py +3 -3
- localcode/tui/styles/app.tcss +4 -2
- localcode/tui/widgets/chat_log.py +23 -1
- localcode/tui/widgets/messages/diff.py +56 -0
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/METADATA +1 -1
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/RECORD +25 -24
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/WHEEL +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.1.dist-info → localcode-0.3.3.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/context.py
CHANGED
|
@@ -45,6 +45,37 @@ if TYPE_CHECKING:
|
|
|
45
45
|
__all__: list[str] = []
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _spill_tool_output(result: str, tool_name: str) -> str | None:
|
|
49
|
+
"""Write an oversized tool result to a file and return its path.
|
|
50
|
+
|
|
51
|
+
Truncation alone makes a small model re-run the command "to see the rest".
|
|
52
|
+
Instead we keep the full output on disk and point the model at it (grep/
|
|
53
|
+
read the exact part). Best-effort: None on error → caller falls back to
|
|
54
|
+
plain truncation. Content-hashed filenames; old spills reaped after 7 days.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import hashlib
|
|
58
|
+
import time as _time
|
|
59
|
+
from ..paths import global_state_dir
|
|
60
|
+
spill_dir = global_state_dir() / "tool_output"
|
|
61
|
+
spill_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
# Reap spills older than 7 days so this never grows unbounded.
|
|
63
|
+
try:
|
|
64
|
+
cutoff = _time.time() - 7 * 86400
|
|
65
|
+
for old in spill_dir.glob("*.txt"):
|
|
66
|
+
if old.stat().st_mtime < cutoff:
|
|
67
|
+
old.unlink(missing_ok=True)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
digest = hashlib.sha1(result.encode("utf-8", "replace")).hexdigest()[:12]
|
|
71
|
+
path = spill_dir / f"{tool_name}_{digest}.txt"
|
|
72
|
+
if not path.exists():
|
|
73
|
+
path.write_text(result, encoding="utf-8", errors="replace")
|
|
74
|
+
return str(path)
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
48
79
|
def _truncate_result(result: str, tool_name: str) -> str:
|
|
49
80
|
"""Truncate a tool result to its per-tool size limit with a
|
|
50
81
|
strategy tuned to what each tool's output actually looks like.
|
|
@@ -70,6 +101,15 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
70
101
|
return result
|
|
71
102
|
|
|
72
103
|
dropped = len(result) - limit
|
|
104
|
+
# Spill the FULL output to disk and point the model at it, so it fetches
|
|
105
|
+
# the exact part it needs instead of re-running the command to "see the
|
|
106
|
+
# rest". Appended to every strategy's hint below.
|
|
107
|
+
_spill = _spill_tool_output(result, tool_name)
|
|
108
|
+
spill_hint = (
|
|
109
|
+
f" Full output saved to {_spill} — read_file (with offset) or grep it "
|
|
110
|
+
f"to see the rest; do NOT re-run the command."
|
|
111
|
+
if _spill else ""
|
|
112
|
+
)
|
|
73
113
|
|
|
74
114
|
if tool_name == "read_file":
|
|
75
115
|
# Keep the HEAD; if we can find line numbers in the content
|
|
@@ -85,7 +125,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
85
125
|
last_numbered = int(line.split("\t", 1)[0])
|
|
86
126
|
hint = (
|
|
87
127
|
f"\n\n[truncated — {dropped} more chars not shown. "
|
|
88
|
-
f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}]"
|
|
128
|
+
f"{'Call read_file with offset=' + str(last_numbered) + ' to continue reading from there.' if last_numbered else 'Call read_file with a larger offset to continue.'}{spill_hint}]"
|
|
89
129
|
)
|
|
90
130
|
return head + hint
|
|
91
131
|
|
|
@@ -99,7 +139,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
99
139
|
dropped_lines = result[limit:].count("\n") + 1
|
|
100
140
|
hint = (
|
|
101
141
|
f"\n\n[truncated — {dropped_lines} more match-lines not shown. "
|
|
102
|
-
"Narrow with a more specific pattern or `include=*.py` to see them.]"
|
|
142
|
+
f"Narrow with a more specific pattern or `include=*.py` to see them.{spill_hint}]"
|
|
103
143
|
)
|
|
104
144
|
return head + hint
|
|
105
145
|
|
|
@@ -114,7 +154,7 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
114
154
|
first_sig = "\n".join(non_empty[:8])
|
|
115
155
|
last_sig = "\n".join(non_empty[-12:]) if len(non_empty) > 12 else ""
|
|
116
156
|
marker = (
|
|
117
|
-
f"\n\n[... {dropped} chars of bash output compressed ...]\n\n"
|
|
157
|
+
f"\n\n[... {dropped} chars of bash output compressed.{spill_hint} ...]\n\n"
|
|
118
158
|
)
|
|
119
159
|
if any(token in result for token in ("backend", "frontend", "src/", "node_modules", "package.json", "pyproject.toml")):
|
|
120
160
|
tree_hint = (
|
|
@@ -133,14 +173,14 @@ def _truncate_result(result: str, tool_name: str) -> str:
|
|
|
133
173
|
tail_size = limit - head_size - 64 # 64 chars for the marker line
|
|
134
174
|
head = result[:head_size]
|
|
135
175
|
tail = result[-tail_size:]
|
|
136
|
-
marker = f"\n\n[... {dropped} chars of middle output dropped ...]\n\n"
|
|
176
|
+
marker = f"\n\n[... {dropped} chars of middle output dropped.{spill_hint} ...]\n\n"
|
|
137
177
|
return head + marker + tail
|
|
138
178
|
|
|
139
179
|
# Default — middle-drop.
|
|
140
180
|
half = limit // 2
|
|
141
181
|
return (
|
|
142
182
|
result[:half]
|
|
143
|
-
+ f"\n\n[...{dropped} chars truncated...]\n\n"
|
|
183
|
+
+ f"\n\n[...{dropped} chars truncated.{spill_hint} ...]\n\n"
|
|
144
184
|
+ result[-half:]
|
|
145
185
|
)
|
|
146
186
|
|
|
@@ -205,11 +245,11 @@ def _semantic_tool_summary(content: str) -> str:
|
|
|
205
245
|
or "Traceback " in content
|
|
206
246
|
)
|
|
207
247
|
if is_error:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
# Self-conditioning (arXiv:2509.09677): echoing an OLD error's text
|
|
249
|
+
# pushes the model to repeat the mistake. Recent errors stay intact
|
|
250
|
+
# (kept above by keep_recent) so it can fix the immediate problem;
|
|
251
|
+
# older ones drop to a neutral note (the ledger still records the fact).
|
|
252
|
+
return "[an earlier tool call errored and was handled — details dropped]"
|
|
213
253
|
if facts:
|
|
214
254
|
return (
|
|
215
255
|
f"[older successful tool result summarized: {len(content)} chars, "
|
|
@@ -684,9 +724,10 @@ def build_progress_ledger(
|
|
|
684
724
|
return list(dict.fromkeys(s for s in seq if s))
|
|
685
725
|
|
|
686
726
|
lines = [
|
|
687
|
-
"##
|
|
688
|
-
"
|
|
689
|
-
"
|
|
727
|
+
"## A log of YOUR OWN tool calls so far this turn — NOT the user's work "
|
|
728
|
+
"and NOT pre-existing files on disk (leftover files you did not create are "
|
|
729
|
+
"not your progress or your task). Don't re-read/re-run anything below "
|
|
730
|
+
"unless you changed it; keep making NEW progress toward the user's goal.",
|
|
690
731
|
]
|
|
691
732
|
rd = _uniq(files_read)
|
|
692
733
|
if rd:
|
localcode/agent/loop.py
CHANGED
|
@@ -721,6 +721,19 @@ def run_agent_loop(
|
|
|
721
721
|
]
|
|
722
722
|
except Exception:
|
|
723
723
|
pass
|
|
724
|
+
# Inject the working-memory checklist (todo_write tool): the model
|
|
725
|
+
# always sees its own plan (done / in-progress / left) so it
|
|
726
|
+
# advances instead of repeating. Appended LAST, ephemeral, empty-safe.
|
|
727
|
+
try:
|
|
728
|
+
from ..tools.todo_write import render_todo_reminder
|
|
729
|
+
_todos = list(getattr(app.session, "todos", []) or [])
|
|
730
|
+
_todo_note = render_todo_reminder(_todos)
|
|
731
|
+
if _todo_note:
|
|
732
|
+
model_messages = list(model_messages) + [
|
|
733
|
+
{"role": "user", "content": _todo_note}
|
|
734
|
+
]
|
|
735
|
+
except Exception:
|
|
736
|
+
pass
|
|
724
737
|
round_tool_schemas = schemas_for_goal(
|
|
725
738
|
_goal_state.goal_type,
|
|
726
739
|
user_text,
|
|
@@ -1206,7 +1219,11 @@ def run_agent_loop(
|
|
|
1206
1219
|
break
|
|
1207
1220
|
|
|
1208
1221
|
# ── No tool calls = model is done ──
|
|
1209
|
-
|
|
1222
|
+
# Also require no pending tool call: the runtime promotes pending
|
|
1223
|
+
# tools into `tool_calls`, but if a future path doesn't, we keep
|
|
1224
|
+
# looping instead of ending early (small models often emit a stop
|
|
1225
|
+
# token with a call still pending).
|
|
1226
|
+
if not tool_calls and not _round_pending_tool_count:
|
|
1210
1227
|
# Match v0.2.12's no-tool-calls path exactly: render content,
|
|
1211
1228
|
# render any grounded file summary, break. Nothing else.
|
|
1212
1229
|
#
|
localcode/agent/prompts.py
CHANGED
|
@@ -42,11 +42,12 @@ _STACK_MARKERS: tuple[tuple[str, str], ...] = (
|
|
|
42
42
|
SYSTEM_PROMPT = """\
|
|
43
43
|
You are LocalCode, a coding agent on the user's machine with full filesystem access.
|
|
44
44
|
|
|
45
|
-
Available tools: read_file, write_file, append_file, edit_file, bash, list_files.
|
|
45
|
+
Available tools: read_file, write_file, append_file, edit_file, bash, list_files, todo_write.
|
|
46
46
|
|
|
47
47
|
How to work:
|
|
48
48
|
- Match scope to the request. Answer plain questions plainly. Build what was asked when asked. Don't write a script when a one-line bash command will do.
|
|
49
49
|
- Cover every requirement the user named. If your chosen approach can't deliver one, change the approach — don't drop the requirement to fit the approach.
|
|
50
|
+
- PLAN MULTI-STEP WORK. For any task with 3+ steps, call `todo_write` FIRST to lay out the steps, then keep it current: mark exactly ONE item in_progress before you start it, and mark it completed the instant it's done. Your task list is shown back to you every round — read it to see what's already finished and what's left, so you advance to the next step instead of redoing one you've already done. If you catch yourself about to re-read a file or re-run a command you ran before, check the list: you've likely already done it.
|
|
50
51
|
- If the request is ambiguous in a way that affects your approach (stack, interface, scope), ask one short question before building, not after.
|
|
51
52
|
- ACT, DON'T NARRATE. Every "let me read", "let me fix", "now I'll write" MUST be followed by the actual tool call IN THE SAME TURN. If you say "let me write the fix" and then end your turn without calling edit_file or write_file, you have failed the user. The user has explicitly complained about this. Never describe a fix without immediately performing it.
|
|
52
53
|
- Forbidden patterns: "Let me read the file:" + end of turn. "I'll rewrite this:" + end of turn. "Now I'll apply the fix:" + end of turn. If the next thing out of your mouth is a verb of intent, the very next action MUST be the tool call that delivers on that intent.
|
|
@@ -58,6 +59,8 @@ How to work:
|
|
|
58
59
|
- For new projects, create a small multi-file structure by default. Keep entrypoints thin; move reusable logic, styles, data/config, templates, and assets into focused files. Use one large file only if requested.
|
|
59
60
|
- Prefer edit_file for existing files. Use write_file when creating a new file or doing a deliberate full rewrite.
|
|
60
61
|
- When a tool returns an error, read it, fix the specific problem, and retry with DIFFERENT input. Never repeat an identical failing call: if the same call fails or returns the same result twice, change the arguments or the approach, or move on — do not loop. Don't give up after one failed call, but don't spin on it either.
|
|
62
|
+
- DON'T RE-READ OR RE-SEARCH what you already have. If you already read a file this turn and it hasn't changed, the content is still above — use it, don't read it again. If a search already gave you a useful result, act on it (open the match, make the edit); don't run the same search again. Repeating a read/search you've already done is the #1 way turns stall.
|
|
63
|
+
- After a tool result, CONTINUE from where you left off — don't restate what you just did or re-summarize the plan. Take the next concrete action.
|
|
61
64
|
- DON'T CONFABULATE. If the user names a person, song, place, term, library, command, or concept you do not recognize, your FIRST move is to say "I don't recognize that" — NOT to invent a plausible-sounding meaning by phonetic association. Phonetic fits ("Alombasi sounds Bantu so it must be a Zambian chant", "Pyfoo sounds like a Python library so it must do X") are exactly the failure mode. If a web search would help, run it BEFORE asserting facts; if results are empty, say so plainly. Never write paragraph-length cultural/etymological/technical descriptions of something you can't actually source — "I'd love to know more, what's it from?" is the correct answer. Doubling down when the user repeats the unknown term is also forbidden; repetition is not evidence.
|
|
62
65
|
|
|
63
66
|
Runtime facts (true today; rely on these instead of guessing):
|
localcode/agent/recovery.py
CHANGED
|
@@ -390,9 +390,11 @@ def churn_nudge_for(signal: ChurnSignal) -> str:
|
|
|
390
390
|
)
|
|
391
391
|
# INVESTIGATION_SPIN
|
|
392
392
|
return (
|
|
393
|
-
"SYSTEM:
|
|
394
|
-
"
|
|
395
|
-
"
|
|
396
|
-
"
|
|
397
|
-
"
|
|
393
|
+
"SYSTEM: Several rounds of reading/listing/searching without WRITING "
|
|
394
|
+
"anything — you're going in circles. STOP exploring. If the task is to "
|
|
395
|
+
"build something, create the target file and write real code THIS round "
|
|
396
|
+
"with write_file/edit_file; do NOT read, ls, find, cat, or grep again. "
|
|
397
|
+
"Leftover files on disk are NOT your progress (may be an unrelated run) — "
|
|
398
|
+
"only files YOU write count. If you truly lack info only the user has, "
|
|
399
|
+
"ask ONE focused question; otherwise start writing now."
|
|
398
400
|
)
|
localcode/entrypoint.py
CHANGED
|
@@ -22,21 +22,74 @@ _TERMINAL_RESTORE = (
|
|
|
22
22
|
"\x1b[?25h\x1b[?1049l\x1b[0m"
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
+
# Snapshot of the controlling tty's termios settings, captured BEFORE the
|
|
26
|
+
# TUI puts the terminal into raw mode. The escape sequence above only undoes
|
|
27
|
+
# escape-driven state (alt-screen, mouse tracking, hidden cursor); it does
|
|
28
|
+
# NOT undo the kernel-level raw mode (ECHO/ICANON/ISIG off). If the TUI
|
|
29
|
+
# crashes or hangs and its own teardown is skipped, the terminal is left in
|
|
30
|
+
# raw mode — no echo, and Ctrl+C generates no SIGINT — which looks like a
|
|
31
|
+
# dead terminal. Restoring this snapshot (or `stty sane`) brings it back.
|
|
32
|
+
_ORIG_TERMIOS = None # tuple[int fd, list attrs] | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _snapshot_terminal_state() -> None:
|
|
36
|
+
"""Capture the tty's termios attrs before the TUI mutates them."""
|
|
37
|
+
global _ORIG_TERMIOS
|
|
38
|
+
if _ORIG_TERMIOS is not None:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
import termios
|
|
42
|
+
for stream in (sys.__stdin__, sys.__stdout__):
|
|
43
|
+
try:
|
|
44
|
+
if stream is not None and stream.isatty():
|
|
45
|
+
fd = stream.fileno()
|
|
46
|
+
_ORIG_TERMIOS = (fd, termios.tcgetattr(fd))
|
|
47
|
+
return
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
25
53
|
|
|
26
54
|
def _reset_terminal_state() -> None:
|
|
27
55
|
payload = _TERMINAL_RESTORE.encode()
|
|
56
|
+
wrote = False
|
|
28
57
|
try:
|
|
29
58
|
with open("/dev/tty", "wb", buffering=0) as tty:
|
|
30
59
|
tty.write(payload)
|
|
31
|
-
|
|
60
|
+
wrote = True
|
|
32
61
|
except Exception:
|
|
33
62
|
pass
|
|
63
|
+
if not wrote:
|
|
64
|
+
try:
|
|
65
|
+
if sys.__stdout__.isatty():
|
|
66
|
+
sys.__stdout__.write(_TERMINAL_RESTORE)
|
|
67
|
+
sys.__stdout__.flush()
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
# Undo raw mode at the kernel level — escape codes can't do this. Restore
|
|
71
|
+
# the snapshot if we have one; otherwise fall back to `stty sane`. Without
|
|
72
|
+
# this, a crashed/hung TUI leaves the shell with no echo and a dead
|
|
73
|
+
# Ctrl+C until the user blindly types `reset`.
|
|
34
74
|
try:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
75
|
+
import termios
|
|
76
|
+
if _ORIG_TERMIOS is not None:
|
|
77
|
+
fd, attrs = _ORIG_TERMIOS
|
|
78
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, attrs)
|
|
79
|
+
else:
|
|
80
|
+
raise RuntimeError("no snapshot")
|
|
38
81
|
except Exception:
|
|
39
|
-
|
|
82
|
+
try:
|
|
83
|
+
import subprocess
|
|
84
|
+
subprocess.run(
|
|
85
|
+
["stty", "sane"],
|
|
86
|
+
stdin=open("/dev/tty", "rb"),
|
|
87
|
+
stdout=subprocess.DEVNULL,
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
timeout=2,
|
|
90
|
+
)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
40
93
|
|
|
41
94
|
|
|
42
95
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -248,6 +301,9 @@ def _run_headless(config, args, console) -> int:
|
|
|
248
301
|
|
|
249
302
|
def main(argv: list[str] | None = None) -> None:
|
|
250
303
|
_harden_against_debugger_attach()
|
|
304
|
+
# Snapshot the terminal while it's still sane — before the TUI enters
|
|
305
|
+
# raw mode — so any exit path (crash, hang, kill) can restore it.
|
|
306
|
+
_snapshot_terminal_state()
|
|
251
307
|
import os
|
|
252
308
|
import signal
|
|
253
309
|
import warnings
|
localcode/runtime.py
CHANGED
|
@@ -242,6 +242,11 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
|
|
|
242
242
|
self.config = config
|
|
243
243
|
self._client: httpx.Client | None = None
|
|
244
244
|
self.last_response_meta: dict[str, Any] = {} # for token tracking
|
|
245
|
+
# Circuit-breaker state for the stream-recovery loop: consecutive
|
|
246
|
+
# server deaths since the last successful stream. Persists across
|
|
247
|
+
# calls/turns (this gateway is one per session) so a deterministic
|
|
248
|
+
# crash can't restart-loop forever. Reset to 0 on any success.
|
|
249
|
+
self._consecutive_stream_deaths = 0
|
|
245
250
|
# llama_cpp is the only HTTP runtime (ollama/mlx/hf removed); diffusion
|
|
246
251
|
# models are architecture-routed to the one-shot CLI, not this endpoint.
|
|
247
252
|
base = self.config.base_url.rstrip("/")
|
|
@@ -1324,6 +1329,10 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
|
|
|
1324
1329
|
# error instead of thrashing.
|
|
1325
1330
|
_MAX_STREAM_RESTARTS = 1
|
|
1326
1331
|
_restarts_done = 0
|
|
1332
|
+
# Circuit-breaker ceiling: how many consecutive server deaths (across
|
|
1333
|
+
# calls/turns, reset on any successful stream) we'll keep restarting
|
|
1334
|
+
# through before declaring the server unstable and stopping the loop.
|
|
1335
|
+
_MAX_CONSECUTIVE_STREAM_DEATHS = 3
|
|
1327
1336
|
# STREAM-LEVEL "have we handed real content to the consumer yet?"
|
|
1328
1337
|
# flag. The per-attempt `_emitted_real` is reset each try; this one
|
|
1329
1338
|
# survives across attempts and is the streaming-safety gate for
|
|
@@ -1814,6 +1823,10 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
|
|
|
1814
1823
|
"type": "content",
|
|
1815
1824
|
"content": _fmt_err(_LCE(_by_code("E3108"))),
|
|
1816
1825
|
}
|
|
1826
|
+
# Reached the end of a stream without an exception — the server
|
|
1827
|
+
# answered. Clear the circuit breaker so a later transient death
|
|
1828
|
+
# gets a fresh recovery budget (only CONSECUTIVE deaths count).
|
|
1829
|
+
self._consecutive_stream_deaths = 0
|
|
1817
1830
|
yield {
|
|
1818
1831
|
"type": "stream_done",
|
|
1819
1832
|
"finish_reason": last_finish_reason,
|
|
@@ -1858,7 +1871,29 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
|
|
|
1858
1871
|
# With recovery: detect the pattern, wait for memory to
|
|
1859
1872
|
# settle, restart the server, retry the stream once.
|
|
1860
1873
|
err_str = _error_message(exc).lower()
|
|
1861
|
-
|
|
1874
|
+
# Classify by EXCEPTION TYPE (with a text fallback), not a bare
|
|
1875
|
+
# `"connect" in err_str` — that substring also matched httpx's
|
|
1876
|
+
# RemoteProtocolError ("peer closed CONNECTION … incomplete
|
|
1877
|
+
# chunked read"), but that's fine on its own; both a dead socket
|
|
1878
|
+
# AND a mid-body drop are recoverable by restart. The real
|
|
1879
|
+
# defect was the ABSENCE of a stop condition: a deterministic
|
|
1880
|
+
# failure (an oversized request that kills the server every
|
|
1881
|
+
# time) restarted + re-POSTed forever. The circuit breaker
|
|
1882
|
+
# below (_consecutive_stream_deaths) bounds that.
|
|
1883
|
+
try:
|
|
1884
|
+
import httpx as _httpx
|
|
1885
|
+
_is_protocol_drop = isinstance(exc, _httpx.RemoteProtocolError)
|
|
1886
|
+
_is_connect_err = isinstance(
|
|
1887
|
+
exc, (_httpx.ConnectError, _httpx.ConnectTimeout)
|
|
1888
|
+
)
|
|
1889
|
+
except Exception:
|
|
1890
|
+
_is_protocol_drop = "incomplete chunked read" in err_str or "peer closed" in err_str
|
|
1891
|
+
_is_connect_err = False
|
|
1892
|
+
if not _is_connect_err and not _is_protocol_drop:
|
|
1893
|
+
_is_connect_err = "connect" in err_str or "refused" in err_str
|
|
1894
|
+
# Both a connect failure and a mid-stream protocol drop are
|
|
1895
|
+
# "server went away" — recoverable by restart + retry.
|
|
1896
|
+
is_conn_err = _is_connect_err or _is_protocol_drop
|
|
1862
1897
|
# Auto-recovery on connection-refused: try ONE server
|
|
1863
1898
|
# restart before surfacing the error to the user. Any
|
|
1864
1899
|
# time the server is dead (pressure-kill, OOM, crash,
|
|
@@ -1876,27 +1911,44 @@ class LocalCodeRuntimeGateway(_DiffusionMixin):
|
|
|
1876
1911
|
# e2e run 2026-04-23T23:54, long_coding_session failed
|
|
1877
1912
|
# 25/25 turns at 0.5 s each on a dead server).
|
|
1878
1913
|
_pressure_related = _pressure_kill_recent()
|
|
1879
|
-
# STREAMING-SAFETY
|
|
1880
|
-
#
|
|
1881
|
-
#
|
|
1882
|
-
#
|
|
1883
|
-
#
|
|
1884
|
-
# (drop before first token). Mid-stream → surface E3102 with
|
|
1885
|
-
# diagnostic context instead of silently replaying.
|
|
1914
|
+
# STREAMING-SAFETY: a mid-stream drop that already yielded
|
|
1915
|
+
# content may replay a partial duplicate on retry — acceptable
|
|
1916
|
+
# (the recovery stage explains it) and far better than killing a
|
|
1917
|
+
# turn that would otherwise self-heal (a hard E3102 here used to
|
|
1918
|
+
# kill builds on a transient memory-pressure pause).
|
|
1886
1919
|
if is_conn_err:
|
|
1887
|
-
# Capture the disconnect class for diagnostics
|
|
1920
|
+
# Capture the disconnect class for diagnostics (both a dead
|
|
1921
|
+
# socket and a mid-body protocol drop land here).
|
|
1888
1922
|
_log_disconnect_context(
|
|
1889
|
-
"stream_chat_events", exc,
|
|
1923
|
+
"stream_chat_events", exc,
|
|
1924
|
+
mid_stream=_emitted_real_stream or _is_protocol_drop,
|
|
1890
1925
|
)
|
|
1891
|
-
#
|
|
1892
|
-
#
|
|
1893
|
-
#
|
|
1894
|
-
#
|
|
1895
|
-
#
|
|
1896
|
-
#
|
|
1897
|
-
#
|
|
1898
|
-
|
|
1899
|
-
|
|
1926
|
+
# CIRCUIT BREAKER — the fix for the restart loop. Count
|
|
1927
|
+
# consecutive server deaths across calls (reset on any
|
|
1928
|
+
# successful stream, see below). A transient death recovers
|
|
1929
|
+
# on the first restart; a DETERMINISTIC one — a request too
|
|
1930
|
+
# large for the server, killing it every time — would
|
|
1931
|
+
# otherwise restart + re-POST forever. Once the deaths pile
|
|
1932
|
+
# up we stop restarting and surface actionable guidance.
|
|
1933
|
+
self._consecutive_stream_deaths = (
|
|
1934
|
+
getattr(self, "_consecutive_stream_deaths", 0) + 1
|
|
1935
|
+
)
|
|
1936
|
+
if self._consecutive_stream_deaths > _MAX_CONSECUTIVE_STREAM_DEATHS:
|
|
1937
|
+
yield {
|
|
1938
|
+
"type": "stage", "name": "server_unstable",
|
|
1939
|
+
"message": (
|
|
1940
|
+
"The model server keeps crashing on this request — "
|
|
1941
|
+
"it's likely too large for the current settings. "
|
|
1942
|
+
"Try /clear to shrink the context, or /model to "
|
|
1943
|
+
"switch to a lighter model."
|
|
1944
|
+
),
|
|
1945
|
+
}
|
|
1946
|
+
break # stop the restart loop; fall through to E3102
|
|
1947
|
+
if (
|
|
1948
|
+
is_conn_err
|
|
1949
|
+
and attempt < self.config.max_retries
|
|
1950
|
+
and _restarts_done < _MAX_STREAM_RESTARTS
|
|
1951
|
+
):
|
|
1900
1952
|
_restarts_done += 1
|
|
1901
1953
|
recovery_label = (
|
|
1902
1954
|
"memory_pressure_recovery"
|
localcode/server_manager.py
CHANGED
|
@@ -398,23 +398,20 @@ class ServerManager:
|
|
|
398
398
|
"""
|
|
399
399
|
with self._lock:
|
|
400
400
|
had_prior = self._process is not None
|
|
401
|
+
_prev_model = self._model_path # capture before shutdown clears it
|
|
401
402
|
self._shutdown_locked()
|
|
402
|
-
# Wait for the OLD server's memory to actually release
|
|
403
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
# behind the user's "two servers running" hypothesis).
|
|
409
|
-
#
|
|
410
|
-
# Target: enough free memory to fit a typical model + 1 GB
|
|
411
|
-
# margin (~11 GB for our standard quant). Best-effort —
|
|
412
|
-
# times out at 8 s if the target isn't met, logs the
|
|
413
|
-
# outcome, and proceeds anyway (the user wanted to switch
|
|
414
|
-
# models; refusing entirely would be worse than retry).
|
|
403
|
+
# Wait for the OLD server's memory to actually release before
|
|
404
|
+
# spawning the new one: the kernel may take 1-3 s to free wired
|
|
405
|
+
# Metal memory after the child reaps, and spawning the new ~10 GB
|
|
406
|
+
# server in that window double-commits and trips the pressure
|
|
407
|
+
# monitor on a 16 GB Mac. Best-effort — targets ~11 GB free, times
|
|
408
|
+
# out at 8 s, and proceeds anyway (refusing would be worse).
|
|
415
409
|
if had_prior:
|
|
416
|
-
|
|
417
|
-
|
|
410
|
+
# Only a genuine model change is "model_switch"; a same-model
|
|
411
|
+
# relaunch (crash/disconnect recovery) is "server_restart".
|
|
412
|
+
_reason = ("model_switch" if (model_path and model_path != _prev_model)
|
|
413
|
+
else "server_restart")
|
|
414
|
+
_lifecycle_log("memory_wait_start", reason=_reason, target_mb=11000)
|
|
418
415
|
_wait_for_memory_release(min_free_mb=11000, timeout_s=8.0)
|
|
419
416
|
|
|
420
417
|
# Find a port we can actually bind to. If the default is
|
localcode/session.py
CHANGED
|
@@ -49,6 +49,10 @@ class SessionState:
|
|
|
49
49
|
events: list[dict[str, str]] = field(default_factory=list)
|
|
50
50
|
current_task: TaskState | None = None
|
|
51
51
|
recent_tasks: list[TaskState] = field(default_factory=list)
|
|
52
|
+
# Working-memory checklist written by the todo_write tool: a list of
|
|
53
|
+
# {content, status, activeForm} dicts. Persisted so a resumed session
|
|
54
|
+
# keeps its plan; injected into context each round by the agent loop.
|
|
55
|
+
todos: list[dict] = field(default_factory=list)
|
|
52
56
|
|
|
53
57
|
|
|
54
58
|
class SessionStore:
|
|
@@ -170,6 +174,7 @@ class SessionStore:
|
|
|
170
174
|
events=list(data.get("events", [])),
|
|
171
175
|
current_task=_load_task(current_task),
|
|
172
176
|
recent_tasks=[task for task in (_load_task(t) for t in recent_tasks) if task is not None],
|
|
177
|
+
todos=list(data.get("todos", [])),
|
|
173
178
|
)
|
|
174
179
|
|
|
175
180
|
def save(self, session: SessionState) -> Path:
|
|
@@ -184,6 +189,7 @@ class SessionStore:
|
|
|
184
189
|
"pinned_files": session.pinned_files,
|
|
185
190
|
"last_assistant_text": session.last_assistant_text,
|
|
186
191
|
"events": session.events,
|
|
192
|
+
"todos": session.todos,
|
|
187
193
|
"current_task": None if session.current_task is None else {
|
|
188
194
|
"task_id": session.current_task.task_id,
|
|
189
195
|
"user_request": session.current_task.user_request,
|
localcode/tools/__init__.py
CHANGED
|
@@ -36,6 +36,7 @@ from . import (
|
|
|
36
36
|
plan_mode,
|
|
37
37
|
read_file,
|
|
38
38
|
skill_tool,
|
|
39
|
+
todo_write,
|
|
39
40
|
web_fetch,
|
|
40
41
|
web_search,
|
|
41
42
|
write_file,
|
|
@@ -60,6 +61,7 @@ _TOOLS: dict[str, tuple[dict, Callable[[ToolContext, dict], str]]] = {
|
|
|
60
61
|
"web_search": (web_search.SCHEMA, web_search.execute),
|
|
61
62
|
"web_fetch": (web_fetch.SCHEMA, web_fetch.execute),
|
|
62
63
|
"skill": (skill_tool.SCHEMA, skill_tool.execute),
|
|
64
|
+
"todo_write": (todo_write.SCHEMA, todo_write.execute),
|
|
63
65
|
"agent": (agent.SCHEMA, agent.execute),
|
|
64
66
|
"enter_plan_mode": (plan_mode.ENTER_SCHEMA, plan_mode.execute_enter),
|
|
65
67
|
"exit_plan_mode": (plan_mode.EXIT_SCHEMA, plan_mode.execute_exit),
|
|
@@ -80,6 +82,7 @@ _PUBLIC_TOOL_NAMES = [
|
|
|
80
82
|
"web_search",
|
|
81
83
|
"web_fetch",
|
|
82
84
|
"skill",
|
|
85
|
+
"todo_write",
|
|
83
86
|
"agent",
|
|
84
87
|
]
|
|
85
88
|
|
|
@@ -126,6 +129,9 @@ def schemas_for_goal(
|
|
|
126
129
|
# Sub-agent — lets the model spawn focused sub-tasks (explore/plan/verify
|
|
127
130
|
# /general-purpose) so it doesn't burn its own context on long searches.
|
|
128
131
|
"agent",
|
|
132
|
+
# Working-memory checklist — lets the model record a plan and track
|
|
133
|
+
# done/in-progress/remaining across rounds so it stops repeating work.
|
|
134
|
+
"todo_write",
|
|
129
135
|
}
|
|
130
136
|
schemas = schemas_for_names(selected)
|
|
131
137
|
# MCP tools — any tools exposed by user-configured MCP servers in
|
|
@@ -154,6 +160,7 @@ _MODULES = {
|
|
|
154
160
|
"web_search": web_search,
|
|
155
161
|
"web_fetch": web_fetch,
|
|
156
162
|
"skill": skill_tool,
|
|
163
|
+
"todo_write": todo_write,
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
|
localcode/tools/bash.py
CHANGED
|
@@ -711,6 +711,50 @@ def _normalize_repo_root_variants(cmd: str, repo: str) -> str:
|
|
|
711
711
|
return pattern.sub(repo, cmd)
|
|
712
712
|
|
|
713
713
|
|
|
714
|
+
def _quoting_error_hint(output: str) -> str:
|
|
715
|
+
"""Turn a raw shell quoting failure into actionable guidance.
|
|
716
|
+
|
|
717
|
+
A path with spaces or parentheses (e.g. `Qwen 3.6 35B-A3B (Q8)`) that
|
|
718
|
+
isn't quoted breaks bash with `syntax error near unexpected token` — an
|
|
719
|
+
error a model can't act on, so it retries the same broken shape in a loop
|
|
720
|
+
(observed exactly this). Tell it precisely how to fix it.
|
|
721
|
+
"""
|
|
722
|
+
low = output.lower()
|
|
723
|
+
if "syntax error near unexpected token" not in low and "unexpected eof" not in low:
|
|
724
|
+
return ""
|
|
725
|
+
return (
|
|
726
|
+
"HINT: a path or argument with spaces or parentheses wasn't quoted, so "
|
|
727
|
+
"the shell mis-parsed it. Wrap paths in SINGLE quotes — e.g. "
|
|
728
|
+
"ls -la '/Users/you/My Dir (v2)'. Simpler and safer: use list_files or "
|
|
729
|
+
"read_file with the path as an argument — they take the raw path and "
|
|
730
|
+
"never need shell escaping."
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _redirect_shell_dir_listing(cmd: str) -> str:
|
|
735
|
+
"""Route `ls <path>` to list_files, which can't break on spaces/parens.
|
|
736
|
+
|
|
737
|
+
Mirrors the `cat → read_file` guard. Only fires for a plain `ls` with a
|
|
738
|
+
PATH argument (no pipes/redirs/chains) — bare `ls`/`ls -la` in the cwd
|
|
739
|
+
can't hit the quoting failure, so it's left to run.
|
|
740
|
+
"""
|
|
741
|
+
_cd, body = _extract_leading_cd(cmd)
|
|
742
|
+
b = body.strip()
|
|
743
|
+
if any(sep in b for sep in ("|", ">", "<", "&&", "||", ";", "$(", "`")):
|
|
744
|
+
return ""
|
|
745
|
+
# Path arg must NOT start with '-' (else `ls -la` reads its own flags as a path).
|
|
746
|
+
m = re.match(r"^ls((?:\s+-[a-zA-Z]+)*)\s+([^-\s].*)$", b)
|
|
747
|
+
if m is None:
|
|
748
|
+
return "" # bare `ls`/`ls -la` (no path) — harmless, let it run
|
|
749
|
+
path = m.group(2).strip().strip('"').strip("'")
|
|
750
|
+
return (
|
|
751
|
+
f"REJECTED: use list_files(path='{path}') to inspect a directory, not "
|
|
752
|
+
"bash `ls <path>`. list_files takes the path as an argument, so it never "
|
|
753
|
+
"breaks on spaces or parentheses in a folder name — which is what fails "
|
|
754
|
+
"with bash here."
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
714
758
|
def execute(ctx: ToolContext, args: dict) -> str:
|
|
715
759
|
cmd = _normalize_repo_root_variants(args["command"], str(ctx.repo))
|
|
716
760
|
# Block ANY use of process-attaching debuggers — lldb / dtrace /
|
|
@@ -776,6 +820,9 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
776
820
|
file_read_redirect = _redirect_shell_file_read(cmd, str(repo))
|
|
777
821
|
if file_read_redirect:
|
|
778
822
|
return file_read_redirect
|
|
823
|
+
dir_listing_redirect = _redirect_shell_dir_listing(cmd)
|
|
824
|
+
if dir_listing_redirect:
|
|
825
|
+
return dir_listing_redirect
|
|
779
826
|
file_write_redirect = _redirect_shell_file_write(cmd, str(repo))
|
|
780
827
|
if file_write_redirect:
|
|
781
828
|
return file_write_redirect
|
|
@@ -969,6 +1016,11 @@ def execute(ctx: ToolContext, args: dict) -> str:
|
|
|
969
1016
|
pass
|
|
970
1017
|
if r.returncode != 0:
|
|
971
1018
|
output = f"[exit code {r.returncode}]\n{output}"
|
|
1019
|
+
# Shell quoting failure (unquoted spaces/parens in a path) → give
|
|
1020
|
+
# the model an actionable fix instead of a raw syntax error it loops on.
|
|
1021
|
+
_qh = _quoting_error_hint(output)
|
|
1022
|
+
if _qh:
|
|
1023
|
+
output = _qh + "\n\n" + output
|
|
972
1024
|
# AirPlay-collision detector: if the model curls localhost:5000
|
|
973
1025
|
# or :7000, those are squatted by macOS AirPlay Receiver /
|
|
974
1026
|
# AirTunes — bare `curl -s` sees a 200 with empty body and
|