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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.34.0"
6
+ __version__ = "0.35.1"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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.cwd),
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.cwd, limit=1)
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 self.chat(self.messages, self.tool_schemas(), self.settings.think):
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
- f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
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
- choice = (r.json().get("choices") or [{}])[0].get("message") or {}
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
- return {"message": message}
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
- try:
914
- with time_limit(timeout):
915
- agent.run_turn(task.prompt)
916
- except TaskTimeout:
917
- error = f"timed out after {timeout:.0f}s"
918
- except OllamaError as e:
919
- error = str(e).splitlines()[0][:200]
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
+ )