lcode-cli 0.34.0__py3-none-any.whl → 0.35.1__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 +150 -10
- lcode/api.py +6 -1
- lcode/backends.py +10 -2
- lcode/bench.py +54 -18
- lcode/checks.py +71 -0
- lcode/cli.py +22 -1
- lcode/config.py +24 -0
- lcode/context.py +40 -15
- lcode/escalate.py +315 -0
- lcode/repeats.py +37 -0
- lcode/repl.py +39 -2
- lcode/screenshots.py +21 -9
- lcode/subagents.py +6 -5
- lcode/tools.py +12 -3
- lcode/unreachable.py +186 -0
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.1.dist-info}/METADATA +1 -1
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.1.dist-info}/RECORD +21 -19
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.1.dist-info}/WHEEL +0 -0
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.1.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.1.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -29,6 +29,7 @@ from lcode import (
|
|
|
29
29
|
catalog,
|
|
30
30
|
checks,
|
|
31
31
|
codesearch,
|
|
32
|
+
escalate,
|
|
32
33
|
extensions,
|
|
33
34
|
limits,
|
|
34
35
|
notify,
|
|
@@ -50,7 +51,7 @@ from lcode.mcp import McpManager
|
|
|
50
51
|
from lcode.ollama import Ollama, OllamaError
|
|
51
52
|
from lcode.permissions import LEADING_CD, Permissions
|
|
52
53
|
from lcode.render import MarkdownStreamer
|
|
53
|
-
from lcode.repeats import Repeats
|
|
54
|
+
from lcode.repeats import Circles, Repeats
|
|
54
55
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
55
56
|
from lcode.steer import NOTE_INTRO, Steering
|
|
56
57
|
from lcode.tools import (
|
|
@@ -70,6 +71,7 @@ from lcode.tools import (
|
|
|
70
71
|
tree,
|
|
71
72
|
truncate,
|
|
72
73
|
)
|
|
74
|
+
from lcode.unreachable import NOTE_AT, Sites
|
|
73
75
|
|
|
74
76
|
GPU_MEMORY_ERRORS = (
|
|
75
77
|
"out of memory",
|
|
@@ -79,6 +81,27 @@ GPU_MEMORY_ERRORS = (
|
|
|
79
81
|
)
|
|
80
82
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
81
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
|
+
|
|
82
105
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
83
106
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
84
107
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
@@ -307,6 +330,9 @@ class Settings:
|
|
|
307
330
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
308
331
|
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
309
332
|
review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
|
|
333
|
+
escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
|
|
334
|
+
escalate_url: str | None = None # its server, when not the provider's usual one
|
|
335
|
+
escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
|
|
310
336
|
|
|
311
337
|
|
|
312
338
|
class Agent:
|
|
@@ -315,6 +341,9 @@ class Agent:
|
|
|
315
341
|
self.ollama = ollama
|
|
316
342
|
self.settings = settings
|
|
317
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
|
|
318
347
|
self.perms = Permissions(self.console, settings.permission_mode)
|
|
319
348
|
self.tools = Toolbox(self)
|
|
320
349
|
self.session_id = self.new_session_id()
|
|
@@ -369,10 +398,14 @@ class Agent:
|
|
|
369
398
|
self._check: tuple[Path, checks.Check | None] | None = None
|
|
370
399
|
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
371
400
|
self.review_results: list[str] | None = None # what the last request's review found (None: no review)
|
|
401
|
+
self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
|
|
402
|
+
self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
|
|
372
403
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
373
404
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
374
|
-
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
|
|
375
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 ""
|
|
376
409
|
self.response = None # the streaming response, so another thread can abort it
|
|
377
410
|
self.ctx_used = 0
|
|
378
411
|
self.last_speed = 0.0
|
|
@@ -642,7 +675,7 @@ class Agent:
|
|
|
642
675
|
f = self.session_file()
|
|
643
676
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
644
677
|
data = {
|
|
645
|
-
"cwd": str(self.
|
|
678
|
+
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
646
679
|
"model": self.settings.model,
|
|
647
680
|
"name": self.session_name,
|
|
648
681
|
"title": self.session_title,
|
|
@@ -681,7 +714,7 @@ class Agent:
|
|
|
681
714
|
saved_cwd = Path(info.cwd) if info.cwd else self.cwd
|
|
682
715
|
if saved_cwd != self.cwd:
|
|
683
716
|
if saved_cwd.is_dir():
|
|
684
|
-
self.cwd = saved_cwd.resolve()
|
|
717
|
+
self.cwd = self.workspace = saved_cwd.resolve()
|
|
685
718
|
note = f"Working directory is now {self.cwd}"
|
|
686
719
|
else:
|
|
687
720
|
note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
|
|
@@ -701,7 +734,7 @@ class Agent:
|
|
|
701
734
|
return note
|
|
702
735
|
|
|
703
736
|
def load_latest(self) -> bool:
|
|
704
|
-
latest = sessions.list_sessions(self.
|
|
737
|
+
latest = sessions.list_sessions(self.workspace, limit=1)
|
|
705
738
|
if latest:
|
|
706
739
|
self.load(latest[0])
|
|
707
740
|
return bool(latest)
|
|
@@ -799,8 +832,11 @@ class Agent:
|
|
|
799
832
|
status = None
|
|
800
833
|
|
|
801
834
|
start_spinner()
|
|
835
|
+
circles = Circles()
|
|
836
|
+
self.reply_trouble = ""
|
|
837
|
+
stream = self.chat(self.messages, self.tool_schemas(), self.settings.think)
|
|
802
838
|
try:
|
|
803
|
-
for chunk in
|
|
839
|
+
for chunk in stream:
|
|
804
840
|
if self.cancel is not None and self.cancel.is_set():
|
|
805
841
|
raise KeyboardInterrupt
|
|
806
842
|
msg = chunk.get("message", {})
|
|
@@ -816,6 +852,9 @@ class Agent:
|
|
|
816
852
|
stop_spinner() # raw text is printed as it streams
|
|
817
853
|
self.console.print(Text(msg["thinking"], style="dim italic"), end="")
|
|
818
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
|
|
819
858
|
if msg.get("content"):
|
|
820
859
|
if printed_thinking:
|
|
821
860
|
self.console.print("\n")
|
|
@@ -840,9 +879,12 @@ class Agent:
|
|
|
840
879
|
self.messages.append({"role": "assistant", "content": content + "\n[interrupted by user]"})
|
|
841
880
|
raise
|
|
842
881
|
finally:
|
|
882
|
+
stream.close()
|
|
843
883
|
stop_spinner()
|
|
844
884
|
if watch is not None:
|
|
845
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
|
|
846
888
|
if printed_thinking:
|
|
847
889
|
self.console.print()
|
|
848
890
|
md.flush()
|
|
@@ -912,6 +954,12 @@ class Agent:
|
|
|
912
954
|
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
913
955
|
)
|
|
914
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
|
+
|
|
915
963
|
def project_check(self) -> checks.Check | None:
|
|
916
964
|
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
917
965
|
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
@@ -952,9 +1000,8 @@ class Agent:
|
|
|
952
1000
|
)
|
|
953
1001
|
return None
|
|
954
1002
|
if round_ >= checks.MAX_ROUNDS:
|
|
955
|
-
self.say
|
|
956
|
-
|
|
957
|
-
)
|
|
1003
|
+
then = "" if self.can_escalate() else ": the model will say what's left"
|
|
1004
|
+
self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
|
|
958
1005
|
else:
|
|
959
1006
|
self.say(
|
|
960
1007
|
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
@@ -1101,14 +1148,21 @@ class Agent:
|
|
|
1101
1148
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1102
1149
|
self.turn_requests = [user_text.strip()]
|
|
1103
1150
|
self.repeats.reset()
|
|
1151
|
+
self.sites.reset()
|
|
1104
1152
|
self.check_results = []
|
|
1105
1153
|
self.review_results = None
|
|
1154
|
+
self.escalations = []
|
|
1155
|
+
escalated = False # the stronger model is asked at most once per request
|
|
1106
1156
|
self.tools.before = {}
|
|
1107
1157
|
reviewed = False
|
|
1108
1158
|
changed = verified = False # files changed in this request; the checks passed since
|
|
1109
1159
|
check_rounds = 0
|
|
1110
1160
|
malformed = 0
|
|
1111
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
|
|
1112
1166
|
for step in range(self.max_steps):
|
|
1113
1167
|
if self.cancel is not None and self.cancel.is_set():
|
|
1114
1168
|
raise KeyboardInterrupt
|
|
@@ -1158,14 +1212,60 @@ class Agent:
|
|
|
1158
1212
|
}
|
|
1159
1213
|
)
|
|
1160
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
|
|
1161
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
|
+
)
|
|
1162
1247
|
if self.steering is not None and self.steering.waiting():
|
|
1163
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
|
|
1164
1255
|
check = self.project_check() if changed and not verified else None
|
|
1165
1256
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1166
1257
|
check_rounds += 1
|
|
1167
1258
|
feedback = self.run_check(check, check_rounds)
|
|
1168
1259
|
if feedback:
|
|
1260
|
+
if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
|
|
1261
|
+
escalated = True
|
|
1262
|
+
why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
|
|
1263
|
+
advice = escalate.run(self, why, failure=feedback)
|
|
1264
|
+
if advice:
|
|
1265
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1266
|
+
check_rounds -= 1 # the checks run once more after the advice
|
|
1267
|
+
changed = False
|
|
1268
|
+
continue
|
|
1169
1269
|
self.messages.append({"role": "user", "content": feedback})
|
|
1170
1270
|
changed = False # run them again only if the model changes something more
|
|
1171
1271
|
continue
|
|
@@ -1231,6 +1331,21 @@ class Agent:
|
|
|
1231
1331
|
)
|
|
1232
1332
|
if verdict.stop:
|
|
1233
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", "")))
|
|
1234
1349
|
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1235
1350
|
changed, verified = True, False
|
|
1236
1351
|
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
@@ -1248,7 +1363,7 @@ class Agent:
|
|
|
1248
1363
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
1249
1364
|
}
|
|
1250
1365
|
)
|
|
1251
|
-
if looping:
|
|
1366
|
+
if looping or blocked:
|
|
1252
1367
|
for rest in calls[i + 1 :]:
|
|
1253
1368
|
skipped = {
|
|
1254
1369
|
"role": "tool",
|
|
@@ -1259,6 +1374,26 @@ class Agent:
|
|
|
1259
1374
|
skipped["tool_call_id"] = rest["id"]
|
|
1260
1375
|
self.messages.append(skipped)
|
|
1261
1376
|
break
|
|
1377
|
+
if looping and not escalated and self.can_escalate():
|
|
1378
|
+
escalated = True
|
|
1379
|
+
advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
|
|
1380
|
+
if advice:
|
|
1381
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1382
|
+
self.repeats.reset()
|
|
1383
|
+
looping = ""
|
|
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
|
|
1262
1397
|
if looping:
|
|
1263
1398
|
self.turn_status = "loop"
|
|
1264
1399
|
self.console.print(
|
|
@@ -1276,6 +1411,11 @@ class Agent:
|
|
|
1276
1411
|
self.turn_status = "success"
|
|
1277
1412
|
self.print_stats()
|
|
1278
1413
|
|
|
1414
|
+
def can_escalate(self) -> bool:
|
|
1415
|
+
"""Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
|
|
1416
|
+
model = (self.settings.escalate_model or "").strip().lower()
|
|
1417
|
+
return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
|
|
1418
|
+
|
|
1279
1419
|
def steer(self) -> None:
|
|
1280
1420
|
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1281
1421
|
steering = self.steering
|
lcode/api.py
CHANGED
|
@@ -78,6 +78,7 @@ class Result:
|
|
|
78
78
|
usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
|
|
79
79
|
checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
|
|
80
80
|
review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
|
|
81
|
+
escalations: list[dict] = field(default_factory=list) # a stronger model asked: model, why, tokens, seconds
|
|
81
82
|
seconds: float = 0.0
|
|
82
83
|
error: str = ""
|
|
83
84
|
|
|
@@ -200,6 +201,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
201
|
repeat_limit=cfg["repeat_limit"],
|
|
201
202
|
checks=cfg["checks"],
|
|
202
203
|
review=cfg["review"],
|
|
204
|
+
escalate_model=cfg["escalate_model"],
|
|
205
|
+
escalate_url=cfg["escalate_url"],
|
|
206
|
+
escalate_price=cfg["escalate_price"],
|
|
203
207
|
compact_at=cfg["compact_at"],
|
|
204
208
|
)
|
|
205
209
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
@@ -281,7 +285,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
281
285
|
|
|
282
286
|
try:
|
|
283
287
|
agent.run_turn(prompt)
|
|
284
|
-
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"
|
|
285
289
|
except KeyboardInterrupt:
|
|
286
290
|
status, error = "interrupted", "interrupted before the request was done"
|
|
287
291
|
except ModelError as e:
|
|
@@ -309,6 +313,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
309
313
|
usage=usage,
|
|
310
314
|
checks=list(agent.check_results),
|
|
311
315
|
review=agent.review_results,
|
|
316
|
+
escalations=list(agent.escalations),
|
|
312
317
|
seconds=round(time.monotonic() - started, 2),
|
|
313
318
|
error=error,
|
|
314
319
|
)
|
lcode/backends.py
CHANGED
|
@@ -265,11 +265,16 @@ class OpenAICompatible:
|
|
|
265
265
|
return self.chat({k: v for k, v in payload.items() if k not in ("format", "tools")}, timeout)
|
|
266
266
|
if r.status_code != 200:
|
|
267
267
|
raise self._failed(r.status_code, r.text)
|
|
268
|
-
|
|
268
|
+
data = r.json()
|
|
269
|
+
choice = (data.get("choices") or [{}])[0].get("message") or {}
|
|
269
270
|
message: dict = {"role": "assistant", "content": strip_thinking(choice.get("content") or "")}
|
|
270
271
|
if choice.get("tool_calls"):
|
|
271
272
|
message["tool_calls"] = [self._ollama_call(c) for c in choice["tool_calls"]]
|
|
272
|
-
|
|
273
|
+
reply: dict = {"message": message}
|
|
274
|
+
usage = data.get("usage") or {}
|
|
275
|
+
if usage.get("prompt_tokens") is not None: # as Ollama counts them
|
|
276
|
+
reply["prompt_eval_count"], reply["eval_count"] = usage["prompt_tokens"], usage.get("completion_tokens")
|
|
277
|
+
return reply
|
|
273
278
|
|
|
274
279
|
@staticmethod
|
|
275
280
|
def _ollama_call(call: dict) -> dict:
|
|
@@ -297,6 +302,7 @@ class OpenAICompatible:
|
|
|
297
302
|
calls: dict[int, dict] = {}
|
|
298
303
|
usage: dict = {}
|
|
299
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
|
|
300
306
|
splitter = ThinkSplitter()
|
|
301
307
|
first_token = None
|
|
302
308
|
with r:
|
|
@@ -318,6 +324,7 @@ class OpenAICompatible:
|
|
|
318
324
|
usage = chunk.get("usage") or usage
|
|
319
325
|
timings = chunk.get("timings") or timings
|
|
320
326
|
for choice in chunk.get("choices") or []:
|
|
327
|
+
finished = choice.get("finish_reason") or finished
|
|
321
328
|
delta = choice.get("delta") or {}
|
|
322
329
|
thinking = delta.get("reasoning_content") or delta.get("reasoning")
|
|
323
330
|
if thinking:
|
|
@@ -351,6 +358,7 @@ class OpenAICompatible:
|
|
|
351
358
|
"prompt_eval_duration": first_token - started,
|
|
352
359
|
"eval_count": int(usage.get("completion_tokens") or 0),
|
|
353
360
|
"eval_duration": max(1, ended - first_token),
|
|
361
|
+
"done_reason": "length" if finished == "length" else "stop",
|
|
354
362
|
}
|
|
355
363
|
if timings.get("prompt_ms") is not None and timings.get("predicted_ms"):
|
|
356
364
|
# The whole prompt counts (it's what fills the context), cached tokens included.
|
lcode/bench.py
CHANGED
|
@@ -29,7 +29,7 @@ from rich.markup import escape
|
|
|
29
29
|
from rich.table import Table
|
|
30
30
|
from rich.text import Text
|
|
31
31
|
|
|
32
|
-
from lcode import __version__
|
|
32
|
+
from lcode import __version__, escalate
|
|
33
33
|
from lcode.agent import Agent, Settings
|
|
34
34
|
from lcode.config import format_tokens
|
|
35
35
|
from lcode.hardware import Hardware
|
|
@@ -790,6 +790,8 @@ class TaskResult:
|
|
|
790
790
|
tool_errors: int = 0 # failed tool calls, including malformed ones
|
|
791
791
|
error: str | None = None # why the run stopped early: a timeout or an Ollama error
|
|
792
792
|
errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
|
|
793
|
+
first_passed: bool | None = None # with --retry: whether the first try passed (None: no retry was needed)
|
|
794
|
+
escalations: int = 0 # times the stronger model was asked (--escalate)
|
|
793
795
|
|
|
794
796
|
|
|
795
797
|
@dataclass
|
|
@@ -887,8 +889,13 @@ def run_task(
|
|
|
887
889
|
keep: bool,
|
|
888
890
|
usage: dict,
|
|
889
891
|
agent: Agent | None = None,
|
|
892
|
+
retry: bool = False,
|
|
890
893
|
) -> TaskResult:
|
|
891
|
-
"""Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session).
|
|
894
|
+
"""Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session).
|
|
895
|
+
|
|
896
|
+
With `retry`, a failed task gets one more try, as a user would ask: "That didn't fix it. Look again." With a
|
|
897
|
+
stronger model set (--escalate), its advice goes along, as with /escalate.
|
|
898
|
+
"""
|
|
892
899
|
folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
|
|
893
900
|
try:
|
|
894
901
|
for rel, content in task.files.items():
|
|
@@ -897,7 +904,7 @@ def run_task(
|
|
|
897
904
|
if agent is None:
|
|
898
905
|
agent = Agent(ollama, settings, folder, console=log)
|
|
899
906
|
else: # the same conversation moves on to this task's folder, as with /cd
|
|
900
|
-
agent.cwd = folder.resolve()
|
|
907
|
+
agent.cwd = agent.workspace = folder.resolve()
|
|
901
908
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
902
909
|
if settings.embed_model != "off": # --code-search: index the task's folder first, outside the timer
|
|
903
910
|
from lcode import codesearch
|
|
@@ -906,25 +913,28 @@ def run_task(
|
|
|
906
913
|
if model:
|
|
907
914
|
codesearch.Index(folder, model).update(ollama) # on the CPU: lcode's model stays loaded
|
|
908
915
|
agent._code_index = None
|
|
916
|
+
agent.escalate_approved = True if settings.escalate_model else None # the --escalate flag is the OK
|
|
909
917
|
before = dict(agent.usage)
|
|
910
918
|
first = len(agent.messages)
|
|
911
|
-
error = None
|
|
912
919
|
started = time.monotonic()
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
+
escalations = 0
|
|
921
|
+
error = attempt(agent, task.prompt, timeout)
|
|
922
|
+
escalations += len(agent.escalations)
|
|
923
|
+
check = checked(task, folder, agent)
|
|
924
|
+
first_passed = None
|
|
925
|
+
if retry and not (check.passed and error is None):
|
|
926
|
+
first_passed = False
|
|
927
|
+
text = RETRY
|
|
928
|
+
if error is None and agent.can_escalate():
|
|
929
|
+
advice = escalate.run(agent, "the user says the fix didn't work")
|
|
930
|
+
escalations += 1 if advice else 0
|
|
931
|
+
text = f"{RETRY}\n\n{advice}" if advice else RETRY
|
|
932
|
+
error = attempt(agent, text, timeout)
|
|
933
|
+
escalations += len(agent.escalations)
|
|
934
|
+
check = checked(task, folder, agent)
|
|
920
935
|
seconds = time.monotonic() - started
|
|
921
936
|
for key, value in agent.usage.items():
|
|
922
937
|
usage[key] = usage.get(key, 0) + value - before.get(key, 0)
|
|
923
|
-
answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
|
|
924
|
-
try:
|
|
925
|
-
check = task.check(folder, answer)
|
|
926
|
-
except Exception as e: # a check must never crash the benchmark
|
|
927
|
-
check = Check(False, f"check failed: {type(e).__name__}: {e}")
|
|
928
938
|
finally: # also when Ctrl+C stops the task
|
|
929
939
|
from lcode.codesearch import index_dir
|
|
930
940
|
|
|
@@ -942,9 +952,34 @@ def run_task(
|
|
|
942
952
|
tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
943
953
|
errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
944
954
|
error=error,
|
|
955
|
+
first_passed=first_passed,
|
|
956
|
+
escalations=escalations,
|
|
945
957
|
)
|
|
946
958
|
|
|
947
959
|
|
|
960
|
+
RETRY = "That didn't fix it: it still doesn't work right. Look again."
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def attempt(agent: Agent, text: str, timeout: float) -> str | None:
|
|
964
|
+
"""One request of a task; why it stopped early, if it did."""
|
|
965
|
+
try:
|
|
966
|
+
with time_limit(timeout):
|
|
967
|
+
agent.run_turn(text)
|
|
968
|
+
except TaskTimeout:
|
|
969
|
+
return f"timed out after {timeout:.0f}s"
|
|
970
|
+
except OllamaError as e:
|
|
971
|
+
return str(e).splitlines()[0][:200]
|
|
972
|
+
return None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def checked(task: Task, folder: Path, agent: Agent) -> Check:
|
|
976
|
+
answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
|
|
977
|
+
try:
|
|
978
|
+
return task.check(folder, answer)
|
|
979
|
+
except Exception as e: # a check must never crash the benchmark
|
|
980
|
+
return Check(False, f"check failed: {type(e).__name__}: {e}")
|
|
981
|
+
|
|
982
|
+
|
|
948
983
|
def prompt_speed(ollama: Ollama, settings: Settings, options: dict) -> float | None:
|
|
949
984
|
"""Prompt tokens per second for prompts the model hasn't seen: the faster of two readings.
|
|
950
985
|
|
|
@@ -998,6 +1033,7 @@ def run_model(
|
|
|
998
1033
|
keep: bool = False,
|
|
999
1034
|
verbose: bool = False,
|
|
1000
1035
|
session: bool = False,
|
|
1036
|
+
retry: bool = False,
|
|
1001
1037
|
) -> ModelRun:
|
|
1002
1038
|
run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
|
|
1003
1039
|
shared: Agent | None = None # with --session, one conversation for all tasks
|
|
@@ -1038,10 +1074,10 @@ def run_model(
|
|
|
1038
1074
|
try:
|
|
1039
1075
|
if verbose:
|
|
1040
1076
|
console.rule(f"{task.id}: {task.title}")
|
|
1041
|
-
result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
|
|
1077
|
+
result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
|
|
1042
1078
|
else:
|
|
1043
1079
|
with console.status(Elapsed(f" {task.id:<13} {task.title}")):
|
|
1044
|
-
result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
|
|
1080
|
+
result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
|
|
1045
1081
|
except KeyboardInterrupt:
|
|
1046
1082
|
console.print(" [yellow]Stopped.[/]")
|
|
1047
1083
|
run.interrupted = True
|
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
|
+
)
|