lcode-cli 0.33.1__py3-none-any.whl → 0.35.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 +64 -8
- lcode/api.py +5 -0
- lcode/backends.py +7 -2
- lcode/bench.py +53 -17
- lcode/cli.py +22 -1
- lcode/config.py +24 -0
- lcode/context.py +40 -15
- lcode/escalate.py +315 -0
- lcode/repl.py +38 -1
- lcode/screenshots.py +1098 -0
- lcode/tools.py +113 -1
- {lcode_cli-0.33.1.dist-info → lcode_cli-0.35.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.33.1.dist-info → lcode_cli-0.35.0.dist-info}/RECORD +17 -15
- {lcode_cli-0.33.1.dist-info → lcode_cli-0.35.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.33.1.dist-info → lcode_cli-0.35.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.33.1.dist-info → lcode_cli-0.35.0.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,
|
|
@@ -48,7 +49,7 @@ from lcode.config import format_tokens
|
|
|
48
49
|
from lcode.jobs import Jobs
|
|
49
50
|
from lcode.mcp import McpManager
|
|
50
51
|
from lcode.ollama import Ollama, OllamaError
|
|
51
|
-
from lcode.permissions import Permissions
|
|
52
|
+
from lcode.permissions import LEADING_CD, Permissions
|
|
52
53
|
from lcode.render import MarkdownStreamer
|
|
53
54
|
from lcode.repeats import Repeats
|
|
54
55
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
@@ -122,7 +123,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
122
123
|
# Verifying your work
|
|
123
124
|
- A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
|
|
124
125
|
- To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
|
|
125
|
-
- For what you can't see, like a GUI, a web page or a layout,
|
|
126
|
+
- For what you can't see, like a GUI, a web page or a layout, use the screenshot tool: it runs the program (or opens the page) without a screen and returns what it shows, with the sizes of windows, widgets or the page measured{look}. Measure instead of assuming.
|
|
126
127
|
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.{checks}
|
|
127
128
|
|
|
128
129
|
# Communication
|
|
@@ -131,7 +132,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
131
132
|
|
|
132
133
|
# Environment
|
|
133
134
|
- Working directory: {cwd}
|
|
134
|
-
- OS: {os}; shell: bash; Python command:
|
|
135
|
+
- OS: {os}; shell: bash; Python command: {python}
|
|
135
136
|
- Date: {date}
|
|
136
137
|
- Git: {git}
|
|
137
138
|
|
|
@@ -218,6 +219,30 @@ def call_arguments(call: dict) -> dict:
|
|
|
218
219
|
return args if isinstance(args, dict) else {}
|
|
219
220
|
|
|
220
221
|
|
|
222
|
+
VENV_DIRS = (".venv", "venv", "env")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def project_python(cwd: Path) -> Path | None:
|
|
226
|
+
"""The project's virtual environment's Python (.venv, venv or env, here or up to the repository's root)."""
|
|
227
|
+
for folder in [cwd, *cwd.parents][:4]:
|
|
228
|
+
for name in VENV_DIRS:
|
|
229
|
+
path = folder / name / "bin" / "python"
|
|
230
|
+
if path.exists() and (folder / name / "pyvenv.cfg").exists():
|
|
231
|
+
return path
|
|
232
|
+
if (folder / ".git").exists():
|
|
233
|
+
break # not beyond the repository
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def python_line(cwd: Path) -> str:
|
|
238
|
+
"""How the system prompt names the Python command."""
|
|
239
|
+
venv = project_python(cwd)
|
|
240
|
+
if venv is None:
|
|
241
|
+
return f"`{'python' if shutil.which('python') else 'python3'}`"
|
|
242
|
+
shown = venv.relative_to(cwd) if cwd in venv.parents else venv
|
|
243
|
+
return f"`{shown}` (the project's virtual environment: plain python lacks its packages)"
|
|
244
|
+
|
|
245
|
+
|
|
221
246
|
class Progress:
|
|
222
247
|
"""The status line under a running request.
|
|
223
248
|
|
|
@@ -283,6 +308,9 @@ class Settings:
|
|
|
283
308
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
284
309
|
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
285
310
|
review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
|
|
311
|
+
escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
|
|
312
|
+
escalate_url: str | None = None # its server, when not the provider's usual one
|
|
313
|
+
escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
|
|
286
314
|
|
|
287
315
|
|
|
288
316
|
class Agent:
|
|
@@ -345,6 +373,8 @@ class Agent:
|
|
|
345
373
|
self._check: tuple[Path, checks.Check | None] | None = None
|
|
346
374
|
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
347
375
|
self.review_results: list[str] | None = None # what the last request's review found (None: no review)
|
|
376
|
+
self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
|
|
377
|
+
self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
|
|
348
378
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
349
379
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
350
380
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
@@ -372,7 +402,7 @@ class Agent:
|
|
|
372
402
|
model=self.settings.model,
|
|
373
403
|
cwd=self.cwd,
|
|
374
404
|
os=f"{platform.system()} {platform.release()} ({platform.machine()})",
|
|
375
|
-
python=
|
|
405
|
+
python=python_line(self.cwd),
|
|
376
406
|
date=dt.date.today().isoformat(),
|
|
377
407
|
git=git_info(self.cwd),
|
|
378
408
|
tree=tree(self.cwd, depth=1, limit=120),
|
|
@@ -383,7 +413,7 @@ class Agent:
|
|
|
383
413
|
skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
|
|
384
414
|
code=self.code_prompt(),
|
|
385
415
|
map=self.map_prompt(),
|
|
386
|
-
look="
|
|
416
|
+
look="; look at other images with view_image" if self.vision_model() else "",
|
|
387
417
|
checks=self.checks_prompt(),
|
|
388
418
|
)
|
|
389
419
|
|
|
@@ -928,9 +958,8 @@ class Agent:
|
|
|
928
958
|
)
|
|
929
959
|
return None
|
|
930
960
|
if round_ >= checks.MAX_ROUNDS:
|
|
931
|
-
self.say
|
|
932
|
-
|
|
933
|
-
)
|
|
961
|
+
then = "" if self.can_escalate() else ": the model will say what's left"
|
|
962
|
+
self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
|
|
934
963
|
else:
|
|
935
964
|
self.say(
|
|
936
965
|
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
@@ -1000,6 +1029,9 @@ class Agent:
|
|
|
1000
1029
|
return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
|
|
1001
1030
|
if name == "bash":
|
|
1002
1031
|
return f"$ {str(args.get('command', ''))[:60]}"
|
|
1032
|
+
if name == "screenshot":
|
|
1033
|
+
what = args.get("url") or LEADING_CD.sub("", str(args.get("command") or ""))
|
|
1034
|
+
return f"screenshot({what[:60]})"
|
|
1003
1035
|
if name in ("web_search", "web_fetch", "view_image"):
|
|
1004
1036
|
return f"{name}({args.get('query') or args.get('url') or args.get('path') or ''})"
|
|
1005
1037
|
if self.mcp and self.mcp.owns(name):
|
|
@@ -1076,6 +1108,8 @@ class Agent:
|
|
|
1076
1108
|
self.repeats.reset()
|
|
1077
1109
|
self.check_results = []
|
|
1078
1110
|
self.review_results = None
|
|
1111
|
+
self.escalations = []
|
|
1112
|
+
escalated = False # the stronger model is asked at most once per request
|
|
1079
1113
|
self.tools.before = {}
|
|
1080
1114
|
reviewed = False
|
|
1081
1115
|
changed = verified = False # files changed in this request; the checks passed since
|
|
@@ -1139,6 +1173,15 @@ class Agent:
|
|
|
1139
1173
|
check_rounds += 1
|
|
1140
1174
|
feedback = self.run_check(check, check_rounds)
|
|
1141
1175
|
if feedback:
|
|
1176
|
+
if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
|
|
1177
|
+
escalated = True
|
|
1178
|
+
why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
|
|
1179
|
+
advice = escalate.run(self, why, failure=feedback)
|
|
1180
|
+
if advice:
|
|
1181
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1182
|
+
check_rounds -= 1 # the checks run once more after the advice
|
|
1183
|
+
changed = False
|
|
1184
|
+
continue
|
|
1142
1185
|
self.messages.append({"role": "user", "content": feedback})
|
|
1143
1186
|
changed = False # run them again only if the model changes something more
|
|
1144
1187
|
continue
|
|
@@ -1232,6 +1275,14 @@ class Agent:
|
|
|
1232
1275
|
skipped["tool_call_id"] = rest["id"]
|
|
1233
1276
|
self.messages.append(skipped)
|
|
1234
1277
|
break
|
|
1278
|
+
if looping and not escalated and self.can_escalate():
|
|
1279
|
+
escalated = True
|
|
1280
|
+
advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
|
|
1281
|
+
if advice:
|
|
1282
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1283
|
+
self.repeats.reset()
|
|
1284
|
+
looping = ""
|
|
1285
|
+
continue
|
|
1235
1286
|
if looping:
|
|
1236
1287
|
self.turn_status = "loop"
|
|
1237
1288
|
self.console.print(
|
|
@@ -1249,6 +1300,11 @@ class Agent:
|
|
|
1249
1300
|
self.turn_status = "success"
|
|
1250
1301
|
self.print_stats()
|
|
1251
1302
|
|
|
1303
|
+
def can_escalate(self) -> bool:
|
|
1304
|
+
"""Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
|
|
1305
|
+
model = (self.settings.escalate_model or "").strip().lower()
|
|
1306
|
+
return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
|
|
1307
|
+
|
|
1252
1308
|
def steer(self) -> None:
|
|
1253
1309
|
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1254
1310
|
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)
|
|
@@ -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:
|
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():
|
|
@@ -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/cli.py
CHANGED
|
@@ -582,11 +582,14 @@ def cmd_bench(args) -> None:
|
|
|
582
582
|
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
583
583
|
checks=cfg["checks"] if args.checks is None else args.checks,
|
|
584
584
|
review=cfg["review"] if args.review is None else args.review,
|
|
585
|
+
escalate_model=args.escalate_model,
|
|
586
|
+
escalate_url=cfg["escalate_url"],
|
|
587
|
+
escalate_price=cfg["escalate_price"],
|
|
585
588
|
repo_map=args.repo_map,
|
|
586
589
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
587
590
|
)
|
|
588
591
|
run = bench.run_model(
|
|
589
|
-
ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session
|
|
592
|
+
ollama, name, settings, tasks, console, args.timeout, args.keep, args.verbose, args.session, args.retry
|
|
590
593
|
)
|
|
591
594
|
runs.append(run)
|
|
592
595
|
if run.interrupted:
|
|
@@ -662,6 +665,8 @@ def cmd_chat(args) -> None:
|
|
|
662
665
|
sys.exit(1)
|
|
663
666
|
fail(str(e))
|
|
664
667
|
exit_code = 0
|
|
668
|
+
if args.escalate and args.prompt:
|
|
669
|
+
agent.escalate_approved = True # the flag is the user's OK for this run
|
|
665
670
|
try:
|
|
666
671
|
if quiet:
|
|
667
672
|
agent.perms.approve = lambda request: False # nobody to ask: refuse what needs permission
|
|
@@ -727,6 +732,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
727
732
|
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
728
733
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
729
734
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
735
|
+
parser.add_argument(
|
|
736
|
+
"--escalate",
|
|
737
|
+
action="store_true",
|
|
738
|
+
help="with -p: let lcode ask escalate_model for help when stuck, without asking you (it costs money)",
|
|
739
|
+
)
|
|
730
740
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
731
741
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
732
742
|
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
@@ -779,6 +789,17 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
779
789
|
p.add_argument(
|
|
780
790
|
"--review", choices=["auto", "off"], help="review each change before it counts (default: the setting)"
|
|
781
791
|
)
|
|
792
|
+
p.add_argument(
|
|
793
|
+
"--retry",
|
|
794
|
+
action="store_true",
|
|
795
|
+
help='give a failed task one more try, as a user would: "That didn\'t fix it. Look again."',
|
|
796
|
+
)
|
|
797
|
+
p.add_argument(
|
|
798
|
+
"--escalate",
|
|
799
|
+
dest="escalate_model",
|
|
800
|
+
metavar="MODEL",
|
|
801
|
+
help="a stronger model to ask when stuck and on --retry, e.g. ollama/qwen3.8-27b (no questions asked)",
|
|
802
|
+
)
|
|
782
803
|
p.add_argument(
|
|
783
804
|
"--checks",
|
|
784
805
|
metavar="auto|off|COMMAND",
|
lcode/config.py
CHANGED
|
@@ -61,6 +61,14 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
62
|
"review": ("off", str, "before the answer, check a change against the request and send problems back: auto | off"),
|
|
63
63
|
"checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
|
|
64
|
+
"escalate_model": (
|
|
65
|
+
None,
|
|
66
|
+
str,
|
|
67
|
+
"a stronger model to ask, with your OK, when the model is stuck: anthropic/…, openai/…, xai/… (key from "
|
|
68
|
+
"ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY) or ollama/<model> (local or at escalate_url)",
|
|
69
|
+
),
|
|
70
|
+
"escalate_url": (None, str, "the stronger model's server, if not its provider's (another Ollama, any OpenAI API)"),
|
|
71
|
+
"escalate_price": (None, str, "its price in dollars per million tokens in/out, e.g. 3/15, for the cost shown"),
|
|
64
72
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
65
73
|
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
66
74
|
"compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
|
|
@@ -165,6 +173,22 @@ def coerce(key: str, value: object) -> object:
|
|
|
165
173
|
if value not in MEMORY_MODES:
|
|
166
174
|
raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
|
|
167
175
|
return value
|
|
176
|
+
if key in ("escalate_model", "escalate_url", "escalate_price"):
|
|
177
|
+
from lcode import escalate
|
|
178
|
+
|
|
179
|
+
text = str(value).strip()
|
|
180
|
+
if key == "escalate_url":
|
|
181
|
+
return normalize_host(text)
|
|
182
|
+
if key == "escalate_model":
|
|
183
|
+
provider = text.partition("/")[0].lower()
|
|
184
|
+
if "/" in text and provider not in escalate.PROVIDERS:
|
|
185
|
+
raise ConfigError(f"escalate_model's provider must be one of {', '.join(escalate.PROVIDERS)}")
|
|
186
|
+
return text
|
|
187
|
+
try:
|
|
188
|
+
escalate.parse_price(text)
|
|
189
|
+
except escalate.EscalateError as e:
|
|
190
|
+
raise ConfigError(str(e)) from e
|
|
191
|
+
return text
|
|
168
192
|
if key == "lsp" and str(value).lower() not in ("auto", "off"):
|
|
169
193
|
raise ConfigError("lsp must be auto or off")
|
|
170
194
|
if key == "lsp":
|
lcode/context.py
CHANGED
|
@@ -41,6 +41,8 @@ ERROR_LINE = re.compile(
|
|
|
41
41
|
)
|
|
42
42
|
MAX_ERROR_LINES = 40
|
|
43
43
|
OUTPUT_DAYS = 7 # saved full outputs are deleted after this
|
|
44
|
+
MAX_SAVED = 20_000_000 # characters of one saved output: a runaway command doesn't fill the disk
|
|
45
|
+
MAX_SAVED_TOTAL = 200_000_000 # bytes of saved outputs in all, the oldest go first
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
# ----------------------------------------------------------------------------- the user's requests
|
|
@@ -262,17 +264,38 @@ def save_output(text: str, label: str) -> Path | None:
|
|
|
262
264
|
folder = outputs_dir()
|
|
263
265
|
try:
|
|
264
266
|
folder.mkdir(parents=True, exist_ok=True)
|
|
265
|
-
|
|
266
|
-
for old in folder.glob("*.txt"):
|
|
267
|
-
if old.stat().st_mtime < cutoff:
|
|
268
|
-
old.unlink(missing_ok=True)
|
|
267
|
+
clean_outputs(folder, room=min(len(text), MAX_SAVED))
|
|
269
268
|
path = folder / f"{time.strftime('%Y%m%d-%H%M%S')}-{re.sub(r'[^a-z0-9]+', '-', label.lower())[:30]}.txt"
|
|
270
|
-
path.write_text(text)
|
|
269
|
+
path.write_text(text if len(text) <= MAX_SAVED else text[:MAX_SAVED] + CUT_SAVED)
|
|
271
270
|
return path
|
|
272
271
|
except OSError:
|
|
273
272
|
return None
|
|
274
273
|
|
|
275
274
|
|
|
275
|
+
CUT_SAVED = "\n[lcode stopped saving here: the output went on, but a saved output is kept to 20 MB]\n"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def clean_outputs(folder: Path, room: int = 0) -> None:
|
|
279
|
+
"""Delete saved outputs older than OUTPUT_DAYS, then the oldest until they take MAX_SAVED_TOTAL with `room`."""
|
|
280
|
+
cutoff = time.time() - OUTPUT_DAYS * 86400
|
|
281
|
+
files = []
|
|
282
|
+
for old in folder.glob("*.txt"):
|
|
283
|
+
try:
|
|
284
|
+
info = old.stat()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
if info.st_mtime < cutoff:
|
|
288
|
+
old.unlink(missing_ok=True)
|
|
289
|
+
else:
|
|
290
|
+
files.append((info.st_mtime, info.st_size, old))
|
|
291
|
+
total = sum(size for _, size, _ in files) + room
|
|
292
|
+
for _, size, old in sorted(files):
|
|
293
|
+
if total <= MAX_SAVED_TOTAL:
|
|
294
|
+
break
|
|
295
|
+
old.unlink(missing_ok=True)
|
|
296
|
+
total -= size
|
|
297
|
+
|
|
298
|
+
|
|
276
299
|
def shorten(text: str, limit: int, label: str = "output") -> str:
|
|
277
300
|
"""Head, tail and the error-looking lines in between; the full text goes to a file."""
|
|
278
301
|
if len(text) <= limit:
|
|
@@ -315,6 +338,7 @@ class OutputSpool:
|
|
|
315
338
|
self.chars = 0
|
|
316
339
|
self.file = None
|
|
317
340
|
self.path: Path | None = None
|
|
341
|
+
self.saved = 0 # characters written to the file
|
|
318
342
|
|
|
319
343
|
def add(self, line: str) -> None:
|
|
320
344
|
self.chars += len(line)
|
|
@@ -323,10 +347,12 @@ class OutputSpool:
|
|
|
323
347
|
if self.chars > self.limit * 2:
|
|
324
348
|
self._spool()
|
|
325
349
|
return
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
350
|
+
if self.saved <= MAX_SAVED: # past it, only the end and the error lines are kept (in memory)
|
|
351
|
+
try:
|
|
352
|
+
self.file.write(line if self.saved + len(line) <= MAX_SAVED else CUT_SAVED)
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
self.saved += len(line)
|
|
330
356
|
if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
|
|
331
357
|
self.errors.append(line.strip()[:300])
|
|
332
358
|
self.tail.append(line)
|
|
@@ -337,6 +363,7 @@ class OutputSpool:
|
|
|
337
363
|
def _spool(self) -> None:
|
|
338
364
|
text = "".join(self.head)
|
|
339
365
|
self.path = save_output(text, self.label)
|
|
366
|
+
self.saved = len(text)
|
|
340
367
|
if self.path is not None:
|
|
341
368
|
try:
|
|
342
369
|
self.file = self.path.open("a")
|
|
@@ -359,12 +386,10 @@ class OutputSpool:
|
|
|
359
386
|
note += "\n[...]\n\n"
|
|
360
387
|
else:
|
|
361
388
|
note += "] ...\n\n"
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
"
|
|
365
|
-
|
|
366
|
-
else ""
|
|
367
|
-
)
|
|
389
|
+
whole = f"The full {self.label} ({self.chars} characters)"
|
|
390
|
+
if self.saved > MAX_SAVED:
|
|
391
|
+
whole = f"The first {MAX_SAVED // 1_000_000} MB of the {self.label} ({self.chars} characters in all)"
|
|
392
|
+
where = f"\n[{whole} is in {self.path}: read parts of it with read_file if you need them.]" if self.path else ""
|
|
368
393
|
return f"{head}{note}{tail}{where}"
|
|
369
394
|
|
|
370
395
|
|