lcode-cli 0.26.0__py3-none-any.whl → 0.28.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 +160 -24
- lcode/api.py +2 -0
- lcode/cli.py +15 -0
- lcode/config.py +16 -0
- lcode/context.py +81 -4
- lcode/repl.py +26 -5
- lcode/residency.py +167 -0
- lcode/tools.py +2 -3
- {lcode_cli-0.26.0.dist-info → lcode_cli-0.28.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.26.0.dist-info → lcode_cli-0.28.0.dist-info}/RECORD +14 -13
- {lcode_cli-0.26.0.dist-info → lcode_cli-0.28.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.26.0.dist-info → lcode_cli-0.28.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.26.0.dist-info → lcode_cli-0.28.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import contextlib
|
|
6
7
|
import datetime as dt
|
|
7
8
|
import json
|
|
8
9
|
import platform
|
|
@@ -23,7 +24,20 @@ from rich.markup import escape
|
|
|
23
24
|
from rich.panel import Panel
|
|
24
25
|
from rich.text import Text
|
|
25
26
|
|
|
26
|
-
from lcode import
|
|
27
|
+
from lcode import (
|
|
28
|
+
catalog,
|
|
29
|
+
codesearch,
|
|
30
|
+
extensions,
|
|
31
|
+
limits,
|
|
32
|
+
notify,
|
|
33
|
+
planning,
|
|
34
|
+
repomap,
|
|
35
|
+
residency,
|
|
36
|
+
sessions,
|
|
37
|
+
subagents,
|
|
38
|
+
vision,
|
|
39
|
+
web,
|
|
40
|
+
)
|
|
27
41
|
from lcode import context as context_tools
|
|
28
42
|
from lcode import memory as memory_notes
|
|
29
43
|
from lcode.checkpoints import Checkpoints
|
|
@@ -53,7 +67,12 @@ from lcode.tools import (
|
|
|
53
67
|
truncate,
|
|
54
68
|
)
|
|
55
69
|
|
|
56
|
-
GPU_MEMORY_ERRORS = (
|
|
70
|
+
GPU_MEMORY_ERRORS = (
|
|
71
|
+
"out of memory",
|
|
72
|
+
"illegal memory access",
|
|
73
|
+
"cudamalloc failed",
|
|
74
|
+
"requires more system memory", # Ollama: the model and its context don't fit in RAM either
|
|
75
|
+
)
|
|
57
76
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
58
77
|
MAX_STEPS_PER_TURN = 150
|
|
59
78
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
@@ -62,6 +81,7 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
|
|
|
62
81
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
63
82
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
64
83
|
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
84
|
+
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
65
85
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
66
86
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
67
87
|
# @path, @"path with spaces" or @'path'
|
|
@@ -171,6 +191,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
|
|
|
171
191
|
return work_tree_for(cwd)
|
|
172
192
|
|
|
173
193
|
|
|
194
|
+
def conversation_chars(messages: list[dict]) -> int:
|
|
195
|
+
return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
|
|
196
|
+
|
|
197
|
+
|
|
174
198
|
def call_arguments(call: dict) -> dict:
|
|
175
199
|
"""A tool call's arguments as a dict (some models send them as a JSON string)."""
|
|
176
200
|
args = call.get("function", {}).get("arguments") or {}
|
|
@@ -189,17 +213,22 @@ class Progress:
|
|
|
189
213
|
arrives, for example while the model writes a long file into a tool call.
|
|
190
214
|
"""
|
|
191
215
|
|
|
192
|
-
def __init__(self) -> None:
|
|
216
|
+
def __init__(self, phase: str = "Thinking") -> None:
|
|
193
217
|
self.started = self.last_chunk = time.time()
|
|
194
|
-
self.phase =
|
|
218
|
+
self.phase = phase
|
|
195
219
|
self.chars = 0
|
|
196
220
|
|
|
197
221
|
def update(self, phase: str, text: str) -> None:
|
|
198
222
|
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
199
223
|
|
|
224
|
+
def set_phase(self, phase: str) -> None:
|
|
225
|
+
"""What happens before the first token: loading the model, reading the prompt."""
|
|
226
|
+
self.phase, self.last_chunk = phase, time.time()
|
|
227
|
+
|
|
200
228
|
def __rich__(self) -> Text:
|
|
201
229
|
now = time.time()
|
|
202
|
-
|
|
230
|
+
streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
|
|
231
|
+
phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
|
|
203
232
|
return Text.from_markup(
|
|
204
233
|
f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
|
|
205
234
|
)
|
|
@@ -234,6 +263,7 @@ class Settings:
|
|
|
234
263
|
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
235
264
|
max_parallel_agents: int = 1
|
|
236
265
|
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
266
|
+
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
237
267
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
238
268
|
|
|
239
269
|
|
|
@@ -270,6 +300,7 @@ class Agent:
|
|
|
270
300
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
271
301
|
self.plan = "" # the plan the user approved; kept through compaction
|
|
272
302
|
self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
|
|
303
|
+
self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
|
|
273
304
|
self._extensions: tuple[Path, extensions.Extensions] | None = None
|
|
274
305
|
self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
|
|
275
306
|
# Set on subagents (see lcode.subagents):
|
|
@@ -286,6 +317,10 @@ class Agent:
|
|
|
286
317
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
287
318
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
288
319
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
320
|
+
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
321
|
+
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
322
|
+
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
323
|
+
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
289
324
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
290
325
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
291
326
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -463,12 +498,33 @@ class Agent:
|
|
|
463
498
|
image = vision.read_image(path)
|
|
464
499
|
# The session's own model keeps its settings, so Ollama doesn't reload it.
|
|
465
500
|
options = self.options() if model == self.settings.model else None
|
|
466
|
-
|
|
467
|
-
with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
|
|
501
|
+
with self.vision_swap(model, path.name):
|
|
468
502
|
return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
|
|
469
503
|
except vision.VisionError as e:
|
|
470
504
|
raise ToolError(str(e)) from e
|
|
471
505
|
|
|
506
|
+
@contextlib.contextmanager
|
|
507
|
+
def vision_swap(self, model: str, what: str):
|
|
508
|
+
"""Look at an image with `model`, saying when it has to load and when the main model made room for it."""
|
|
509
|
+
main = self.settings.model
|
|
510
|
+
own = model == main
|
|
511
|
+
main_was_loaded = not own and residency.placement(self.ollama, main) is not None
|
|
512
|
+
if own or residency.placement(self.ollama, model) is not None:
|
|
513
|
+
text = f"Looking at {what} with {model}…"
|
|
514
|
+
else:
|
|
515
|
+
text = f"Loading the vision model {model} to look at {what}" + (
|
|
516
|
+
f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
|
|
517
|
+
)
|
|
518
|
+
with self.console.status(text, spinner="dots"):
|
|
519
|
+
yield
|
|
520
|
+
if main_was_loaded and residency.placement(self.ollama, main) is None:
|
|
521
|
+
self.made_room_for = f"the vision model {model}"
|
|
522
|
+
self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
|
|
523
|
+
|
|
524
|
+
def say(self, text: str, warning: bool = False) -> None:
|
|
525
|
+
"""One line about what happens under the hood (loading, memory), not about the work itself."""
|
|
526
|
+
self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
|
|
527
|
+
|
|
472
528
|
def describe_image_data(self, data: str, mime: str) -> str:
|
|
473
529
|
"""For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
|
|
474
530
|
model = self.vision_model()
|
|
@@ -476,7 +532,7 @@ class Agent:
|
|
|
476
532
|
return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
|
|
477
533
|
options = self.options() if model == self.settings.model else None
|
|
478
534
|
try:
|
|
479
|
-
with self.
|
|
535
|
+
with self.vision_swap(model, f"the {mime} image"):
|
|
480
536
|
text = vision.describe(
|
|
481
537
|
self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
|
|
482
538
|
)
|
|
@@ -514,6 +570,7 @@ class Agent:
|
|
|
514
570
|
return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
|
|
515
571
|
|
|
516
572
|
def reset(self) -> None:
|
|
573
|
+
self.summarized_size = 0
|
|
517
574
|
self.messages = [{"role": "system", "content": self.system_prompt()}]
|
|
518
575
|
self._mcp_prompt = ""
|
|
519
576
|
self.tools.read_mtimes.clear()
|
|
@@ -621,9 +678,11 @@ class Agent:
|
|
|
621
678
|
if not started and batch and batch > SAFE_NUM_BATCH:
|
|
622
679
|
# Larger batches read prompts faster but need extra VRAM that isn't always free.
|
|
623
680
|
self.settings.num_batch = SAFE_NUM_BATCH
|
|
624
|
-
self.
|
|
625
|
-
f"
|
|
626
|
-
f"{SAFE_NUM_BATCH}
|
|
681
|
+
self.say(
|
|
682
|
+
f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
|
|
683
|
+
f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
|
|
684
|
+
f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
|
|
685
|
+
warning=True,
|
|
627
686
|
)
|
|
628
687
|
yield from self.chat(messages, tools, think)
|
|
629
688
|
return
|
|
@@ -633,16 +692,19 @@ class Agent:
|
|
|
633
692
|
smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
|
|
634
693
|
self.settings.context = smaller
|
|
635
694
|
limits.record(self.settings.model, smaller)
|
|
636
|
-
self.
|
|
637
|
-
f"
|
|
638
|
-
|
|
639
|
-
"time on this machine.
|
|
695
|
+
self.say(
|
|
696
|
+
f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
|
|
697
|
+
"context needs its own memory next to the model. Reloading it with "
|
|
698
|
+
f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
|
|
699
|
+
warning=True,
|
|
640
700
|
)
|
|
641
701
|
yield from self.chat(messages, tools, think)
|
|
642
702
|
return
|
|
643
703
|
raise OllamaError(
|
|
644
|
-
f"{e}\
|
|
645
|
-
"
|
|
704
|
+
f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
|
|
705
|
+
"model, a game, an image generator), use a smaller context (/ctx "
|
|
706
|
+
f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
|
|
707
|
+
"model (/models)."
|
|
646
708
|
) from e
|
|
647
709
|
raise
|
|
648
710
|
|
|
@@ -656,6 +718,7 @@ class Agent:
|
|
|
656
718
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
657
719
|
md = MarkdownStreamer(self.console)
|
|
658
720
|
progress = Progress()
|
|
721
|
+
watch = self.before_request(progress)
|
|
659
722
|
status = None
|
|
660
723
|
printed_thinking = False
|
|
661
724
|
|
|
@@ -677,6 +740,9 @@ class Agent:
|
|
|
677
740
|
if self.cancel is not None and self.cancel.is_set():
|
|
678
741
|
raise KeyboardInterrupt
|
|
679
742
|
msg = chunk.get("message", {})
|
|
743
|
+
if watch is not None:
|
|
744
|
+
self.after_load(watch)
|
|
745
|
+
watch = None
|
|
680
746
|
if msg.get("thinking"):
|
|
681
747
|
thinking += msg["thinking"]
|
|
682
748
|
if self.on_delta:
|
|
@@ -711,6 +777,8 @@ class Agent:
|
|
|
711
777
|
raise
|
|
712
778
|
finally:
|
|
713
779
|
stop_spinner()
|
|
780
|
+
if watch is not None:
|
|
781
|
+
watch.stop()
|
|
714
782
|
if printed_thinking:
|
|
715
783
|
self.console.print()
|
|
716
784
|
md.flush()
|
|
@@ -729,10 +797,49 @@ class Agent:
|
|
|
729
797
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
730
798
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
731
799
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
+
self.sent_chars = conversation_chars(self.messages)
|
|
732
801
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
733
802
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
734
803
|
return message
|
|
735
804
|
|
|
805
|
+
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
806
|
+
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
807
|
+
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
808
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama":
|
|
809
|
+
return None
|
|
810
|
+
model, context = self.settings.model, self.settings.context
|
|
811
|
+
fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
|
|
812
|
+
where = residency.placement(self.ollama, model)
|
|
813
|
+
if where is not None and (not where.context or where.context == context):
|
|
814
|
+
if fresh >= READING_NOTE_TOKENS:
|
|
815
|
+
progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
|
|
816
|
+
return None
|
|
817
|
+
total = conversation_chars(self.messages) // 3
|
|
818
|
+
if where is not None:
|
|
819
|
+
phase = f"Reloading {model} with a {format_tokens(context)} context"
|
|
820
|
+
elif self.made_room_for:
|
|
821
|
+
phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
|
|
822
|
+
else:
|
|
823
|
+
phase = f"Loading {model} into memory"
|
|
824
|
+
progress.set_phase(phase)
|
|
825
|
+
watch = residency.LoadWatch(
|
|
826
|
+
self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
|
|
827
|
+
)
|
|
828
|
+
watch.reload = where is not None or bool(self.made_room_for)
|
|
829
|
+
return watch
|
|
830
|
+
|
|
831
|
+
def after_load(self, watch: residency.LoadWatch) -> None:
|
|
832
|
+
"""The first token after a load: one line on how long it took and how the model fits."""
|
|
833
|
+
seconds = watch.stop()
|
|
834
|
+
self.made_room_for = ""
|
|
835
|
+
self.load_state = ""
|
|
836
|
+
where = residency.placement(self.ollama, self.settings.model)
|
|
837
|
+
dense = residency.is_moe(self.ollama, self.settings.model) is False
|
|
838
|
+
report = residency.load_report(
|
|
839
|
+
self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
|
|
840
|
+
)
|
|
841
|
+
self.say(report, warning=report.endswith(("closed.", "slow.")))
|
|
842
|
+
|
|
736
843
|
def describe_call(self, name: str, args: dict) -> str:
|
|
737
844
|
if name in ("read_file", "write_file", "edit_file"):
|
|
738
845
|
path = self.tools.rel(self.tools.resolve(args.get("path", "")))
|
|
@@ -1046,23 +1153,46 @@ class Agent:
|
|
|
1046
1153
|
return text + ("\n\n" + "\n\n".join(attached) if attached else "")
|
|
1047
1154
|
|
|
1048
1155
|
# -- context management
|
|
1156
|
+
def compact_limit(self) -> int:
|
|
1157
|
+
"""The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
|
|
1158
|
+
Small models lose track in long contexts well before a large window is full."""
|
|
1159
|
+
limit = int(AUTO_COMPACT_RATIO * self.settings.context)
|
|
1160
|
+
if self.settings.compact_at > 0:
|
|
1161
|
+
limit = min(limit, self.settings.compact_at)
|
|
1162
|
+
return limit
|
|
1163
|
+
|
|
1049
1164
|
def maybe_compact(self) -> None:
|
|
1050
|
-
|
|
1165
|
+
limit = self.compact_limit()
|
|
1166
|
+
if self.ctx_used <= limit:
|
|
1051
1167
|
return
|
|
1168
|
+
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
1052
1169
|
if self.settings.prune:
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1170
|
+
removed: list[str] = [] # files and pages whose only copy goes
|
|
1171
|
+
saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
|
|
1172
|
+
if self.ctx_used - saved // 3 > enough:
|
|
1173
|
+
saved += context_tools.prune(
|
|
1174
|
+
self.messages, keep=1, removed=removed
|
|
1175
|
+
) # not enough: before the last request
|
|
1176
|
+
context_tools.note_removed(self.messages, removed)
|
|
1056
1177
|
if saved:
|
|
1178
|
+
self.sent_chars = 0 # the conversation changed: the model reads it again
|
|
1057
1179
|
self.pruned += 1
|
|
1058
1180
|
self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
|
|
1059
1181
|
self.console.print(
|
|
1060
1182
|
f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
|
|
1061
1183
|
"make room.[/]"
|
|
1062
1184
|
)
|
|
1063
|
-
if self.ctx_used <=
|
|
1185
|
+
if self.ctx_used <= enough:
|
|
1064
1186
|
return
|
|
1065
|
-
self.
|
|
1187
|
+
window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
|
|
1188
|
+
if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
|
|
1189
|
+
return # summarized not long ago: summarizing again so soon would only lose more
|
|
1190
|
+
if limit < AUTO_COMPACT_RATIO * self.settings.context:
|
|
1191
|
+
self.console.print(
|
|
1192
|
+
f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
|
|
1193
|
+
)
|
|
1194
|
+
else:
|
|
1195
|
+
self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
|
|
1066
1196
|
self.compacted += 1
|
|
1067
1197
|
self.compact()
|
|
1068
1198
|
|
|
@@ -1083,9 +1213,13 @@ class Agent:
|
|
|
1083
1213
|
with self.console.status("[cyan]Compacting…[/]"):
|
|
1084
1214
|
for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
|
|
1085
1215
|
summary += chunk.get("message", {}).get("content", "")
|
|
1216
|
+
requests = context_tools.user_requests(self.messages)
|
|
1086
1217
|
self.messages = [
|
|
1087
1218
|
{"role": "system", "content": self.system_prompt()},
|
|
1088
|
-
{
|
|
1219
|
+
{
|
|
1220
|
+
"role": "user",
|
|
1221
|
+
"content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
|
|
1222
|
+
},
|
|
1089
1223
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
1090
1224
|
]
|
|
1091
1225
|
if self.plan: # the approved plan stays word for word
|
|
@@ -1097,6 +1231,8 @@ class Agent:
|
|
|
1097
1231
|
)
|
|
1098
1232
|
self.skills_loaded.clear()
|
|
1099
1233
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1234
|
+
self.summarized_size = self.ctx_used
|
|
1235
|
+
self.sent_chars = 0
|
|
1100
1236
|
self.reflected = len(self.messages)
|
|
1101
1237
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
1102
1238
|
|
lcode/api.py
CHANGED
|
@@ -196,8 +196,10 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
196
196
|
notify_after=cfg["notify_after"],
|
|
197
197
|
secret_check=cfg["secret_check"],
|
|
198
198
|
repeat_limit=cfg["repeat_limit"],
|
|
199
|
+
compact_at=cfg["compact_at"],
|
|
199
200
|
)
|
|
200
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
|
+
agent.hardware = hw
|
|
201
203
|
agent.interactive = options.interactive
|
|
202
204
|
if options.max_steps:
|
|
203
205
|
agent.max_steps = options.max_steps
|
lcode/cli.py
CHANGED
|
@@ -579,6 +579,7 @@ def cmd_bench(args) -> None:
|
|
|
579
579
|
web="off",
|
|
580
580
|
checkpoints=False,
|
|
581
581
|
prune=not args.no_prune,
|
|
582
|
+
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
582
583
|
repo_map=args.repo_map,
|
|
583
584
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
584
585
|
)
|
|
@@ -678,6 +679,14 @@ def cmd_chat(args) -> None:
|
|
|
678
679
|
sys.exit(exit_code)
|
|
679
680
|
|
|
680
681
|
|
|
682
|
+
def compact_tokens(value: str) -> int:
|
|
683
|
+
"""`--compact-at`: a token count such as 64k, or 0."""
|
|
684
|
+
try:
|
|
685
|
+
return config.parse_compact_at(value)
|
|
686
|
+
except ConfigError as e:
|
|
687
|
+
raise argparse.ArgumentTypeError(str(e)) from e
|
|
688
|
+
|
|
689
|
+
|
|
681
690
|
def build_parser() -> argparse.ArgumentParser:
|
|
682
691
|
parser = argparse.ArgumentParser(
|
|
683
692
|
prog="lcode",
|
|
@@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
765
774
|
p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
|
|
766
775
|
p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
|
|
767
776
|
p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
|
|
777
|
+
p.add_argument(
|
|
778
|
+
"--compact-at",
|
|
779
|
+
type=compact_tokens,
|
|
780
|
+
metavar="TOKENS",
|
|
781
|
+
help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)",
|
|
782
|
+
)
|
|
768
783
|
subs["bench"] = p
|
|
769
784
|
p = argparse.ArgumentParser(
|
|
770
785
|
prog="lcode index",
|
lcode/config.py
CHANGED
|
@@ -61,6 +61,7 @@ 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
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
63
63
|
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
64
|
+
"compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
|
|
64
65
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
65
66
|
}
|
|
66
67
|
ENV_OVERRIDES = {
|
|
@@ -81,6 +82,19 @@ class ConfigError(ValueError):
|
|
|
81
82
|
pass
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_compact_at(value: str | int) -> int:
|
|
89
|
+
"""compact_at: a token count like parse_context's, at least 16K, or 0 (off)."""
|
|
90
|
+
if str(value).strip().lower() in ("0", "off", "false", "none"):
|
|
91
|
+
return 0
|
|
92
|
+
tokens = parse_context(value)
|
|
93
|
+
if tokens < MIN_COMPACT_AT:
|
|
94
|
+
raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off")
|
|
95
|
+
return tokens
|
|
96
|
+
|
|
97
|
+
|
|
84
98
|
def parse_context(value: str | int) -> int:
|
|
85
99
|
"""Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024)."""
|
|
86
100
|
if isinstance(value, int):
|
|
@@ -118,6 +132,8 @@ def coerce(key: str, value: object) -> object:
|
|
|
118
132
|
_, typ, _ = SETTINGS[key]
|
|
119
133
|
if key == "context":
|
|
120
134
|
return parse_context(value) # type: ignore[arg-type]
|
|
135
|
+
if key == "compact_at":
|
|
136
|
+
return parse_compact_at(value) # type: ignore[arg-type]
|
|
121
137
|
if key in ("ollama_host", "searxng_url"):
|
|
122
138
|
return normalize_host(str(value))
|
|
123
139
|
if key == "base_url":
|
lcode/context.py
CHANGED
|
@@ -29,6 +29,10 @@ if TYPE_CHECKING:
|
|
|
29
29
|
KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
|
|
30
30
|
MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
|
|
31
31
|
PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
|
|
32
|
+
GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again
|
|
33
|
+
"earlier; its content was removed to save context and isn't in this conversation any more. Read it again "
|
|
34
|
+
"before answering anything about it.]"
|
|
35
|
+
)
|
|
32
36
|
KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
|
|
33
37
|
ERROR_LINE = re.compile(
|
|
34
38
|
r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
|
|
@@ -37,6 +41,56 @@ MAX_ERROR_LINES = 40
|
|
|
37
41
|
OUTPUT_DAYS = 7 # saved full outputs are deleted after this
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
# ----------------------------------------------------------------------------- the user's requests
|
|
45
|
+
|
|
46
|
+
REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]"
|
|
47
|
+
MAX_REQUESTS = 8 # the most recent ones are kept through a summary
|
|
48
|
+
MAX_REQUEST_CHARS = 1500
|
|
49
|
+
# What lcode appends to the user's words: attached files, images and folders, and its own notes.
|
|
50
|
+
ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def request_text(content: str) -> str:
|
|
54
|
+
"""The user's own words in a message, without what lcode attached to it."""
|
|
55
|
+
cut = ATTACHED.search(content)
|
|
56
|
+
return (content[: cut.start()] if cut else content).strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_requests(messages: list[dict]) -> list[str]:
|
|
60
|
+
"""What the user asked in the conversation, oldest first, including the requests an earlier summary kept."""
|
|
61
|
+
requests: list[str] = []
|
|
62
|
+
for message in messages:
|
|
63
|
+
content = message.get("content") or ""
|
|
64
|
+
if message.get("role") != "user":
|
|
65
|
+
continue
|
|
66
|
+
if content.startswith(SUMMARY_PREFIX):
|
|
67
|
+
start = content.find(REQUESTS_HEADER)
|
|
68
|
+
if start >= 0:
|
|
69
|
+
rest = content[start + len(REQUESTS_HEADER) :]
|
|
70
|
+
try:
|
|
71
|
+
kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :])
|
|
72
|
+
requests += [str(r) for r in kept]
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
continue
|
|
76
|
+
if content.startswith("[lcode]"):
|
|
77
|
+
continue # lcode's own nudges
|
|
78
|
+
text = request_text(content)
|
|
79
|
+
if text:
|
|
80
|
+
requests.append(text)
|
|
81
|
+
return requests
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def requests_block(requests: list[str]) -> str:
|
|
85
|
+
"""The user's latest requests, word for word, to keep the goal through a summary."""
|
|
86
|
+
if not requests:
|
|
87
|
+
return ""
|
|
88
|
+
kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests]
|
|
89
|
+
older = len(kept) - MAX_REQUESTS
|
|
90
|
+
note = f" (and {older} earlier, in the summary)" if older > 0 else ""
|
|
91
|
+
return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}"
|
|
92
|
+
|
|
93
|
+
|
|
40
94
|
# ----------------------------------------------------------------------------- pruning
|
|
41
95
|
|
|
42
96
|
|
|
@@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str:
|
|
|
63
117
|
if args.get("offset") or args.get("limit"):
|
|
64
118
|
start = int(args.get("offset") or 1)
|
|
65
119
|
span = f" from line {start}"
|
|
66
|
-
return f"{PRUNED}read {args.get('path', 'a file')}{span}
|
|
120
|
+
return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}"
|
|
67
121
|
if name == "bash":
|
|
68
122
|
command = " ".join(str(args.get("command", "")).split())
|
|
69
123
|
command = command if len(command) <= 80 else command[:79] + "…"
|
|
@@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
|
92
146
|
return requests[-keep] if len(requests) >= keep else 0
|
|
93
147
|
|
|
94
148
|
|
|
95
|
-
def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
96
|
-
"""Replace stale tool results with stubs, in place. Returns the number of characters saved
|
|
149
|
+
def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int:
|
|
150
|
+
"""Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and
|
|
151
|
+
pages whose only copy went are added to `removed`."""
|
|
97
152
|
calls = calls_for_results(messages)
|
|
98
153
|
edge = boundary(messages, keep)
|
|
99
154
|
# A read is superseded by a later read of the whole file, or of the same lines: not by a later
|
|
@@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
|
|
|
118
173
|
continue
|
|
119
174
|
new = stub(name, args, content)
|
|
120
175
|
if reread:
|
|
121
|
-
new = new.replace(
|
|
176
|
+
new = new.replace(
|
|
177
|
+
GONE, "earlier (read again later); removed to save context. Read it again if you need it.]"
|
|
178
|
+
)
|
|
179
|
+
elif removed is not None and name in ("read_file", "web_fetch"):
|
|
180
|
+
what = str(args.get("path") or args.get("url") or "")
|
|
181
|
+
if what and what not in removed:
|
|
182
|
+
removed.append(what)
|
|
122
183
|
messages[i]["content"] = new
|
|
123
184
|
saved += len(content) - len(new)
|
|
124
185
|
return saved
|
|
125
186
|
|
|
126
187
|
|
|
188
|
+
def note_removed(messages: list[dict], removed: list[str]) -> None:
|
|
189
|
+
"""Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
|
|
190
|
+
conversation is easy to overlook, and models then answer from memory instead of reading again."""
|
|
191
|
+
if not removed:
|
|
192
|
+
return
|
|
193
|
+
for message in reversed(messages):
|
|
194
|
+
content = str(message.get("content", ""))
|
|
195
|
+
if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
|
|
196
|
+
names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "")
|
|
197
|
+
message["content"] = (
|
|
198
|
+
f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in "
|
|
199
|
+
"this conversation any more: read it again before answering anything about it, don't answer from memory."
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
|
|
127
204
|
# ----------------------------------------------------------------------------- long output
|
|
128
205
|
|
|
129
206
|
|
lcode/repl.py
CHANGED
|
@@ -27,7 +27,7 @@ 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 __version__, backends, catalog, clipboard, extensions, gitflow, limits, sessions, web
|
|
30
|
+
from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, residency, sessions, web
|
|
31
31
|
from lcode import context as context_tools
|
|
32
32
|
from lcode import memory as memory_notes
|
|
33
33
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
@@ -153,6 +153,7 @@ def build_session(agent: Agent) -> PromptSession:
|
|
|
153
153
|
f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
|
|
154
154
|
f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
|
|
155
155
|
f"think {'on' if s.think else 'off'} · {html.escape(agent.cwd.name)}/"
|
|
156
|
+
+ (" · <ansiyellow>loading the model into memory…</ansiyellow>" if agent.load_state == "loading" else "")
|
|
156
157
|
+ (f" · <b>{html.escape(agent.session_name)}</b>" if agent.session_name else "")
|
|
157
158
|
)
|
|
158
159
|
|
|
@@ -242,7 +243,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
242
243
|
c.print(
|
|
243
244
|
f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
|
|
244
245
|
f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
|
|
245
|
-
f"{'' if len(agent.messages) == 1 else 's'};
|
|
246
|
+
f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} "
|
|
247
|
+
+ ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).")
|
|
246
248
|
)
|
|
247
249
|
if agent.mcp and agent.mcp.ready():
|
|
248
250
|
how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
|
|
@@ -934,8 +936,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
934
936
|
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
935
937
|
elif backends.is_openai(agent.ollama):
|
|
936
938
|
s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
|
|
939
|
+
agent._vision = False # the model that looks at images goes with the model: decide again
|
|
937
940
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
938
941
|
c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
|
|
942
|
+
warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
|
|
943
|
+
if warning:
|
|
944
|
+
agent.say(warning, warning=True)
|
|
939
945
|
elif cmd == "/think":
|
|
940
946
|
s.think = (arg == "on") if arg in ("on", "off") else not s.think
|
|
941
947
|
c.print(f"Reasoning {'on' if s.think else 'off'}.")
|
|
@@ -1109,9 +1115,12 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1109
1115
|
if prompt:
|
|
1110
1116
|
run_safely(agent, prompt)
|
|
1111
1117
|
return
|
|
1112
|
-
preload(agent)
|
|
1113
1118
|
banner(agent)
|
|
1119
|
+
warning = residency.dense_warning(agent.ollama, agent.settings.model, agent.settings.context, hardware)
|
|
1120
|
+
if warning:
|
|
1121
|
+
agent.say(warning, warning=True)
|
|
1114
1122
|
session = build_session(agent)
|
|
1123
|
+
preload(agent, session)
|
|
1115
1124
|
while True:
|
|
1116
1125
|
try:
|
|
1117
1126
|
agent.console.print()
|
|
@@ -1134,13 +1143,25 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1134
1143
|
agent.console.print("[dim]Bye.[/]")
|
|
1135
1144
|
|
|
1136
1145
|
|
|
1137
|
-
def preload(agent: Agent) -> None:
|
|
1138
|
-
"""Start loading the model in the background so the first answer comes sooner."""
|
|
1146
|
+
def preload(agent: Agent, session: PromptSession | None = None) -> None:
|
|
1147
|
+
"""Start loading the model in the background so the first answer comes sooner; the status bar says so."""
|
|
1148
|
+
where = residency.placement(agent.ollama, agent.settings.model)
|
|
1149
|
+
if where is not None and (not where.context or where.context == agent.settings.context):
|
|
1150
|
+
return # already in memory
|
|
1139
1151
|
|
|
1140
1152
|
def load() -> None:
|
|
1153
|
+
agent.load_state = "loading"
|
|
1154
|
+
refresh()
|
|
1141
1155
|
try:
|
|
1142
1156
|
agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
|
|
1143
1157
|
except OllamaError:
|
|
1144
1158
|
pass
|
|
1159
|
+
finally:
|
|
1160
|
+
agent.load_state = ""
|
|
1161
|
+
refresh()
|
|
1162
|
+
|
|
1163
|
+
def refresh() -> None:
|
|
1164
|
+
if session is not None and session.app.is_running:
|
|
1165
|
+
session.app.invalidate()
|
|
1145
1166
|
|
|
1146
1167
|
threading.Thread(target=load, daemon=True).start()
|
lcode/residency.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Where the model is, in words: loading it, how it fits, making room for another one.
|
|
2
|
+
|
|
3
|
+
A local model has to be in memory before it answers. On a small GPU loading takes a while, how much of
|
|
4
|
+
the model fits on the GPU decides how fast it runs, and a second model (the one that looks at images)
|
|
5
|
+
can push the first one out. lcode says so, in a line, when it happens: nothing is printed while the
|
|
6
|
+
model simply stays loaded.
|
|
7
|
+
|
|
8
|
+
Only Ollama is asked: other servers load and place their models themselves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from lcode import catalog
|
|
18
|
+
from lcode.ollama import OllamaError
|
|
19
|
+
|
|
20
|
+
GB = 1e9
|
|
21
|
+
SPLIT_WARNING = 0.95 # a dense model with less than this on the GPU runs much slower
|
|
22
|
+
SHORTFALL = 0.25 # this much less on the GPU than expected: something else is using GPU memory
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Placement:
|
|
27
|
+
"""A loaded model, from Ollama's /api/ps."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
size: float # bytes in memory
|
|
31
|
+
vram: float # bytes of it on the GPU
|
|
32
|
+
context: int = 0 # the context window it was loaded with
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def gpu_share(self) -> float:
|
|
36
|
+
return self.vram / self.size if self.size else 0.0
|
|
37
|
+
|
|
38
|
+
def describe(self) -> str:
|
|
39
|
+
"""'22.3 GB, 44% on the GPU and the rest in system RAM'"""
|
|
40
|
+
size = f"{self.size / GB:.1f} GB"
|
|
41
|
+
share = self.gpu_share
|
|
42
|
+
if share >= 0.995:
|
|
43
|
+
return f"{size}, all on the GPU"
|
|
44
|
+
if share <= 0.005:
|
|
45
|
+
return f"{size}, all in system RAM (on the CPU)"
|
|
46
|
+
return f"{size}, {share:.0%} on the GPU and the rest in system RAM"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def same_model(a: str, b: str) -> bool:
|
|
50
|
+
return a.removesuffix(":latest") == b.removesuffix(":latest")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def loaded(ollama, model: str | None = None) -> list[Placement]:
|
|
54
|
+
"""The models Ollama has in memory (only `model`, if given). Empty when it can't be asked."""
|
|
55
|
+
if getattr(ollama, "kind", "ollama") != "ollama":
|
|
56
|
+
return []
|
|
57
|
+
try:
|
|
58
|
+
entries = ollama.running()
|
|
59
|
+
except (OllamaError, OSError):
|
|
60
|
+
return []
|
|
61
|
+
found = []
|
|
62
|
+
for entry in entries or []:
|
|
63
|
+
name = entry.get("name") or entry.get("model") or ""
|
|
64
|
+
if model is None or same_model(name, model):
|
|
65
|
+
size = float(entry.get("size") or 0)
|
|
66
|
+
vram = float(entry.get("size_vram") if entry.get("size_vram") is not None else size)
|
|
67
|
+
found.append(Placement(name.removesuffix(":latest"), size, vram, int(entry.get("context_length") or 0)))
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def placement(ollama, model: str) -> Placement | None:
|
|
72
|
+
found = loaded(ollama, model)
|
|
73
|
+
return found[0] if found else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_moe(ollama, model: str) -> bool | None:
|
|
77
|
+
"""Whether the model is a Mixture-of-Experts (None: unknown)."""
|
|
78
|
+
spec = catalog.find(model)
|
|
79
|
+
if spec:
|
|
80
|
+
return spec.moe
|
|
81
|
+
try:
|
|
82
|
+
info = (ollama.show(model) or {}).get("model_info") or {}
|
|
83
|
+
except (OllamaError, OSError):
|
|
84
|
+
return None
|
|
85
|
+
if not info:
|
|
86
|
+
return None
|
|
87
|
+
return any(key.endswith(".expert_count") and value for key, value in info.items())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def expected_share(model: str, context: int, hardware) -> float | None:
|
|
91
|
+
"""How much of the model should fit on the GPU, from the catalog's estimate."""
|
|
92
|
+
spec = catalog.find(model)
|
|
93
|
+
if not spec or hardware is None or not hardware.gpu or hardware.unified:
|
|
94
|
+
return None
|
|
95
|
+
return min(1.0, hardware.vram_gib / spec.memory_gib(context))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def dense_warning(ollama, model: str, context: int, hardware) -> str:
|
|
99
|
+
"""For a dense model that won't fit on the GPU: what that means and what to do. Empty otherwise."""
|
|
100
|
+
if hardware is None or hardware.unified or is_moe(ollama, model) is not False:
|
|
101
|
+
return ""
|
|
102
|
+
spec = catalog.find(model)
|
|
103
|
+
params = f" {spec.params.split(' ')[0]}" if spec and spec.params else ""
|
|
104
|
+
if not hardware.gpu:
|
|
105
|
+
where = "There's no GPU here, so it runs on the CPU"
|
|
106
|
+
else:
|
|
107
|
+
share = expected_share(model, context, hardware)
|
|
108
|
+
if share is None or share >= SPLIT_WARNING:
|
|
109
|
+
return ""
|
|
110
|
+
where = (
|
|
111
|
+
f"Only about {share:.0%} of it fits in the {hardware.vram_gib:.0f} GB of GPU memory and the rest runs "
|
|
112
|
+
"on the CPU"
|
|
113
|
+
)
|
|
114
|
+
return (
|
|
115
|
+
f"{model} is a dense model: all{params} parameters work on every token. It's capable, but slow here. "
|
|
116
|
+
f"{where}, so expect a few tokens a second. For speed, pick a Mixture-of-Experts model such as "
|
|
117
|
+
"qwen3.6-35b (/model qwen3.6-35b), or a smaller context (/ctx 16k) to keep more of it on the GPU."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_report(
|
|
122
|
+
model: str, seconds: float, where: Placement | None, context: int, hardware, dense: bool, reload: bool = False
|
|
123
|
+
) -> str:
|
|
124
|
+
"""The line after a load: how long it took, how it fits, and a warning if it fits worse than it should."""
|
|
125
|
+
took = f" in {seconds:.0f}s" if seconds >= 1 else ""
|
|
126
|
+
line = f"{'Reloaded' if reload else 'Loaded'} {model}{took}" + (f": {where.describe()}." if where else ".")
|
|
127
|
+
if where is None:
|
|
128
|
+
return line
|
|
129
|
+
expected = expected_share(model, context, hardware)
|
|
130
|
+
if expected is not None and where.gpu_share < expected - SHORTFALL:
|
|
131
|
+
line += (
|
|
132
|
+
f" Less of it is on the GPU than usual ({expected:.0%}): another program or model is using GPU "
|
|
133
|
+
"memory, so it runs slower until that's closed."
|
|
134
|
+
)
|
|
135
|
+
elif dense and where.gpu_share < SPLIT_WARNING:
|
|
136
|
+
line += " It's a dense model split between GPU and CPU, so expect it to be slow."
|
|
137
|
+
return line
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class LoadWatch:
|
|
141
|
+
"""While a model loads: asks Ollama once a second, and once the model is in memory moves the status line
|
|
142
|
+
on to reading the prompt, which comes next and can take a while too."""
|
|
143
|
+
|
|
144
|
+
POLL = 1.0
|
|
145
|
+
|
|
146
|
+
def __init__(self, ollama, model: str, context: int, progress, reading: str) -> None:
|
|
147
|
+
self.ollama, self.model, self.context = ollama, model, context
|
|
148
|
+
self.progress, self.reading = progress, reading
|
|
149
|
+
self.started = time.monotonic()
|
|
150
|
+
self.loaded_after: float | None = None
|
|
151
|
+
self.reload = False # it was in memory before: unloaded for another model, or loaded with another context
|
|
152
|
+
self.stopped = threading.Event()
|
|
153
|
+
threading.Thread(target=self.run, daemon=True).start()
|
|
154
|
+
|
|
155
|
+
def run(self) -> None:
|
|
156
|
+
while not self.stopped.wait(self.POLL):
|
|
157
|
+
where = placement(self.ollama, self.model)
|
|
158
|
+
if where and (not where.context or not self.context or where.context == self.context):
|
|
159
|
+
self.loaded_after = time.monotonic() - self.started
|
|
160
|
+
if not self.stopped.is_set():
|
|
161
|
+
self.progress.set_phase(self.reading)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
def stop(self) -> float:
|
|
165
|
+
"""Seconds the load took (until the first token, when the end of the load went unseen)."""
|
|
166
|
+
self.stopped.set()
|
|
167
|
+
return self.loaded_after if self.loaded_after is not None else time.monotonic() - self.started
|
lcode/tools.py
CHANGED
|
@@ -614,9 +614,8 @@ class Toolbox:
|
|
|
614
614
|
arguments = {**arguments, "wait": True}
|
|
615
615
|
freed = self.agent.free_gpu()
|
|
616
616
|
if freed:
|
|
617
|
-
self.
|
|
618
|
-
|
|
619
|
-
)
|
|
617
|
+
self.agent.made_room_for = state.name
|
|
618
|
+
self.agent.say(f"Unloaded {', '.join(freed)} so {state.name} can use the GPU; it reloads afterwards.")
|
|
620
619
|
result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
|
|
621
620
|
if result.startswith("Error:"):
|
|
622
621
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.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,20 +1,20 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=OG44SXHF0F_qxUeC9BH5G84cellTu3wqLwxsfUfYdUU,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=HFdmGXK_3oHu57L15z2dmJsQTj_ddjRDq7JnxM1Qq34,63045
|
|
6
|
+
lcode/api.py,sha256=kYq7RghkNyf-A7qsZh6AeMzLoZbc9rTobsFRLtAcvD8,15480
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
8
|
lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
10
|
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
|
-
lcode/cli.py,sha256=
|
|
13
|
+
lcode/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
|
|
14
14
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
15
15
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
16
|
-
lcode/config.py,sha256=
|
|
17
|
-
lcode/context.py,sha256=
|
|
16
|
+
lcode/config.py,sha256=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
|
|
17
|
+
lcode/context.py,sha256=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
|
|
18
18
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
19
19
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
20
20
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
@@ -31,13 +31,14 @@ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
|
|
|
31
31
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
32
32
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
33
33
|
lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
|
|
34
|
-
lcode/repl.py,sha256=
|
|
34
|
+
lcode/repl.py,sha256=Et8Tf4COLJ9_FsXtjcOciah_33lLgUS5sl4QAer1m8Y,50652
|
|
35
35
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
36
|
+
lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
|
|
36
37
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
37
38
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
38
39
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
39
40
|
lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
|
|
40
|
-
lcode/tools.py,sha256=
|
|
41
|
+
lcode/tools.py,sha256=OFltvnRZlSUMtzNLEUF84RFoyKQkAFpb7_ZBVhVcVp4,48426
|
|
41
42
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
42
43
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
43
44
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -57,8 +58,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
57
58
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
58
59
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
59
60
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
60
|
-
lcode_cli-0.
|
|
61
|
-
lcode_cli-0.
|
|
62
|
-
lcode_cli-0.
|
|
63
|
-
lcode_cli-0.
|
|
64
|
-
lcode_cli-0.
|
|
61
|
+
lcode_cli-0.28.0.dist-info/METADATA,sha256=b-CcZWIZTz_P0M5H9xP1cwtQpICZpHlaGWdBRlSB52U,12620
|
|
62
|
+
lcode_cli-0.28.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
63
|
+
lcode_cli-0.28.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
64
|
+
lcode_cli-0.28.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
65
|
+
lcode_cli-0.28.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|