lcode-cli 0.34.0__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 +32 -3
- 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 +21 -9
- lcode/tools.py +2 -1
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.0.dist-info}/RECORD +17 -16
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.34.0.dist-info → lcode_cli-0.35.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.34.0.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,
|
|
@@ -307,6 +308,9 @@ class Settings:
|
|
|
307
308
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
308
309
|
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
309
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
|
|
310
314
|
|
|
311
315
|
|
|
312
316
|
class Agent:
|
|
@@ -369,6 +373,8 @@ class Agent:
|
|
|
369
373
|
self._check: tuple[Path, checks.Check | None] | None = None
|
|
370
374
|
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
371
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)
|
|
372
378
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
373
379
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
374
380
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
@@ -952,9 +958,8 @@ class Agent:
|
|
|
952
958
|
)
|
|
953
959
|
return None
|
|
954
960
|
if round_ >= checks.MAX_ROUNDS:
|
|
955
|
-
self.say
|
|
956
|
-
|
|
957
|
-
)
|
|
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)
|
|
958
963
|
else:
|
|
959
964
|
self.say(
|
|
960
965
|
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
@@ -1103,6 +1108,8 @@ class Agent:
|
|
|
1103
1108
|
self.repeats.reset()
|
|
1104
1109
|
self.check_results = []
|
|
1105
1110
|
self.review_results = None
|
|
1111
|
+
self.escalations = []
|
|
1112
|
+
escalated = False # the stronger model is asked at most once per request
|
|
1106
1113
|
self.tools.before = {}
|
|
1107
1114
|
reviewed = False
|
|
1108
1115
|
changed = verified = False # files changed in this request; the checks passed since
|
|
@@ -1166,6 +1173,15 @@ class Agent:
|
|
|
1166
1173
|
check_rounds += 1
|
|
1167
1174
|
feedback = self.run_check(check, check_rounds)
|
|
1168
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
|
|
1169
1185
|
self.messages.append({"role": "user", "content": feedback})
|
|
1170
1186
|
changed = False # run them again only if the model changes something more
|
|
1171
1187
|
continue
|
|
@@ -1259,6 +1275,14 @@ class Agent:
|
|
|
1259
1275
|
skipped["tool_call_id"] = rest["id"]
|
|
1260
1276
|
self.messages.append(skipped)
|
|
1261
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
|
|
1262
1286
|
if looping:
|
|
1263
1287
|
self.turn_status = "loop"
|
|
1264
1288
|
self.console.print(
|
|
@@ -1276,6 +1300,11 @@ class Agent:
|
|
|
1276
1300
|
self.turn_status = "success"
|
|
1277
1301
|
self.print_stats()
|
|
1278
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
|
+
|
|
1279
1308
|
def steer(self) -> None:
|
|
1280
1309
|
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1281
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
|
|
lcode/escalate.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Ask a stronger model when the local one is stuck (opt-in, asked each time, with the cost shown).
|
|
2
|
+
|
|
3
|
+
Some problems are beyond a small local model: it loops, or the same check keeps failing. With `escalate_model`
|
|
4
|
+
set, lcode can then ask a stronger model, in the cloud (OpenAI, Anthropic, xAI, with the user's key) or a
|
|
5
|
+
bigger local one (Ollama, here or on another server), for one piece of advice. The local model gets the advice
|
|
6
|
+
and carries on with its own tools.
|
|
7
|
+
|
|
8
|
+
Nothing is sent without asking: the user sees why, what goes (the requests, the task sheet, the failure, the
|
|
9
|
+
files in question) and its size and price, and can read all of it first. Only that goes, never the whole
|
|
10
|
+
session, and API keys come from environment variables, read when needed and sent only to their own provider.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from rich.markup import escape
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from lcode import context as context_tools
|
|
26
|
+
from lcode.ollama import Ollama, OllamaError, redact
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lcode.agent import Agent
|
|
30
|
+
|
|
31
|
+
# provider -> (API address, the environment variable with its key, its name)
|
|
32
|
+
PROVIDERS = {
|
|
33
|
+
"openai": ("https://api.openai.com/v1", "OPENAI_API_KEY", "OpenAI"),
|
|
34
|
+
"anthropic": ("https://api.anthropic.com/v1", "ANTHROPIC_API_KEY", "Anthropic"),
|
|
35
|
+
"xai": ("https://api.x.ai/v1", "XAI_API_KEY", "xAI"),
|
|
36
|
+
"ollama": ("", "", "Ollama"), # lcode's own Ollama, or another one at escalate_url
|
|
37
|
+
"custom": ("", "LCODE_ESCALATE_API_KEY", "OpenAI-compatible server"), # any OpenAI API at escalate_url
|
|
38
|
+
}
|
|
39
|
+
BUDGET = 60_000 # characters sent at most (about 15K tokens)
|
|
40
|
+
FILE_CHARS = 16_000 # of one file
|
|
41
|
+
MAX_OUTPUT = 4096 # tokens the advice may take (thinking included, for local models)
|
|
42
|
+
RECENT_STEPS = 8 # the last tool calls and results that go along
|
|
43
|
+
TIMEOUT = 1800 # seconds: a big local model is slow
|
|
44
|
+
|
|
45
|
+
ADVISOR = """You are a senior engineer helping a smaller coding model that is stuck on a task in the user's repository. You can't run anything: you get the user's request, the model's notes, why it's stuck, the files in question and its last steps. Find the real cause and say exactly what to change.
|
|
46
|
+
|
|
47
|
+
Answer in this order:
|
|
48
|
+
1. What's wrong: the root cause in a few sentences, pointing at file:line.
|
|
49
|
+
2. The fix: unified diffs, or edits precise enough to apply (file, the exact code to replace, the new code). Fix the cause, not the symptom: no special cases for a test.
|
|
50
|
+
3. How to check it: the command to run and what it should show.
|
|
51
|
+
Be brief. If something you need is missing, say what to look at."""
|
|
52
|
+
|
|
53
|
+
ADVICE = """[lcode] You were stuck ({why}), so lcode asked a stronger model, {model}, for advice. It saw your notes, the failure, the files involved and your last steps, but couldn't run anything:
|
|
54
|
+
|
|
55
|
+
{advice}
|
|
56
|
+
|
|
57
|
+
Check its advice against the code, apply what's right with your tools, run the checks, and carry on."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EscalateError(Exception):
|
|
61
|
+
"""The stronger model can't be asked; the message says why and what to do."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Target:
|
|
66
|
+
provider: str
|
|
67
|
+
model: str
|
|
68
|
+
url: str # where the request goes ("" for lcode's own Ollama)
|
|
69
|
+
key_env: str # the environment variable with the key ("" when none is needed)
|
|
70
|
+
label: str # e.g. "claude-sonnet-4-5 (Anthropic)"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def local(self) -> bool:
|
|
74
|
+
return not self.url or bool(re.match(r"^https?://(localhost|127\.0\.0\.1|\[::1\])(:|/|$)", self.url))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def cloud(self) -> bool:
|
|
78
|
+
"""Paid per token: a cloud provider, or an OpenAI-compatible API elsewhere."""
|
|
79
|
+
return self.provider in ("openai", "anthropic", "xai") or (self.provider == "custom" and not self.local)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def where(self) -> str:
|
|
83
|
+
if self.url:
|
|
84
|
+
return re.sub(r"^https?://", "", redact(self.url)).split("/")[0]
|
|
85
|
+
return "this machine"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def target(setting: str | None, url: str | None = None) -> Target | None:
|
|
89
|
+
"""The stronger model from the settings (None: escalation is off). Raises EscalateError for a bad setting."""
|
|
90
|
+
setting = (setting or "").strip()
|
|
91
|
+
if not setting or setting.lower() == "off":
|
|
92
|
+
return None
|
|
93
|
+
provider, sep, model = setting.partition("/")
|
|
94
|
+
if not sep or provider.lower() not in PROVIDERS:
|
|
95
|
+
if url: # a model name with its own server
|
|
96
|
+
provider, model = ("ollama" if ":11434" in url else "custom"), setting
|
|
97
|
+
else:
|
|
98
|
+
raise EscalateError(
|
|
99
|
+
f"escalate_model {setting!r} needs a provider: openai/…, anthropic/…, xai/…, ollama/… or custom/… "
|
|
100
|
+
"(with escalate_url)"
|
|
101
|
+
)
|
|
102
|
+
provider = provider.lower()
|
|
103
|
+
base, key_env, name = PROVIDERS[provider]
|
|
104
|
+
if not model.strip():
|
|
105
|
+
raise EscalateError(f"escalate_model {setting!r} names no model")
|
|
106
|
+
if provider == "custom" and not url:
|
|
107
|
+
raise EscalateError("a custom/… escalate_model needs escalate_url, the server's OpenAI-compatible address")
|
|
108
|
+
return Target(provider, model.strip(), (url or base).rstrip("/"), key_env, f"{model.strip()} ({name})")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def client(t: Target, ollama):
|
|
112
|
+
"""The client that talks to the stronger model. The key is read now and only goes to its provider."""
|
|
113
|
+
if t.provider == "ollama":
|
|
114
|
+
if t.url:
|
|
115
|
+
return Ollama(t.url)
|
|
116
|
+
# lcode's own Ollama, unless lcode talks to another kind of server: then the usual address
|
|
117
|
+
return ollama if getattr(ollama, "kind", "ollama") == "ollama" else Ollama("http://localhost:11434")
|
|
118
|
+
from lcode.backends import OpenAICompatible
|
|
119
|
+
|
|
120
|
+
key = os.environ.get(t.key_env, "") if t.key_env else ""
|
|
121
|
+
if t.provider != "custom" and not key:
|
|
122
|
+
raise EscalateError(f"{t.label} needs an API key: set {t.key_env} in your environment (lcode never stores it)")
|
|
123
|
+
return OpenAICompatible(t.url, api_key=key, name=t.label)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_price(text: str | None) -> tuple[float, float] | None:
|
|
127
|
+
"""`escalate_price` "3/15": dollars per million input and output tokens."""
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
m = re.fullmatch(r"\s*\$?(\d+(?:\.\d+)?)\s*/\s*\$?(\d+(?:\.\d+)?)\s*", str(text))
|
|
131
|
+
if not m:
|
|
132
|
+
raise EscalateError(f"escalate_price must look like 3/15 (dollars per million tokens in/out), not {text!r}")
|
|
133
|
+
return float(m.group(1)), float(m.group(2))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def tokens(text: str) -> int:
|
|
137
|
+
return max(1, len(text) // 4)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cost_line(t: Target, sent: int, price: tuple[float, float] | None) -> str:
|
|
141
|
+
if not t.cloud:
|
|
142
|
+
return "free: it runs on " + ("this machine" if not t.url else t.where)
|
|
143
|
+
if price is None:
|
|
144
|
+
return "unknown: set escalate_price to see it (dollars per million tokens in/out, e.g. lcode config set escalate_price 3/15)"
|
|
145
|
+
low = sent * price[0] / 1e6
|
|
146
|
+
high = low + MAX_OUTPUT * price[1] / 1e6
|
|
147
|
+
return f"about ${low:.3f} to ${high:.3f} ({format_k(sent)} tokens in at ${price[0]:g}/M, up to {format_k(MAX_OUTPUT)} out at ${price[1]:g}/M)"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_k(n: int) -> str:
|
|
151
|
+
return f"{n / 1000:.1f}K" if n >= 1000 else str(n)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------------------------- what's sent
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def brief(agent: Agent, why: str, failure: str = "") -> tuple[str, list[str]]:
|
|
158
|
+
"""What the stronger model gets, and the names of the files in it."""
|
|
159
|
+
parts = []
|
|
160
|
+
requests = context_tools.user_requests(agent.messages)[-3:]
|
|
161
|
+
parts.append("# What the user asked (latest last)\n" + "\n---\n".join(r[:3000] for r in requests))
|
|
162
|
+
sheet = context_tools.task_sheet(agent)
|
|
163
|
+
if sheet:
|
|
164
|
+
parts.append("# The model's task sheet\n" + sheet)
|
|
165
|
+
parts.append(f"# Why it's stuck\n{why}" + (f"\n\n{failure[-6000:]}" if failure else ""))
|
|
166
|
+
steps = recent_steps(agent.messages)
|
|
167
|
+
if steps:
|
|
168
|
+
parts.append("# Its last steps\n" + steps)
|
|
169
|
+
used = sum(len(p) for p in parts)
|
|
170
|
+
files, names = [], []
|
|
171
|
+
for path in relevant_files(agent):
|
|
172
|
+
text = agent.current_text(path)
|
|
173
|
+
if text is None:
|
|
174
|
+
continue
|
|
175
|
+
if len(text) > FILE_CHARS:
|
|
176
|
+
text = text[:FILE_CHARS] + f"\n[… {len(text) - FILE_CHARS} more characters]"
|
|
177
|
+
block = f"## {agent.tools.rel(agent.tools.resolve(path))}\n```\n{text}\n```"
|
|
178
|
+
if used + len(block) > BUDGET:
|
|
179
|
+
break
|
|
180
|
+
used += len(block)
|
|
181
|
+
files.append(block)
|
|
182
|
+
names.append(agent.tools.rel(agent.tools.resolve(path)))
|
|
183
|
+
if files:
|
|
184
|
+
parts.append("# The files involved (as they are now)\n" + "\n\n".join(files))
|
|
185
|
+
return "\n\n".join(parts), names
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def relevant_files(agent: Agent) -> list[str]:
|
|
189
|
+
"""The files changed in this request, then the ones read most recently."""
|
|
190
|
+
seen: dict[str, None] = dict.fromkeys(agent.tools.before)
|
|
191
|
+
for message in reversed(agent.messages):
|
|
192
|
+
for call in message.get("tool_calls") or []:
|
|
193
|
+
fn = call.get("function") or {}
|
|
194
|
+
args = fn.get("arguments") or {}
|
|
195
|
+
if (
|
|
196
|
+
isinstance(args, dict)
|
|
197
|
+
and fn.get("name") in ("read_file", "edit_file", "write_file")
|
|
198
|
+
and args.get("path")
|
|
199
|
+
):
|
|
200
|
+
seen.setdefault(str(args["path"]), None)
|
|
201
|
+
if len(seen) >= 12:
|
|
202
|
+
break
|
|
203
|
+
return list(seen)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def recent_steps(messages: list[dict]) -> str:
|
|
207
|
+
lines = []
|
|
208
|
+
for message in messages[-40:]:
|
|
209
|
+
for call in message.get("tool_calls") or []:
|
|
210
|
+
fn = call.get("function") or {}
|
|
211
|
+
args = fn.get("arguments") or {}
|
|
212
|
+
shown = ", ".join(f"{k}={str(v)[:120]!r}" for k, v in args.items()) if isinstance(args, dict) else ""
|
|
213
|
+
lines.append(f"- {fn.get('name', '?')}({shown})")
|
|
214
|
+
if message.get("role") == "tool":
|
|
215
|
+
result = str(message.get("content") or "").strip()
|
|
216
|
+
lines.append(" → " + (result[:400] + " …" if len(result) > 400 else result).replace("\n", "\n "))
|
|
217
|
+
return "\n".join(lines[-RECENT_STEPS * 2 :])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------------------------------------- asking
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def ask_user(agent: Agent, t: Target, why: str, text: str, names: list[str], price) -> bool:
|
|
224
|
+
"""Show what would go where, at what cost, and ask. Always asked, whatever the permission mode."""
|
|
225
|
+
if agent.escalate_approved is not None: # a program decides (lcode -p --escalate, the benchmark)
|
|
226
|
+
return agent.escalate_approved
|
|
227
|
+
if not agent.interactive:
|
|
228
|
+
agent.say("A stronger model could help, but nobody can be asked in this run (lcode -p --escalate allows it).")
|
|
229
|
+
return False
|
|
230
|
+
sent = tokens(ADVISOR) + tokens(text)
|
|
231
|
+
files = ", ".join(names[:6]) + (f" and {len(names) - 6} more" if len(names) > 6 else "") if names else "none"
|
|
232
|
+
body = Text.from_markup(
|
|
233
|
+
f"[bold]Why:[/] {escape(why)}\n"
|
|
234
|
+
f"[bold]What goes:[/] your requests, the task sheet, the failure, the last steps and these files: "
|
|
235
|
+
f"{escape(files)} ({format_k(sent)} tokens)\n"
|
|
236
|
+
f"[bold]Where:[/] {escape(t.label)}, {'on' if t.where == 'this machine' else 'at'} {escape(t.where)}\n"
|
|
237
|
+
f"[bold]Cost:[/] {escape(cost_line(t, sent, price))}"
|
|
238
|
+
)
|
|
239
|
+
agent.console.print(Panel(body, title=f"Ask {t.model} for help?", title_align="left", border_style="magenta"))
|
|
240
|
+
agent.waiting(f"ask {t.model} for help?")
|
|
241
|
+
read = agent.perms.read_line or input
|
|
242
|
+
while True:
|
|
243
|
+
try:
|
|
244
|
+
answer = read(" Send it? [y]es / [n]o / [s]how what's sent: ").strip().lower()
|
|
245
|
+
except EOFError:
|
|
246
|
+
return False
|
|
247
|
+
if answer in ("s", "show"):
|
|
248
|
+
agent.console.print(Panel(Text(text), title="What would be sent", title_align="left", border_style="dim"))
|
|
249
|
+
continue
|
|
250
|
+
return answer in ("y", "yes")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def advise(agent: Agent, t: Target, text: str) -> tuple[str, dict]:
|
|
254
|
+
"""The stronger model's advice, and its token counts."""
|
|
255
|
+
talk = client(t, agent.ollama)
|
|
256
|
+
payload: dict = {
|
|
257
|
+
"model": t.model,
|
|
258
|
+
"messages": [{"role": "system", "content": ADVISOR}, {"role": "user", "content": text}],
|
|
259
|
+
"options": {"num_predict": MAX_OUTPUT},
|
|
260
|
+
}
|
|
261
|
+
if t.provider == "ollama":
|
|
262
|
+
from lcode.cli import NotInstalled, resolve_model
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
payload["model"], _ = resolve_model(talk, t.model) # a catalog key (qwen3.8-27b) or an Ollama tag
|
|
266
|
+
except (NotInstalled, OllamaError) as e:
|
|
267
|
+
raise EscalateError(str(e)) from e
|
|
268
|
+
payload["think"] = agent.settings.think
|
|
269
|
+
payload["keep_alive"] = "5m" # it gives way to lcode's model soon after
|
|
270
|
+
payload["options"]["num_ctx"] = max(16384, min(65536, (tokens(text) + MAX_OUTPUT) * 5 // 4))
|
|
271
|
+
try:
|
|
272
|
+
reply = talk.chat(payload, timeout=TIMEOUT)
|
|
273
|
+
except OllamaError as e:
|
|
274
|
+
raise EscalateError(str(e).splitlines()[0][:300]) from e
|
|
275
|
+
advice = ((reply.get("message") or {}).get("content") or "").strip()
|
|
276
|
+
if not advice:
|
|
277
|
+
raise EscalateError(f"{t.model} gave no answer")
|
|
278
|
+
usage = {"in": reply.get("prompt_eval_count") or tokens(text), "out": reply.get("eval_count") or tokens(advice)}
|
|
279
|
+
return advice, usage
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def run(agent: Agent, why: str, failure: str = "") -> str | None:
|
|
283
|
+
"""Ask the stronger model, if one is set and the user agrees; the message for the local model, or None."""
|
|
284
|
+
try:
|
|
285
|
+
t = target(agent.settings.escalate_model, agent.settings.escalate_url)
|
|
286
|
+
price = parse_price(agent.settings.escalate_price)
|
|
287
|
+
except EscalateError as e:
|
|
288
|
+
agent.say(str(e), warning=True)
|
|
289
|
+
return None
|
|
290
|
+
if t is None:
|
|
291
|
+
return None
|
|
292
|
+
text, names = brief(agent, why, failure)
|
|
293
|
+
if not ask_user(agent, t, why, text, names, price):
|
|
294
|
+
return None
|
|
295
|
+
started = time.monotonic()
|
|
296
|
+
try:
|
|
297
|
+
with agent.console.status(f"Asking {t.label} for help…", spinner="dots"):
|
|
298
|
+
advice, usage = advise(agent, t, text)
|
|
299
|
+
except EscalateError as e:
|
|
300
|
+
agent.say(f"Asking {t.model} failed: {e}", warning=True)
|
|
301
|
+
return None
|
|
302
|
+
seconds = time.monotonic() - started
|
|
303
|
+
cost = ""
|
|
304
|
+
if t.cloud and price:
|
|
305
|
+
cost = f", ${usage['in'] * price[0] / 1e6 + usage['out'] * price[1] / 1e6:.3f}"
|
|
306
|
+
agent.escalations.append(
|
|
307
|
+
{"model": t.label, "why": why, "tokens_in": usage["in"], "tokens_out": usage["out"], "seconds": round(seconds)}
|
|
308
|
+
)
|
|
309
|
+
agent.say(
|
|
310
|
+
f"{t.model} answered in {seconds:.0f}s ({format_k(usage['in'])} tokens in, {format_k(usage['out'])} out"
|
|
311
|
+
f"{cost}). Its advice goes to the model."
|
|
312
|
+
)
|
|
313
|
+
first = next((line.strip() for line in advice.splitlines() if line.strip()), "")
|
|
314
|
+
agent.console.print(Text(f" ⎿ {first[:200]}", style="dim"))
|
|
315
|
+
return ADVICE.format(why=why, model=t.label, advice=advice)
|
lcode/repl.py
CHANGED
|
@@ -27,7 +27,19 @@ from rich.rule import Rule
|
|
|
27
27
|
from rich.table import Table
|
|
28
28
|
from rich.text import Text
|
|
29
29
|
|
|
30
|
-
from lcode import
|
|
30
|
+
from lcode import (
|
|
31
|
+
__version__,
|
|
32
|
+
backends,
|
|
33
|
+
catalog,
|
|
34
|
+
clipboard,
|
|
35
|
+
escalate,
|
|
36
|
+
extensions,
|
|
37
|
+
gitflow,
|
|
38
|
+
limits,
|
|
39
|
+
residency,
|
|
40
|
+
sessions,
|
|
41
|
+
web,
|
|
42
|
+
)
|
|
31
43
|
from lcode import context as context_tools
|
|
32
44
|
from lcode import memory as memory_notes
|
|
33
45
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
@@ -65,6 +77,7 @@ COMMANDS = {
|
|
|
65
77
|
"/cd": "Change the working directory",
|
|
66
78
|
"/todos": "Show the current todo list",
|
|
67
79
|
"/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
|
|
80
|
+
"/escalate": "Ask the stronger model (escalate_model) for advice, with your OK: /escalate [what's wrong]",
|
|
68
81
|
"/jobs": "Background commands the model started: /jobs, /jobs stop <id>",
|
|
69
82
|
"/commit": "Commit the changes with a message in the repository's style, after you approve it",
|
|
70
83
|
"/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
|
|
@@ -910,6 +923,28 @@ def stop_steering(agent: Agent, reader: KeyReader) -> None:
|
|
|
910
923
|
agent.console.print(Text("✎ What you typed didn't reach the model; it's in the prompt below.", style="cyan"))
|
|
911
924
|
|
|
912
925
|
|
|
926
|
+
def escalate_command(agent: Agent, arg: str) -> None:
|
|
927
|
+
"""Ask the stronger model about the work so far, then let the model carry on with its advice."""
|
|
928
|
+
if agent.planning():
|
|
929
|
+
agent.console.print("[yellow]Leave plan mode first (Shift+Tab): the advice is for changing the code.[/]")
|
|
930
|
+
return
|
|
931
|
+
if not agent.can_escalate():
|
|
932
|
+
agent.console.print(
|
|
933
|
+
"[yellow]No stronger model is set.[/] Set one, for example:\n"
|
|
934
|
+
" lcode config set escalate_model anthropic/<model> (with ANTHROPIC_API_KEY in your environment)\n"
|
|
935
|
+
" lcode config set escalate_model ollama/qwen3.8-27b (a bigger local model)\n"
|
|
936
|
+
"See https://nasser1941.github.io/lcode/usage/#a-stronger-model-when-stuck"
|
|
937
|
+
)
|
|
938
|
+
return
|
|
939
|
+
if not context_tools.user_requests(agent.messages):
|
|
940
|
+
agent.console.print("[dim]Nothing to ask about yet: make a request first.[/]")
|
|
941
|
+
return
|
|
942
|
+
why = f"the user says: {arg}" if arg else "the user asked for a second opinion on the work so far"
|
|
943
|
+
advice = escalate.run(agent, why)
|
|
944
|
+
if advice:
|
|
945
|
+
run_safely(agent, f"{arg}\n\n{advice}" if arg else advice)
|
|
946
|
+
|
|
947
|
+
|
|
913
948
|
def session_start(agent: Agent) -> None:
|
|
914
949
|
"""session_start hooks: their output is shown, and with feedback = true also given to the model."""
|
|
915
950
|
for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
|
|
@@ -1037,6 +1072,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1037
1072
|
jobs_command(agent, arg)
|
|
1038
1073
|
elif cmd == "/init":
|
|
1039
1074
|
run_safely(agent, INIT_PROMPT)
|
|
1075
|
+
elif cmd == "/escalate":
|
|
1076
|
+
escalate_command(agent, arg)
|
|
1040
1077
|
elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
|
|
1041
1078
|
git_command(agent, cmd, arg)
|
|
1042
1079
|
elif cmd[1:] in agent.extensions().commands:
|
lcode/screenshots.py
CHANGED
|
@@ -786,23 +786,32 @@ def is_local(url: str) -> bool:
|
|
|
786
786
|
)
|
|
787
787
|
|
|
788
788
|
|
|
789
|
+
def answers(url: str) -> bool:
|
|
790
|
+
"""Whether something answers at the URL (an HTTP error is an answer: the page shows it)."""
|
|
791
|
+
if url.startswith("file:"):
|
|
792
|
+
return True
|
|
793
|
+
try:
|
|
794
|
+
with urllib.request.urlopen(url, timeout=2):
|
|
795
|
+
return True
|
|
796
|
+
except urllib.error.HTTPError:
|
|
797
|
+
return True
|
|
798
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
799
|
+
return False
|
|
800
|
+
|
|
801
|
+
|
|
789
802
|
def wait_for_server(url: str, proc: subprocess.Popen, timeout: int, log: Path, cancel=None) -> None:
|
|
790
803
|
deadline = time.time() + timeout
|
|
791
804
|
while time.time() < deadline:
|
|
792
805
|
if cancel is not None and cancel():
|
|
793
806
|
raise KeyboardInterrupt
|
|
807
|
+
if answers(url): # first: a command that hands off to a server and exits is fine too
|
|
808
|
+
return
|
|
794
809
|
if proc.poll() is not None:
|
|
795
810
|
raise ShotError(
|
|
796
811
|
f"the server command exited (exit code {proc.returncode}) before {url} answered"
|
|
797
812
|
+ (f"\nWhat it printed:\n{printed(log)}" if printed(log) else "")
|
|
798
813
|
)
|
|
799
|
-
|
|
800
|
-
with urllib.request.urlopen(url, timeout=2):
|
|
801
|
-
return
|
|
802
|
-
except urllib.error.HTTPError:
|
|
803
|
-
return # it answers, even if with an error: the page shows it
|
|
804
|
-
except (urllib.error.URLError, OSError, ValueError):
|
|
805
|
-
time.sleep(0.5)
|
|
814
|
+
time.sleep(0.5)
|
|
806
815
|
raise ShotError(
|
|
807
816
|
f"{url} didn't answer within {timeout}s of starting the server"
|
|
808
817
|
+ (f"\nWhat it printed:\n{printed(log)}" if printed(log) else "")
|
|
@@ -1060,14 +1069,17 @@ def take(
|
|
|
1060
1069
|
url = normalize_url(url, cwd)
|
|
1061
1070
|
server = None
|
|
1062
1071
|
log = tmp / "server.txt"
|
|
1072
|
+
running = bool(command) and answers(url) # a server is up already (one the model started, say)
|
|
1063
1073
|
try:
|
|
1064
|
-
if command:
|
|
1074
|
+
if command and not running:
|
|
1065
1075
|
server = run_program(command, cwd, dict(os.environ), log)
|
|
1066
1076
|
wait_for_server(url, server, SERVER_TIMEOUT, log, cancel)
|
|
1067
1077
|
shot = web(url, screen, wait, full_page, path, tmp)
|
|
1068
1078
|
finally:
|
|
1069
1079
|
stop(server)
|
|
1070
|
-
if
|
|
1080
|
+
if running:
|
|
1081
|
+
shot.report.append(f"({url} already answered, so `{command}` wasn't started: a server is running.)")
|
|
1082
|
+
elif command:
|
|
1071
1083
|
shot.output = printed(log)
|
|
1072
1084
|
return shot
|
|
1073
1085
|
if not command:
|
lcode/tools.py
CHANGED
|
@@ -191,7 +191,8 @@ SCHEMAS = [
|
|
|
191
191
|
"page), saves a screenshot in .lcode/screenshots/ and returns what it shows, with the sizes of windows and "
|
|
192
192
|
"widgets (Qt) or of the page, and the browser's console errors. Use it to check GUI and layout work instead "
|
|
193
193
|
"of guessing. Python Qt programs run offscreen; other desktop programs on a virtual display (Linux); web pages "
|
|
194
|
-
"in a headless browser. For a web app, give the command that starts its server and the url
|
|
194
|
+
"in a headless browser. For a web app, give the command that starts its server and the url (lcode starts and "
|
|
195
|
+
"stops it); if a server is already running, or for an .html file, give only the url.",
|
|
195
196
|
{
|
|
196
197
|
"command": {"type": "string", "description": "Command that starts the program, or the web server"},
|
|
197
198
|
"url": {"type": "string", "description": "Page to open: a URL, localhost:PORT/path or an .html file"},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=gk-TBYXXl_EmM4X0Si03ubfRw1OFZ-QjmkNP_Rj4jfc,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
7
|
-
lcode/backends.py,sha256=
|
|
8
|
-
lcode/bench.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=hLnVWTzXjqgXMn5JgkR-VXPZj4GDri1lIU7xYB5YBBs,78221
|
|
6
|
+
lcode/api.py,sha256=FahtmNRjdgrVbImGFZhnKjMyeHHXGAMHv6-7NR9vm9I,16153
|
|
7
|
+
lcode/backends.py,sha256=Tq8B1XzZflb56N-j7qtzgIws4lodWXIyWJkIqB1wSO4,20916
|
|
8
|
+
lcode/bench.py,sha256=cBchBoDIqq5iA8Otevbf9BKF6mi6BzTEmmdxdG6MpCI,43078
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
13
|
lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
|
|
14
|
-
lcode/cli.py,sha256=
|
|
14
|
+
lcode/cli.py,sha256=nPvGnL0gZiUkbTniCoTAAyItiPEXZ-f6rHLYYUOKSiU,42151
|
|
15
15
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
16
16
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
17
|
-
lcode/config.py,sha256=
|
|
18
|
-
lcode/context.py,sha256=
|
|
17
|
+
lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
|
|
18
|
+
lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
|
|
19
|
+
lcode/escalate.py,sha256=6amspekybujPn0BfbVsNDYkB-I_pu6jJB45YJgFv0yc,14638
|
|
19
20
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
20
21
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
21
22
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
@@ -33,17 +34,17 @@ lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
|
33
34
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
34
35
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
35
36
|
lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
|
|
36
|
-
lcode/repl.py,sha256=
|
|
37
|
+
lcode/repl.py,sha256=zEXyfmIW-V8NqvfXmoqqeiMiM8Il4jb8bUFAA1SnF94,54628
|
|
37
38
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
38
39
|
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
39
40
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
40
41
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
41
|
-
lcode/screenshots.py,sha256=
|
|
42
|
+
lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
|
|
42
43
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
43
44
|
lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
|
|
44
45
|
lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
|
|
45
46
|
lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
|
|
46
|
-
lcode/tools.py,sha256=
|
|
47
|
+
lcode/tools.py,sha256=2aZtQ1dot9p6Y6pcympP8nyiCdlba8gMbCqREwPMtPI,67586
|
|
47
48
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
48
49
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
49
50
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -63,8 +64,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
63
64
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
64
65
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
65
66
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
66
|
-
lcode_cli-0.
|
|
67
|
-
lcode_cli-0.
|
|
68
|
-
lcode_cli-0.
|
|
69
|
-
lcode_cli-0.
|
|
70
|
-
lcode_cli-0.
|
|
67
|
+
lcode_cli-0.35.0.dist-info/METADATA,sha256=FanOqu5FsF_g3lvnsVZ9ciVjq11BOHYFeBYTFkqm4G4,13141
|
|
68
|
+
lcode_cli-0.35.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
69
|
+
lcode_cli-0.35.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
70
|
+
lcode_cli-0.35.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
71
|
+
lcode_cli-0.35.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|