lcode-cli 0.35.0__py3-none-any.whl → 0.36.0__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.
- lcode/__init__.py +1 -1
- lcode/agent.py +118 -7
- lcode/api.py +1 -1
- lcode/backends.py +3 -0
- lcode/bench.py +1 -1
- lcode/checks.py +71 -0
- lcode/cli.py +81 -21
- lcode/repeats.py +37 -0
- lcode/repl.py +81 -32
- lcode/subagents.py +6 -5
- lcode/tools.py +10 -2
- lcode/unreachable.py +186 -0
- {lcode_cli-0.35.0.dist-info → lcode_cli-0.36.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.35.0.dist-info → lcode_cli-0.36.0.dist-info}/RECORD +17 -16
- {lcode_cli-0.35.0.dist-info → lcode_cli-0.36.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.35.0.dist-info → lcode_cli-0.36.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.35.0.dist-info → lcode_cli-0.36.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -51,7 +51,7 @@ from lcode.mcp import McpManager
|
|
|
51
51
|
from lcode.ollama import Ollama, OllamaError
|
|
52
52
|
from lcode.permissions import LEADING_CD, Permissions
|
|
53
53
|
from lcode.render import MarkdownStreamer
|
|
54
|
-
from lcode.repeats import Repeats
|
|
54
|
+
from lcode.repeats import Circles, Repeats
|
|
55
55
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
56
56
|
from lcode.steer import NOTE_INTRO, Steering
|
|
57
57
|
from lcode.tools import (
|
|
@@ -71,6 +71,7 @@ from lcode.tools import (
|
|
|
71
71
|
tree,
|
|
72
72
|
truncate,
|
|
73
73
|
)
|
|
74
|
+
from lcode.unreachable import NOTE_AT, Sites
|
|
74
75
|
|
|
75
76
|
GPU_MEMORY_ERRORS = (
|
|
76
77
|
"out of memory",
|
|
@@ -80,6 +81,27 @@ GPU_MEMORY_ERRORS = (
|
|
|
80
81
|
)
|
|
81
82
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
82
83
|
MAX_STEPS_PER_TURN = 150
|
|
84
|
+
MAX_STALLS = 2 # replies that go round in circles or say nothing, per request, before lcode stops it
|
|
85
|
+
STALLED = {
|
|
86
|
+
"circles": "the reply went round in circles, so lcode stopped it",
|
|
87
|
+
"cut_off": "the reply filled the context before it said anything",
|
|
88
|
+
"": "the reply was empty",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def stall_note(trouble: str) -> str:
|
|
93
|
+
"""What the model is told after a reply that went round in circles or ended without a word."""
|
|
94
|
+
first = {
|
|
95
|
+
"circles": "Your last reply went round in circles (the same thoughts over and over), so lcode stopped it.",
|
|
96
|
+
"cut_off": "Your last reply filled the rest of the context before you said anything.",
|
|
97
|
+
"": "Your last reply was empty.",
|
|
98
|
+
}[trouble]
|
|
99
|
+
return (
|
|
100
|
+
f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
|
|
101
|
+
"tell the user in a few lines what you did, what fails, and what they could do."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
83
105
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
84
106
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
85
107
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
@@ -319,6 +341,9 @@ class Agent:
|
|
|
319
341
|
self.ollama = ollama
|
|
320
342
|
self.settings = settings
|
|
321
343
|
self.cwd = cwd.resolve()
|
|
344
|
+
# The folder lcode works in: where it started, or where /cd or a resumed session took it. A shell
|
|
345
|
+
# command may move the working directory around its project (self.cwd), but not out of it.
|
|
346
|
+
self.workspace = self.cwd
|
|
322
347
|
self.perms = Permissions(self.console, settings.permission_mode)
|
|
323
348
|
self.tools = Toolbox(self)
|
|
324
349
|
self.session_id = self.new_session_id()
|
|
@@ -377,8 +402,10 @@ class Agent:
|
|
|
377
402
|
self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
|
|
378
403
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
379
404
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
380
|
-
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
405
|
+
self.turn_status = "" # how the last request ended: success, max_steps, loop, blocked
|
|
381
406
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
407
|
+
self.sites = Sites(NOTE_AT if settings.repeat_limit else 0) # notices a site this machine can't reach
|
|
408
|
+
self.reply_trouble = "" # what went wrong with the last reply: "circles", "cut_off" or ""
|
|
382
409
|
self.response = None # the streaming response, so another thread can abort it
|
|
383
410
|
self.ctx_used = 0
|
|
384
411
|
self.last_speed = 0.0
|
|
@@ -648,7 +675,7 @@ class Agent:
|
|
|
648
675
|
f = self.session_file()
|
|
649
676
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
650
677
|
data = {
|
|
651
|
-
"cwd": str(self.
|
|
678
|
+
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
652
679
|
"model": self.settings.model,
|
|
653
680
|
"name": self.session_name,
|
|
654
681
|
"title": self.session_title,
|
|
@@ -687,7 +714,7 @@ class Agent:
|
|
|
687
714
|
saved_cwd = Path(info.cwd) if info.cwd else self.cwd
|
|
688
715
|
if saved_cwd != self.cwd:
|
|
689
716
|
if saved_cwd.is_dir():
|
|
690
|
-
self.cwd = saved_cwd.resolve()
|
|
717
|
+
self.cwd = self.workspace = saved_cwd.resolve()
|
|
691
718
|
note = f"Working directory is now {self.cwd}"
|
|
692
719
|
else:
|
|
693
720
|
note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
|
|
@@ -707,7 +734,7 @@ class Agent:
|
|
|
707
734
|
return note
|
|
708
735
|
|
|
709
736
|
def load_latest(self) -> bool:
|
|
710
|
-
latest = sessions.list_sessions(self.
|
|
737
|
+
latest = sessions.list_sessions(self.workspace, limit=1)
|
|
711
738
|
if latest:
|
|
712
739
|
self.load(latest[0])
|
|
713
740
|
return bool(latest)
|
|
@@ -805,8 +832,11 @@ class Agent:
|
|
|
805
832
|
status = None
|
|
806
833
|
|
|
807
834
|
start_spinner()
|
|
835
|
+
circles = Circles()
|
|
836
|
+
self.reply_trouble = ""
|
|
837
|
+
stream = self.chat(self.messages, self.tool_schemas(), self.settings.think)
|
|
808
838
|
try:
|
|
809
|
-
for chunk in
|
|
839
|
+
for chunk in stream:
|
|
810
840
|
if self.cancel is not None and self.cancel.is_set():
|
|
811
841
|
raise KeyboardInterrupt
|
|
812
842
|
msg = chunk.get("message", {})
|
|
@@ -822,6 +852,9 @@ class Agent:
|
|
|
822
852
|
stop_spinner() # raw text is printed as it streams
|
|
823
853
|
self.console.print(Text(msg["thinking"], style="dim italic"), end="")
|
|
824
854
|
printed_thinking = True
|
|
855
|
+
if self.settings.repeat_limit and circles.feed(msg["thinking"]):
|
|
856
|
+
self.reply_trouble = "circles"
|
|
857
|
+
break # closing the stream below stops the model
|
|
825
858
|
if msg.get("content"):
|
|
826
859
|
if printed_thinking:
|
|
827
860
|
self.console.print("\n")
|
|
@@ -846,9 +879,12 @@ class Agent:
|
|
|
846
879
|
self.messages.append({"role": "assistant", "content": content + "\n[interrupted by user]"})
|
|
847
880
|
raise
|
|
848
881
|
finally:
|
|
882
|
+
stream.close()
|
|
849
883
|
stop_spinner()
|
|
850
884
|
if watch is not None:
|
|
851
885
|
watch.stop()
|
|
886
|
+
if final.get("done_reason") == "length" and not self.reply_trouble:
|
|
887
|
+
self.reply_trouble = "cut_off" # it filled the context (or hit the output limit) mid-reply
|
|
852
888
|
if printed_thinking:
|
|
853
889
|
self.console.print()
|
|
854
890
|
md.flush()
|
|
@@ -918,6 +954,12 @@ class Agent:
|
|
|
918
954
|
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
919
955
|
)
|
|
920
956
|
|
|
957
|
+
def unrun_scripts(self, commands: list[str]) -> list[str]:
|
|
958
|
+
"""Shell scripts the model wrote in this request and never ran (only where the checks would run)."""
|
|
959
|
+
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
960
|
+
return []
|
|
961
|
+
return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
|
|
962
|
+
|
|
921
963
|
def project_check(self) -> checks.Check | None:
|
|
922
964
|
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
923
965
|
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
@@ -1106,6 +1148,7 @@ class Agent:
|
|
|
1106
1148
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1107
1149
|
self.turn_requests = [user_text.strip()]
|
|
1108
1150
|
self.repeats.reset()
|
|
1151
|
+
self.sites.reset()
|
|
1109
1152
|
self.check_results = []
|
|
1110
1153
|
self.review_results = None
|
|
1111
1154
|
self.escalations = []
|
|
@@ -1116,6 +1159,10 @@ class Agent:
|
|
|
1116
1159
|
check_rounds = 0
|
|
1117
1160
|
malformed = 0
|
|
1118
1161
|
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
1162
|
+
blocked = None # the site that couldn't be reached, when lcode stopped the request for it
|
|
1163
|
+
stalls = 0 # replies that went round in circles or ended without a word
|
|
1164
|
+
commands: list[str] = [] # shell commands run in this request
|
|
1165
|
+
scripts_noted = False # the model was told about scripts it wrote and never ran
|
|
1119
1166
|
for step in range(self.max_steps):
|
|
1120
1167
|
if self.cancel is not None and self.cancel.is_set():
|
|
1121
1168
|
raise KeyboardInterrupt
|
|
@@ -1165,9 +1212,46 @@ class Agent:
|
|
|
1165
1212
|
}
|
|
1166
1213
|
)
|
|
1167
1214
|
continue
|
|
1215
|
+
if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
|
|
1216
|
+
stalls += 1
|
|
1217
|
+
self.messages.pop() # nothing to keep: no answer, and circles would only fill the context
|
|
1218
|
+
if stalls < MAX_STALLS:
|
|
1219
|
+
self.console.print(
|
|
1220
|
+
Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
|
|
1221
|
+
)
|
|
1222
|
+
self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
|
|
1223
|
+
continue
|
|
1224
|
+
why = "its replies went round in circles or ended without an answer, twice in this request"
|
|
1225
|
+
if not escalated and self.can_escalate():
|
|
1226
|
+
escalated = True
|
|
1227
|
+
advice = escalate.run(self, why)
|
|
1228
|
+
if advice:
|
|
1229
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1230
|
+
stalls = 0
|
|
1231
|
+
continue
|
|
1232
|
+
self.turn_status = "loop"
|
|
1233
|
+
self.console.print(
|
|
1234
|
+
Text(
|
|
1235
|
+
"Stopped: the model's replies went round in circles or ended without an answer, twice in "
|
|
1236
|
+
"this request. Try again with a smaller step, or tell it what to do differently.",
|
|
1237
|
+
style="yellow",
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
return
|
|
1168
1241
|
if not calls:
|
|
1242
|
+
if self.reply_trouble == "cut_off":
|
|
1243
|
+
self.say(
|
|
1244
|
+
"The answer was cut off: the context window is full. /compact or a larger /context helps.",
|
|
1245
|
+
warning=True,
|
|
1246
|
+
)
|
|
1169
1247
|
if self.steering is not None and self.steering.waiting():
|
|
1170
1248
|
continue # the user added a note while the model wrote its answer: it reads the note first
|
|
1249
|
+
unrun = [] if scripts_noted else self.unrun_scripts(commands)
|
|
1250
|
+
if unrun:
|
|
1251
|
+
scripts_noted = True
|
|
1252
|
+
self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
|
|
1253
|
+
self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
|
|
1254
|
+
continue
|
|
1171
1255
|
check = self.project_check() if changed and not verified else None
|
|
1172
1256
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1173
1257
|
check_rounds += 1
|
|
@@ -1247,6 +1331,21 @@ class Agent:
|
|
|
1247
1331
|
)
|
|
1248
1332
|
if verdict.stop:
|
|
1249
1333
|
looping = self.describe_call(name, args)
|
|
1334
|
+
out_of_reach = self.sites.check(name, args, result)
|
|
1335
|
+
if out_of_reach.note and not looping:
|
|
1336
|
+
result += out_of_reach.note
|
|
1337
|
+
what = "stopping the request" if out_of_reach.stop else "told the model"
|
|
1338
|
+
self.console.print(
|
|
1339
|
+
Text(
|
|
1340
|
+
f" ⎿ {out_of_reach.calls} calls to {out_of_reach.site} failed "
|
|
1341
|
+
f"({out_of_reach.what}), none worked: {what}",
|
|
1342
|
+
style="yellow",
|
|
1343
|
+
)
|
|
1344
|
+
)
|
|
1345
|
+
if out_of_reach.stop:
|
|
1346
|
+
blocked = out_of_reach
|
|
1347
|
+
if name == "bash":
|
|
1348
|
+
commands.append(str(args.get("command", "")))
|
|
1250
1349
|
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1251
1350
|
changed, verified = True, False
|
|
1252
1351
|
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
@@ -1264,7 +1363,7 @@ class Agent:
|
|
|
1264
1363
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
1265
1364
|
}
|
|
1266
1365
|
)
|
|
1267
|
-
if looping:
|
|
1366
|
+
if looping or blocked:
|
|
1268
1367
|
for rest in calls[i + 1 :]:
|
|
1269
1368
|
skipped = {
|
|
1270
1369
|
"role": "tool",
|
|
@@ -1283,6 +1382,18 @@ class Agent:
|
|
|
1283
1382
|
self.repeats.reset()
|
|
1284
1383
|
looping = ""
|
|
1285
1384
|
continue
|
|
1385
|
+
if blocked:
|
|
1386
|
+
self.turn_status = "blocked"
|
|
1387
|
+
self.console.print(
|
|
1388
|
+
Text(
|
|
1389
|
+
f"Stopped: every request to {blocked.site} failed ({blocked.calls} calls: {blocked.what}) and "
|
|
1390
|
+
"none worked, so this machine probably can't reach it: a firewall, a proxy, a regional block "
|
|
1391
|
+
"or no internet. Check the connection (another network or a VPN, a proxy setting), or tell "
|
|
1392
|
+
"the model another way to get what it needs.",
|
|
1393
|
+
style="yellow",
|
|
1394
|
+
)
|
|
1395
|
+
)
|
|
1396
|
+
return
|
|
1286
1397
|
if looping:
|
|
1287
1398
|
self.turn_status = "loop"
|
|
1288
1399
|
self.console.print(
|
lcode/api.py
CHANGED
|
@@ -285,7 +285,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
285
285
|
|
|
286
286
|
try:
|
|
287
287
|
agent.run_turn(prompt)
|
|
288
|
-
status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
|
|
288
|
+
status = agent.turn_status if agent.turn_status in ("max_steps", "loop", "blocked") else "success"
|
|
289
289
|
except KeyboardInterrupt:
|
|
290
290
|
status, error = "interrupted", "interrupted before the request was done"
|
|
291
291
|
except ModelError as e:
|
lcode/backends.py
CHANGED
|
@@ -302,6 +302,7 @@ class OpenAICompatible:
|
|
|
302
302
|
calls: dict[int, dict] = {}
|
|
303
303
|
usage: dict = {}
|
|
304
304
|
timings: dict = {} # llama-server measures prompt and generation time itself
|
|
305
|
+
finished = "" # why the reply ended, as Ollama's done_reason: "length" when it was cut off
|
|
305
306
|
splitter = ThinkSplitter()
|
|
306
307
|
first_token = None
|
|
307
308
|
with r:
|
|
@@ -323,6 +324,7 @@ class OpenAICompatible:
|
|
|
323
324
|
usage = chunk.get("usage") or usage
|
|
324
325
|
timings = chunk.get("timings") or timings
|
|
325
326
|
for choice in chunk.get("choices") or []:
|
|
327
|
+
finished = choice.get("finish_reason") or finished
|
|
326
328
|
delta = choice.get("delta") or {}
|
|
327
329
|
thinking = delta.get("reasoning_content") or delta.get("reasoning")
|
|
328
330
|
if thinking:
|
|
@@ -356,6 +358,7 @@ class OpenAICompatible:
|
|
|
356
358
|
"prompt_eval_duration": first_token - started,
|
|
357
359
|
"eval_count": int(usage.get("completion_tokens") or 0),
|
|
358
360
|
"eval_duration": max(1, ended - first_token),
|
|
361
|
+
"done_reason": "length" if finished == "length" else "stop",
|
|
359
362
|
}
|
|
360
363
|
if timings.get("prompt_ms") is not None and timings.get("predicted_ms"):
|
|
361
364
|
# The whole prompt counts (it's what fills the context), cached tokens included.
|
lcode/bench.py
CHANGED
|
@@ -904,7 +904,7 @@ def run_task(
|
|
|
904
904
|
if agent is None:
|
|
905
905
|
agent = Agent(ollama, settings, folder, console=log)
|
|
906
906
|
else: # the same conversation moves on to this task's folder, as with /cd
|
|
907
|
-
agent.cwd = folder.resolve()
|
|
907
|
+
agent.cwd = agent.workspace = folder.resolve()
|
|
908
908
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
909
909
|
if settings.embed_model != "off": # --code-search: index the task's folder first, outside the timer
|
|
910
910
|
from lcode import codesearch
|
lcode/checks.py
CHANGED
|
@@ -158,3 +158,74 @@ def missing(check: Check, result: str) -> bool:
|
|
|
158
158
|
return True
|
|
159
159
|
head = result.splitlines()[:5] # a missing program says so first; test failures look different
|
|
160
160
|
return code not in (0, None) and any(MISSING.search(line) for line in head)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------------------------- scripts written, never run
|
|
164
|
+
|
|
165
|
+
SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh")
|
|
166
|
+
SHELL_SHEBANG = re.compile(r"^#!\S*?(?:/|env\s+)(?:ba|z|da|k)?sh\b")
|
|
167
|
+
RUNNERS = frozenset({"bash", "sh", "zsh", "dash", "ksh", "source", ".", "exec"})
|
|
168
|
+
PREFIXES = frozenset({"sudo", "time", "nohup", "env", "command", "nice"})
|
|
169
|
+
SEGMENTS = re.compile(r"&&|\|\||[;|\n&()]")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def unrun_scripts(root: Path, changed: list[str], commands: list[str]) -> list[str]:
|
|
173
|
+
"""Shell scripts written in this request that no command in it ran.
|
|
174
|
+
|
|
175
|
+
Small models write a build or install script and call it done without running it once; the first thing
|
|
176
|
+
the user then sees is its first error. Only shell scripts count (a .sh file, or a shell shebang): a Python
|
|
177
|
+
file with a shebang is as likely an app or a server that never ends.
|
|
178
|
+
"""
|
|
179
|
+
unrun = []
|
|
180
|
+
for rel in changed:
|
|
181
|
+
path = root / rel
|
|
182
|
+
if not is_shell_script(path):
|
|
183
|
+
continue
|
|
184
|
+
if not any(runs(command, path.name) for command in commands):
|
|
185
|
+
unrun.append(rel)
|
|
186
|
+
return unrun
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_shell_script(path: Path) -> bool:
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
return False
|
|
192
|
+
if path.suffix in SCRIPT_SUFFIXES:
|
|
193
|
+
return True
|
|
194
|
+
if path.suffix:
|
|
195
|
+
return False
|
|
196
|
+
try:
|
|
197
|
+
with path.open("rb") as f:
|
|
198
|
+
first = f.readline(200).decode("utf-8", "replace")
|
|
199
|
+
except OSError:
|
|
200
|
+
return False
|
|
201
|
+
return bool(SHELL_SHEBANG.match(first))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def runs(command: str, name: str) -> bool:
|
|
205
|
+
"""Whether a shell command runs the script called `name` (not just chmods, cats or commits it)."""
|
|
206
|
+
for segment in SEGMENTS.split(command):
|
|
207
|
+
words = segment.split()
|
|
208
|
+
while words and (("=" in words[0] and not words[0].startswith("=")) or words[0] in PREFIXES):
|
|
209
|
+
words.pop(0)
|
|
210
|
+
if words and words[0] == "timeout":
|
|
211
|
+
words = words[2:] if len(words) > 2 else []
|
|
212
|
+
if not words:
|
|
213
|
+
continue
|
|
214
|
+
program = words[0].strip("'\"")
|
|
215
|
+
if program in RUNNERS:
|
|
216
|
+
rest = [w.strip("'\"") for w in words[1:] if not w.startswith("-")]
|
|
217
|
+
program = rest[0] if rest else ""
|
|
218
|
+
if Path(program).name == name:
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def unrun_note(scripts: list[str]) -> str:
|
|
224
|
+
names = ", ".join(scripts)
|
|
225
|
+
it = "it" if len(scripts) == 1 else "them"
|
|
226
|
+
return (
|
|
227
|
+
f"[lcode] You wrote {names} in this request but never ran {it}. Run {it} now and fix what fails, so you know "
|
|
228
|
+
f"{it} works before you say so. If running {it} would change things outside the project (install packages, "
|
|
229
|
+
f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
|
|
230
|
+
"and why."
|
|
231
|
+
)
|
lcode/cli.py
CHANGED
|
@@ -113,44 +113,96 @@ def choose_context(
|
|
|
113
113
|
# ----------------------------------------------------------------------------- lcode models
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
def print_served(
|
|
116
|
+
def print_served(
|
|
117
|
+
client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
out = out or console
|
|
117
120
|
try:
|
|
118
121
|
served = client.chat_models()
|
|
119
122
|
current = backends.pick_model(client, current) if current else None
|
|
120
123
|
except OllamaError as e:
|
|
121
124
|
if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
|
|
122
|
-
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
out.print(f"[red]{e}[/]")
|
|
126
|
+
return []
|
|
127
|
+
served = []
|
|
128
|
+
out.print(f"[bold]Models served by {escape(client.describe())}[/]")
|
|
129
|
+
for i, name in enumerate(served, 1):
|
|
130
|
+
number = f"[cyan]{i:>3}[/] " if numbered else " "
|
|
131
|
+
out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
|
|
127
132
|
if not served:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
out.print(" none listed")
|
|
134
|
+
if not numbered:
|
|
135
|
+
out.print(
|
|
136
|
+
"[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
|
|
137
|
+
"or [/]lcode config set model <id>[dim].[/]"
|
|
138
|
+
)
|
|
139
|
+
return served
|
|
133
140
|
|
|
134
141
|
|
|
135
|
-
def
|
|
142
|
+
def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
|
|
143
|
+
"""Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
|
|
144
|
+
Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
|
|
145
|
+
known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
|
|
146
|
+
usable = []
|
|
147
|
+
for name in sorted({n.removesuffix(":latest") for n in installed} - known):
|
|
148
|
+
try:
|
|
149
|
+
capabilities = ollama.show(name).get("capabilities") or []
|
|
150
|
+
if "completion" in capabilities and "tools" in capabilities:
|
|
151
|
+
usable.append(name)
|
|
152
|
+
except OllamaError:
|
|
153
|
+
continue
|
|
154
|
+
return usable
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def model_choices(ollama: Ollama | None) -> list[str]:
|
|
158
|
+
"""What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
|
|
159
|
+
For an OpenAI-compatible server, the models it serves."""
|
|
136
160
|
if ollama is not None and backends.is_openai(ollama):
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
try:
|
|
162
|
+
return ollama.chat_models() # type: ignore[union-attr]
|
|
163
|
+
except OllamaError:
|
|
164
|
+
return []
|
|
165
|
+
try:
|
|
166
|
+
installed = ollama.installed_names() if ollama else set()
|
|
167
|
+
except OllamaError:
|
|
168
|
+
installed = set()
|
|
169
|
+
return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def print_models(
|
|
173
|
+
ollama: Ollama | None,
|
|
174
|
+
hw: Hardware,
|
|
175
|
+
current: str | None = None,
|
|
176
|
+
numbered: bool = False,
|
|
177
|
+
out: Console | None = None,
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
|
|
180
|
+
out = out or console
|
|
181
|
+
if ollama is not None and backends.is_openai(ollama):
|
|
182
|
+
return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
|
|
139
183
|
try:
|
|
140
184
|
installed = ollama.installed_names() if ollama else set()
|
|
141
185
|
except OllamaError:
|
|
142
186
|
installed = set()
|
|
143
187
|
rec = catalog.recommend(hw)
|
|
144
|
-
wide =
|
|
188
|
+
wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
|
|
145
189
|
columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
|
|
146
190
|
if not wide:
|
|
147
191
|
columns = [c for c in columns if c not in ("Model", "Type")]
|
|
192
|
+
if numbered:
|
|
193
|
+
columns.insert(0, "#")
|
|
148
194
|
table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
|
|
149
195
|
for col in columns:
|
|
150
196
|
table.add_column(
|
|
151
|
-
col,
|
|
197
|
+
col,
|
|
198
|
+
no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
|
|
199
|
+
min_width=11 if col == "Status" else None,
|
|
200
|
+
justify="right" if col == "#" else "left",
|
|
201
|
+
style="cyan" if col == "#" else None,
|
|
152
202
|
)
|
|
203
|
+
choices: list[str] = []
|
|
153
204
|
for spec in catalog.load():
|
|
205
|
+
choices.append(spec.key)
|
|
154
206
|
ctx, speed = spec.fit(hw)
|
|
155
207
|
marks = []
|
|
156
208
|
if spec.local_name in installed or spec.tag in installed:
|
|
@@ -161,6 +213,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
|
|
|
161
213
|
marks.append("[bold]current[/]")
|
|
162
214
|
marks.append("tested" if spec.tested else "[dim]untested[/]")
|
|
163
215
|
row = {
|
|
216
|
+
"#": str(len(choices)),
|
|
164
217
|
"Key": spec.key,
|
|
165
218
|
"Model": spec.name,
|
|
166
219
|
"Type": spec.params,
|
|
@@ -171,11 +224,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
|
|
|
171
224
|
"Status": ", ".join(marks) if wide else "\n".join(marks),
|
|
172
225
|
}
|
|
173
226
|
table.add_row(*(row[c] for c in columns))
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"[
|
|
177
|
-
|
|
178
|
-
|
|
227
|
+
for name in other_installed(ollama, installed) if ollama else []:
|
|
228
|
+
choices.append(name)
|
|
229
|
+
marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
|
|
230
|
+
row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
|
|
231
|
+
table.add_row(*(row.get(c, "") for c in columns))
|
|
232
|
+
out.print(table)
|
|
233
|
+
if not numbered:
|
|
234
|
+
out.print(
|
|
235
|
+
"[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
|
|
236
|
+
"choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
|
|
237
|
+
)
|
|
238
|
+
return choices
|
|
179
239
|
|
|
180
240
|
|
|
181
241
|
def cmd_models(args) -> None:
|
lcode/repeats.py
CHANGED
|
@@ -16,6 +16,8 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import hashlib
|
|
18
18
|
import json
|
|
19
|
+
import re
|
|
20
|
+
from collections import deque
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
|
|
21
23
|
from lcode.permissions import is_read_only
|
|
@@ -102,3 +104,38 @@ def changes_things(name: str, args: dict, result: str) -> bool:
|
|
|
102
104
|
if name == "bash":
|
|
103
105
|
return not is_read_only(str(args.get("command", "")))
|
|
104
106
|
return True # other tools (MCP, subagents…) might
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Circles:
|
|
110
|
+
"""Notice a reply that goes round in circles while it streams: the same sentences over and over.
|
|
111
|
+
|
|
112
|
+
Small models sometimes fall into this while they think: a few paragraphs, repeated until the context is
|
|
113
|
+
full. It takes minutes behind the spinner and ends without an answer. Sentences shorter than MIN_SENTENCE
|
|
114
|
+
don't count (they repeat legitimately). The text is going round in circles when, of the last WINDOW
|
|
115
|
+
sentences, LOOPING or more were each seen REPEATS times or more.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
MIN_SENTENCE = 20
|
|
119
|
+
WINDOW = 20
|
|
120
|
+
REPEATS = 4
|
|
121
|
+
LOOPING = 16
|
|
122
|
+
SPLIT = re.compile(r"\n|(?<=[.!?])\s+")
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self.partial = ""
|
|
126
|
+
self.seen: dict[str, int] = {}
|
|
127
|
+
self.recent: deque[str] = deque(maxlen=self.WINDOW)
|
|
128
|
+
|
|
129
|
+
def feed(self, text: str) -> bool:
|
|
130
|
+
"""Add streamed text; whether it's now going round in circles."""
|
|
131
|
+
parts = self.SPLIT.split(self.partial + text)
|
|
132
|
+
self.partial = parts.pop()
|
|
133
|
+
for part in parts:
|
|
134
|
+
sentence = " ".join(part.split()).lower()
|
|
135
|
+
if len(sentence) < self.MIN_SENTENCE:
|
|
136
|
+
continue
|
|
137
|
+
self.seen[sentence] = self.seen.get(sentence, 0) + 1
|
|
138
|
+
self.recent.append(sentence)
|
|
139
|
+
if len(self.recent) < self.WINDOW:
|
|
140
|
+
return False
|
|
141
|
+
return sum(self.seen[s] >= self.REPEATS for s in self.recent) >= self.LOOPING
|
lcode/repl.py
CHANGED
|
@@ -68,8 +68,8 @@ COMMANDS = {
|
|
|
68
68
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
69
69
|
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
70
70
|
"/ctx": "Shortcut for /context",
|
|
71
|
-
"/model": "
|
|
72
|
-
"/models": "List models and how they fit this machine",
|
|
71
|
+
"/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
|
|
72
|
+
"/models": "List models and how they fit this machine; type a number to switch",
|
|
73
73
|
"/think": "Toggle model reasoning on/off",
|
|
74
74
|
"/verbose": "Toggle showing the model's reasoning text",
|
|
75
75
|
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
|
|
@@ -243,14 +243,20 @@ def banner(agent: Agent) -> None:
|
|
|
243
243
|
CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def model_limit(agent: Agent) -> int | None:
|
|
247
|
+
"""The largest context the model supports (what Ollama reports, else the catalog's), if known."""
|
|
248
|
+
spec = catalog.find(agent.settings.model)
|
|
249
|
+
try:
|
|
250
|
+
return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
|
|
251
|
+
except OllamaError:
|
|
252
|
+
return spec.max_context if spec else None
|
|
253
|
+
|
|
254
|
+
|
|
246
255
|
def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
|
|
247
256
|
"""The context sizes this model supports, with how each one fits this machine."""
|
|
248
257
|
s = agent.settings
|
|
249
258
|
spec = catalog.find(s.model)
|
|
250
|
-
|
|
251
|
-
max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
|
|
252
|
-
except OllamaError:
|
|
253
|
-
max_ctx = spec.max_context if spec else None
|
|
259
|
+
max_ctx = model_limit(agent)
|
|
254
260
|
sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
|
|
255
261
|
if max_ctx:
|
|
256
262
|
sizes.add(max_ctx)
|
|
@@ -317,6 +323,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
317
323
|
marks.append("[cyan]recommended[/]")
|
|
318
324
|
table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
|
|
319
325
|
c.print(table)
|
|
326
|
+
limit = model_limit(agent)
|
|
327
|
+
beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
|
|
328
|
+
if beyond:
|
|
329
|
+
sizes = " and ".join(format_tokens(size) for size in beyond[:2])
|
|
330
|
+
c.print(
|
|
331
|
+
f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
|
|
332
|
+
f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
|
|
333
|
+
"setting would only look larger.[/]"
|
|
334
|
+
)
|
|
320
335
|
try:
|
|
321
336
|
answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
|
|
322
337
|
except EOFError:
|
|
@@ -809,6 +824,63 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
809
824
|
agent.save()
|
|
810
825
|
|
|
811
826
|
|
|
827
|
+
def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
828
|
+
"""/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
|
|
829
|
+
from lcode.cli import model_choices, print_models
|
|
830
|
+
|
|
831
|
+
c, s = agent.console, agent.settings
|
|
832
|
+
if arg and not arg.isdigit():
|
|
833
|
+
switch_model(agent, arg, hardware)
|
|
834
|
+
return
|
|
835
|
+
if arg:
|
|
836
|
+
choices = model_choices(agent.ollama)
|
|
837
|
+
else:
|
|
838
|
+
choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
|
|
839
|
+
if not choices:
|
|
840
|
+
return
|
|
841
|
+
try:
|
|
842
|
+
answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
|
|
843
|
+
except EOFError:
|
|
844
|
+
return
|
|
845
|
+
if not answer:
|
|
846
|
+
c.print(f"Keeping {s.model}.")
|
|
847
|
+
return
|
|
848
|
+
arg = answer
|
|
849
|
+
if arg.isdigit():
|
|
850
|
+
if not 1 <= int(arg) <= len(choices):
|
|
851
|
+
c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
|
|
852
|
+
return
|
|
853
|
+
arg = choices[int(arg) - 1]
|
|
854
|
+
switch_model(agent, arg, hardware)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
|
|
858
|
+
"""Switch to a catalog key or an installed model, and size the context for it."""
|
|
859
|
+
from lcode.cli import NotInstalled, resolve_model
|
|
860
|
+
|
|
861
|
+
c, s = agent.console, agent.settings
|
|
862
|
+
try:
|
|
863
|
+
name, spec = resolve_model(agent.ollama, wanted)
|
|
864
|
+
except (NotInstalled, OllamaError) as e:
|
|
865
|
+
c.print(f"[red]{e}[/]")
|
|
866
|
+
return
|
|
867
|
+
if name == s.model:
|
|
868
|
+
c.print(f"Already using {name} (context {format_tokens(s.context)}).")
|
|
869
|
+
return
|
|
870
|
+
s.model = name
|
|
871
|
+
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
|
|
872
|
+
if spec: # size the context for the new model: largest window that fits this machine
|
|
873
|
+
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
874
|
+
elif backends.is_openai(agent.ollama):
|
|
875
|
+
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
|
|
876
|
+
agent._vision = False # the model that looks at images goes with the model: decide again
|
|
877
|
+
agent.messages[0]["content"] = agent.system_prompt()
|
|
878
|
+
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
879
|
+
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
|
|
880
|
+
if warning:
|
|
881
|
+
agent.say(warning, warning=True)
|
|
882
|
+
|
|
883
|
+
|
|
812
884
|
def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
|
|
813
885
|
c, s = agent.console, agent.settings
|
|
814
886
|
if size == s.context:
|
|
@@ -956,8 +1028,6 @@ def session_start(agent: Agent) -> None:
|
|
|
956
1028
|
|
|
957
1029
|
def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
958
1030
|
"""Run a slash command. Returns False when the REPL should exit."""
|
|
959
|
-
from lcode.cli import NotInstalled, print_models, resolve_model
|
|
960
|
-
|
|
961
1031
|
cmd, _, arg = line.partition(" ")
|
|
962
1032
|
arg = arg.strip()
|
|
963
1033
|
c, s = agent.console, agent.settings
|
|
@@ -1006,29 +1076,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1006
1076
|
agent.compact(arg)
|
|
1007
1077
|
elif cmd in ("/context", "/ctx"):
|
|
1008
1078
|
context_command(agent, arg, hardware)
|
|
1009
|
-
elif cmd
|
|
1010
|
-
|
|
1011
|
-
elif cmd == "/model":
|
|
1012
|
-
if not arg:
|
|
1013
|
-
c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
|
|
1014
|
-
else:
|
|
1015
|
-
try:
|
|
1016
|
-
name, spec = resolve_model(agent.ollama, arg)
|
|
1017
|
-
except (NotInstalled, OllamaError) as e:
|
|
1018
|
-
c.print(f"[red]{e}[/]")
|
|
1019
|
-
return True
|
|
1020
|
-
s.model = name
|
|
1021
|
-
s.num_batch = spec.num_batch if spec and name == spec.local_name else None
|
|
1022
|
-
if spec: # size the context for the new model: largest window that fits this machine
|
|
1023
|
-
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
1024
|
-
elif backends.is_openai(agent.ollama):
|
|
1025
|
-
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
|
|
1026
|
-
agent._vision = False # the model that looks at images goes with the model: decide again
|
|
1027
|
-
agent.messages[0]["content"] = agent.system_prompt()
|
|
1028
|
-
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
1029
|
-
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
|
|
1030
|
-
if warning:
|
|
1031
|
-
agent.say(warning, warning=True)
|
|
1079
|
+
elif cmd in ("/models", "/model"):
|
|
1080
|
+
model_command(agent, arg, hardware)
|
|
1032
1081
|
elif cmd == "/think":
|
|
1033
1082
|
s.think = (arg == "on") if arg in ("on", "off") else not s.think
|
|
1034
1083
|
c.print(f"Reasoning {'on' if s.think else 'off'}.")
|
|
@@ -1059,7 +1108,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1059
1108
|
p = Path(os.path.expanduser(arg or "~"))
|
|
1060
1109
|
p = (p if p.is_absolute() else agent.cwd / p).resolve()
|
|
1061
1110
|
if p.is_dir():
|
|
1062
|
-
agent.cwd = p
|
|
1111
|
+
agent.cwd = agent.workspace = p
|
|
1063
1112
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
1064
1113
|
c.print(f"Working directory: {p}")
|
|
1065
1114
|
else:
|
lcode/subagents.py
CHANGED
|
@@ -461,6 +461,8 @@ class Run:
|
|
|
461
461
|
show_thinking=False,
|
|
462
462
|
)
|
|
463
463
|
child = Agent(parent.ollama, settings, cwd, console=Console(file=io.StringIO(), width=120))
|
|
464
|
+
if not self.worktree:
|
|
465
|
+
child.workspace = parent.workspace # it may cd around the same project, not out of it
|
|
464
466
|
child.perms = self.relay # type: ignore[assignment]
|
|
465
467
|
child.sandbox = parent.sandbox
|
|
466
468
|
child.jobs = parent.jobs # its background commands belong to the session
|
|
@@ -487,11 +489,10 @@ class Run:
|
|
|
487
489
|
last = child.messages[-1]
|
|
488
490
|
if last.get("role") != "assistant" or last.get("tool_calls") or not last.get("content", "").strip():
|
|
489
491
|
# It ran out of steps, or went round in circles: ask for what it has so far, without tools.
|
|
490
|
-
why =
|
|
491
|
-
"You
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
)
|
|
492
|
+
why = {
|
|
493
|
+
"loop": "You went round in circles, so lcode stopped you.",
|
|
494
|
+
"blocked": "A site you needed can't be reached from this machine, so lcode stopped you.",
|
|
495
|
+
}.get(child.turn_status, "You've used all your steps.")
|
|
495
496
|
child.messages.append(
|
|
496
497
|
{
|
|
497
498
|
"role": "user",
|
lcode/tools.py
CHANGED
|
@@ -1283,10 +1283,18 @@ class Toolbox:
|
|
|
1283
1283
|
out.add(last)
|
|
1284
1284
|
recent.append(last)
|
|
1285
1285
|
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
1286
|
-
|
|
1286
|
+
from lcode.checkpoints import work_tree_for
|
|
1287
|
+
|
|
1288
|
+
moved = Path(recorded).resolve()
|
|
1289
|
+
if sandbox and not sandbox.contains(moved):
|
|
1287
1290
|
status += "\n[That directory is outside the project; the working directory stays the same]"
|
|
1291
|
+
elif not sandbox and not moved.is_relative_to(work_tree_for(self.agent.workspace)):
|
|
1292
|
+
status += (
|
|
1293
|
+
f"\n[{moved} is outside the project, so the working directory stays {self.agent.cwd}: the next "
|
|
1294
|
+
"command starts there. To run something elsewhere, cd in the same command]"
|
|
1295
|
+
)
|
|
1288
1296
|
else:
|
|
1289
|
-
self.agent.cwd =
|
|
1297
|
+
self.agent.cwd = moved
|
|
1290
1298
|
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
1291
1299
|
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(recent)):
|
|
1292
1300
|
status += (
|
lcode/unreachable.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Notice when the network, not the model, is in the way.
|
|
2
|
+
|
|
3
|
+
A download that fails however its address is written usually means this machine can't reach the site: a
|
|
4
|
+
firewall, a proxy, a regional block, or no internet. Nothing the model writes fixes that, yet a small model
|
|
5
|
+
will try address after address for an hour (and lcode's repeat check never fires: every address differs).
|
|
6
|
+
So within one request lcode keeps track, per site, of the calls that failed to reach it (an HTTP error, a
|
|
7
|
+
name that doesn't resolve, a refused or timed-out connection…) and of the ones that worked. When `NOTE_AT`
|
|
8
|
+
different calls failed for a site and none worked, the result gets a note: check one address that must
|
|
9
|
+
exist, and if that fails too, tell the user. If `STOP_AFTER_NOTE` more fail, lcode ends the request.
|
|
10
|
+
|
|
11
|
+
Which site a failure belongs to is read from the output: the address on the failing line, else the last
|
|
12
|
+
address printed before it, else the command's address when the command names only one site. A call that
|
|
13
|
+
names several sites and prints a bare status line counts for none of them. A site counts as reached when a
|
|
14
|
+
call got an answer from it: a success status, a page from web_fetch, or a call naming only that site that
|
|
15
|
+
printed plenty without an error. Local addresses (localhost, private networks, names without a dot) aren't
|
|
16
|
+
tracked: they aren't "the internet".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import ipaddress
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
|
|
28
|
+
NOTE_AT = 3 # different calls that failed for a site, and none worked: the note
|
|
29
|
+
STOP_AFTER_NOTE = 2 # more such calls after the note: the request ends
|
|
30
|
+
PLENTY = 400 # characters of error-free output from a call naming one site: it was reached
|
|
31
|
+
URL = re.compile(r"\b(?:https?|ftp)://[^\s'\"<>`|]+", re.I)
|
|
32
|
+
OK_STATUS = re.compile(
|
|
33
|
+
r"\bHTTP/\d(?:\.\d)?\s+[23]\d\d\b|awaiting response\.\.\. [23]\d\d\b"
|
|
34
|
+
r"|\b(?:status(?:[ _]code)?|http_code)[:= ]+[23]\d\d\b",
|
|
35
|
+
re.I,
|
|
36
|
+
)
|
|
37
|
+
FAILED_STATUS = re.compile(
|
|
38
|
+
r"\bHTTP/\d(?:\.\d)?\s+([45]\d\d)\b" # curl -I / -v, wget -S
|
|
39
|
+
r"|\bHTTP Error ([45]\d\d)\b" # Python's urllib
|
|
40
|
+
r"|\breturned HTTP ([45]\d\d)\b" # lcode's web_fetch
|
|
41
|
+
r"|\breturned error: ([45]\d\d)\b" # curl --fail
|
|
42
|
+
r"|\bERROR ([45]\d\d):" # wget
|
|
43
|
+
r"|\b([45]\d\d) (?:Client|Server) Error\b" # requests
|
|
44
|
+
r"|\b(?:status(?:[ _]code)?|http_code)[:= ]+([45]\d\d)\b" # what a model's own loop prints
|
|
45
|
+
r"|\b([45]\d\d):? \(?(?:Not Found|Forbidden|Unauthorized|Bad Gateway|Service Unavailable|Gateway Time-?out|"
|
|
46
|
+
r"Too Many Requests)\b", # npm, wget, error pages
|
|
47
|
+
re.I,
|
|
48
|
+
)
|
|
49
|
+
NETWORK_ERRORS = (
|
|
50
|
+
(
|
|
51
|
+
"the name doesn't resolve",
|
|
52
|
+
re.compile(
|
|
53
|
+
r"Could not resolve host|Temporary failure in name resolution|Name or service not known|"
|
|
54
|
+
r"nodename nor servname|getaddrinfo|ENOTFOUND|EAI_AGAIN|NameResolutionError|Failed to resolve",
|
|
55
|
+
re.I,
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
("connection refused", re.compile(r"Connection refused|ECONNREFUSED", re.I)),
|
|
59
|
+
("timed out", re.compile(r"Connection timed out|ETIMEDOUT|Operation timed out|Read timed out", re.I)),
|
|
60
|
+
("connection reset", re.compile(r"Connection reset|ECONNRESET", re.I)),
|
|
61
|
+
("no route to it", re.compile(r"Network is unreachable|No route to host|ENETUNREACH", re.I)),
|
|
62
|
+
("a TLS/SSL error", re.compile(r"SSL certificate problem|certificate verify failed|SSLError", re.I)),
|
|
63
|
+
("couldn't connect", re.compile(r"Failed to connect to|Max retries exceeded", re.I)),
|
|
64
|
+
)
|
|
65
|
+
LOCAL_SUFFIXES = (".local", ".lan", ".home", ".internal", ".localhost", ".test", ".example", ".invalid")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Verdict:
|
|
70
|
+
site: str = ""
|
|
71
|
+
calls: int = 0 # different calls that failed for the site
|
|
72
|
+
what: str = "" # how they failed, e.g. "HTTP 404"
|
|
73
|
+
note: str = "" # to add to the tool result
|
|
74
|
+
stop: bool = False # end the request
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Sites:
|
|
78
|
+
def __init__(self, note_at: int = NOTE_AT) -> None:
|
|
79
|
+
self.note_at = note_at # 0: off
|
|
80
|
+
self.failed: dict[str, dict[str, str]] = {} # site -> {call key: how it failed}
|
|
81
|
+
self.reached: set[str] = set()
|
|
82
|
+
|
|
83
|
+
def reset(self) -> None:
|
|
84
|
+
"""A new request."""
|
|
85
|
+
self.failed.clear()
|
|
86
|
+
self.reached.clear()
|
|
87
|
+
|
|
88
|
+
def check(self, name: str, args: dict, result: str) -> Verdict:
|
|
89
|
+
"""Count what this call reached and failed to reach; a note when a site looks out of reach."""
|
|
90
|
+
if self.note_at <= 0:
|
|
91
|
+
return Verdict()
|
|
92
|
+
failed, reached = outcomes(name, args, result)
|
|
93
|
+
self.reached |= reached
|
|
94
|
+
key = hashlib.sha256(json.dumps([name, args], sort_keys=True, default=str).encode()).hexdigest()
|
|
95
|
+
verdict = Verdict()
|
|
96
|
+
for site, how in failed.items():
|
|
97
|
+
if site in self.reached:
|
|
98
|
+
continue
|
|
99
|
+
calls = self.failed.setdefault(site, {})
|
|
100
|
+
calls[key] = how
|
|
101
|
+
n = len(calls)
|
|
102
|
+
if n < self.note_at or n < verdict.calls:
|
|
103
|
+
continue
|
|
104
|
+
what = ", ".join(sorted(set(calls.values())))
|
|
105
|
+
if n >= self.note_at + STOP_AFTER_NOTE:
|
|
106
|
+
note = (
|
|
107
|
+
f"\n\n[lcode] {n} different calls to {site} failed ({what}) and none worked, so lcode stopped "
|
|
108
|
+
"the request here: this machine can't reach that site."
|
|
109
|
+
)
|
|
110
|
+
verdict = Verdict(site, n, what, note, stop=True)
|
|
111
|
+
elif not verdict.stop:
|
|
112
|
+
verdict = Verdict(site, n, what, advice(site, n, what))
|
|
113
|
+
return verdict
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def advice(site: str, n: int, what: str) -> str:
|
|
117
|
+
return (
|
|
118
|
+
f"\n\n[lcode] Every request to {site} in this request has failed ({n} different calls: {what}) and none "
|
|
119
|
+
"worked. When a site fails however the address is written, the usual cause is the network, not the "
|
|
120
|
+
"address: a firewall, a proxy, a regional block, or no internet on this machine. Check that once: request "
|
|
121
|
+
f"an address on {site} that must exist (one the site's own pages link to). If that fails too, stop trying "
|
|
122
|
+
"addresses and tell the user what fails and what they could do: use another network or a VPN, set a "
|
|
123
|
+
f"proxy (HTTPS_PROXY), use a mirror, or run this step on a machine that can reach {site}, such as CI."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def site_of(url: str) -> str:
|
|
128
|
+
"""The host to track for a URL, or "" for local and private addresses (and anything unparseable)."""
|
|
129
|
+
try:
|
|
130
|
+
host = (urlsplit(url).hostname or "").lower().rstrip(".")
|
|
131
|
+
except ValueError:
|
|
132
|
+
return ""
|
|
133
|
+
if not host or "$" in host or "{" in host or host == "localhost" or host.endswith(LOCAL_SUFFIXES):
|
|
134
|
+
return ""
|
|
135
|
+
try:
|
|
136
|
+
ip = ipaddress.ip_address(host.strip("[]"))
|
|
137
|
+
except ValueError:
|
|
138
|
+
return host if "." in host else ""
|
|
139
|
+
return "" if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified else host
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def failure(line: str) -> str:
|
|
143
|
+
"""How a line of output says a request failed ("HTTP 404", "the name doesn't resolve"…), or ""."""
|
|
144
|
+
m = FAILED_STATUS.search(line)
|
|
145
|
+
if m:
|
|
146
|
+
return f"HTTP {next(g for g in m.groups() if g)}"
|
|
147
|
+
for what, pattern in NETWORK_ERRORS:
|
|
148
|
+
if pattern.search(line):
|
|
149
|
+
return what
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def outcomes(name: str, args: dict, result: str) -> tuple[dict[str, str], set[str]]:
|
|
154
|
+
"""The sites a call failed to reach (with how) and the ones it reached."""
|
|
155
|
+
if name == "web_fetch":
|
|
156
|
+
site = site_of(str(args.get("url", "")))
|
|
157
|
+
if not site:
|
|
158
|
+
return {}, set()
|
|
159
|
+
if result.startswith("Error:"):
|
|
160
|
+
how = failure(result)
|
|
161
|
+
return ({site: how} if how else {}), set()
|
|
162
|
+
return {}, {site}
|
|
163
|
+
if name != "bash":
|
|
164
|
+
return {}, set()
|
|
165
|
+
named = {site_of(url) for url in URL.findall(str(args.get("command", "")))}
|
|
166
|
+
current = next(iter(named)) if len(named) == 1 else ""
|
|
167
|
+
failed: dict[str, str] = {}
|
|
168
|
+
reached: set[str] = set()
|
|
169
|
+
for line in result.splitlines():
|
|
170
|
+
if line.startswith("[lcode") or line.startswith("[exit code"):
|
|
171
|
+
continue
|
|
172
|
+
urls = URL.findall(line)
|
|
173
|
+
if urls:
|
|
174
|
+
current = site_of(urls[-1])
|
|
175
|
+
if not current:
|
|
176
|
+
continue
|
|
177
|
+
how = failure(line)
|
|
178
|
+
if how:
|
|
179
|
+
failed.setdefault(current, how)
|
|
180
|
+
elif OK_STATUS.search(line):
|
|
181
|
+
reached.add(current)
|
|
182
|
+
if len(named) == 1 and (site := next(iter(named))) and not failed and site not in reached:
|
|
183
|
+
printed = "".join(line for line in result.splitlines() if not line.startswith(("[lcode", "[exit code")))
|
|
184
|
+
if result.rstrip().endswith("[exit code: 0]") and len(printed) >= PLENTY:
|
|
185
|
+
reached.add(site)
|
|
186
|
+
return failed, reached
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.36.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=yGzk6CSXaFMumjYybvLVXP6ECcm-RN4YJ77pJlK1FWw,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
7
|
-
lcode/backends.py,sha256=
|
|
8
|
-
lcode/bench.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=m_q7Q-uKWRUru6rUXXEhmUvJeDQQR1EMADigw06MGq4,84599
|
|
6
|
+
lcode/api.py,sha256=mIfmiN3jDv8jg-HOJtqfr-MQ3cOI8nfFmlYYRZR1r1Y,16164
|
|
7
|
+
lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
|
|
8
|
+
lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
|
-
lcode/checks.py,sha256=
|
|
14
|
-
lcode/cli.py,sha256=
|
|
13
|
+
lcode/checks.py,sha256=iPaNaGOGo7zHc_mue41ELEHSNq0VIH_xVYL_bHkYLXU,9373
|
|
14
|
+
lcode/cli.py,sha256=vtrGb1A_Jnxulu-L0nac01ARdQ8dyxZI4fapb-0G2N4,44703
|
|
15
15
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
16
16
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
17
17
|
lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
|
|
@@ -33,8 +33,8 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
|
33
33
|
lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
34
34
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
35
35
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
36
|
-
lcode/repeats.py,sha256
|
|
37
|
-
lcode/repl.py,sha256=
|
|
36
|
+
lcode/repeats.py,sha256=vpXR5pzKX9GsTzQONGS30V-rEU3gunaj_hBzJxg43Q4,5542
|
|
37
|
+
lcode/repl.py,sha256=2i_EDAsXxQ9OxXyWiuPGtIY4pLO3lQW4_SulITcThkM,56548
|
|
38
38
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
39
39
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
40
40
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
@@ -43,8 +43,9 @@ lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
|
|
|
43
43
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
44
44
|
lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
|
|
45
45
|
lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
|
|
46
|
-
lcode/subagents.py,sha256=
|
|
47
|
-
lcode/tools.py,sha256=
|
|
46
|
+
lcode/subagents.py,sha256=thR2ZzFZ1DeoAf0Ctr_oeKjYajhvd3uRs2kGdw6LlJM,29349
|
|
47
|
+
lcode/tools.py,sha256=glp18sbnxaK5iugDxrCFlDaT0SEbO-GTOL_-O3Afp44,68005
|
|
48
|
+
lcode/unreachable.py,sha256=TXrv41ljwX_amxdcf4CY6qGF2YkWetORvPUSvwceJXI,8685
|
|
48
49
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
49
50
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
50
51
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -64,8 +65,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
64
65
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
65
66
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
66
67
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
67
|
-
lcode_cli-0.
|
|
68
|
-
lcode_cli-0.
|
|
69
|
-
lcode_cli-0.
|
|
70
|
-
lcode_cli-0.
|
|
71
|
-
lcode_cli-0.
|
|
68
|
+
lcode_cli-0.36.0.dist-info/METADATA,sha256=kp6gDSBL_DtXIWYn7FRO6CpzOf8t-dQPMOwpCQ_4trU,13141
|
|
69
|
+
lcode_cli-0.36.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
70
|
+
lcode_cli-0.36.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
71
|
+
lcode_cli-0.36.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
72
|
+
lcode_cli-0.36.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|