lcode-cli 0.27.0__tar.gz → 0.28.0__tar.gz
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_cli-0.27.0 → lcode_cli-0.28.0}/CHANGELOG.md +26 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/PKG-INFO +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/usage.md +27 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/agent.py +123 -17
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/api.py +1 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repl.py +24 -4
- lcode_cli-0.28.0/src/lcode/residency.py +167 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/tools.py +2 -3
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/conftest.py +6 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_agent.py +4 -3
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_mcp.py +2 -1
- lcode_cli-0.28.0/tests/test_residency.py +164 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_vision.py +5 -4
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.editorconfig +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.gitignore +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/AGENTS.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/LICENSE +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/README.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/SECURITY.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/action.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/agents.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/automation.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/bench.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/changelog.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/commands.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/configuration.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/contributing.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/editors.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/git.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/hooks.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/index.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/installation.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/lsp.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/mcp.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/memory.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/models.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/search.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/servers.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/README.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/install.sh +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/mkdocs.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/overrides/main.html +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/pyproject.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/cli.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_action.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_api.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_context.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_update.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_web.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.0}/uv.lock +0 -0
|
@@ -6,6 +6,30 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.28.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- lcode says what's happening under the hood, in a line, only when it happens:
|
|
14
|
+
- loading the model and then reading the conversation are named in the status line instead of
|
|
15
|
+
"Thinking";
|
|
16
|
+
- after a load, how long it took and how much of the model is on the GPU, with a warning when less
|
|
17
|
+
is than usual;
|
|
18
|
+
- at startup and on `/model`, a warning when a dense model won't fit on the GPU and will be slow,
|
|
19
|
+
with what to use instead;
|
|
20
|
+
- the vision model making the main model step aside, and the reload after it;
|
|
21
|
+
- an image tool taking the GPU;
|
|
22
|
+
- clearer explanations when Ollama runs out of memory.
|
|
23
|
+
|
|
24
|
+
The status bar shows when the model is still loading at startup. See
|
|
25
|
+
[What's happening under the hood](https://nasser1941.github.io/lcode/usage/#whats-happening-under-the-hood).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- After `/model`, images were still described by the vision model chosen for the previous model.
|
|
30
|
+
- The tests no longer write into your lcode state folder; one of them had left about a hundred
|
|
31
|
+
entries in `extensions-approved.json`.
|
|
32
|
+
|
|
9
33
|
## [0.27.0] - 2026-10-10
|
|
10
34
|
|
|
11
35
|
### Changed
|
|
@@ -575,7 +599,8 @@ First public release.
|
|
|
575
599
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
576
600
|
- One-line installer for Linux and macOS.
|
|
577
601
|
|
|
578
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
602
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.0...HEAD
|
|
603
|
+
[0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
|
|
579
604
|
[0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
|
|
580
605
|
[0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
|
|
581
606
|
[0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
|
|
@@ -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/
|
|
@@ -133,6 +133,33 @@ since last time with `bash_output`, and stops it with `bash_stop`.
|
|
|
133
133
|
- Background commands ask for permission like any other command, and run in the
|
|
134
134
|
[sandbox](sandbox.md) when it's on.
|
|
135
135
|
|
|
136
|
+
## What's happening under the hood
|
|
137
|
+
|
|
138
|
+
Much of the waiting with a local model isn't the model thinking: it's loading the model, or reading
|
|
139
|
+
a long conversation before the first word. lcode says which, in the status line under a request:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
⠋ Loading qwen3.6-35b into memory… (0 tokens, 9s · Ctrl+C to stop)
|
|
143
|
+
⠋ Reading the conversation (18K tokens)… (0 tokens, 21s · Ctrl+C to stop)
|
|
144
|
+
⠋ Thinking… (312 tokens, 27s · Ctrl+C to stop)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It prints a line only when something worth knowing happens, and nothing while the model simply
|
|
148
|
+
stays loaded:
|
|
149
|
+
|
|
150
|
+
| When | What it says |
|
|
151
|
+
|---|---|
|
|
152
|
+
| The model was loaded | `◆ Loaded qwen3.6-35b in 15s: 22.3 GB, 45% on the GPU and the rest in system RAM.`, and a warning when less of it is on the GPU than usual, because another program or model is using GPU memory |
|
|
153
|
+
| A dense model doesn't fit on the GPU (at startup and on `/model`) | `⚠ qwen3.8-27b is a dense model: all 27B parameters work on every token. It's capable, but slow here. Only about 61% of it fits in the 12 GB of GPU memory…`, with what to do instead |
|
|
154
|
+
| The model that looks at images needs the memory | `Loading the vision model … to look at bug.png; qwen3.6-35b makes room and reloads afterwards`, then `◆ qwen3.6-35b was unloaded to make room…`, and the next step says `Reloading qwen3.6-35b into memory (it made room for the vision model …)` |
|
|
155
|
+
| A tool needs the GPU to itself, such as image generation | `◆ Unloaded qwen3.6-35b so comfyui can use the GPU; it reloads afterwards.` |
|
|
156
|
+
| Ollama ran out of memory | `⚠` what ran out (the prompt batch, or the context next to the model), what lcode retries with, and what it remembers for next time |
|
|
157
|
+
|
|
158
|
+
While the model loads in the background at startup, the status bar says
|
|
159
|
+
`loading the model into memory…`. Mixture-of-Experts models, such as qwen3.6-35b, run well with
|
|
160
|
+
part of them in system RAM. Dense ones slow down a lot, to a few tokens a second, which is why they
|
|
161
|
+
get the warning.
|
|
162
|
+
|
|
136
163
|
## Notifications
|
|
137
164
|
|
|
138
165
|
Local models take a while, so you'll often switch to something else during a request. lcode shows
|
|
@@ -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
|
)
|
|
@@ -288,6 +317,10 @@ class Agent:
|
|
|
288
317
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
289
318
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
290
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
|
|
291
324
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
292
325
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
293
326
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -465,12 +498,33 @@ class Agent:
|
|
|
465
498
|
image = vision.read_image(path)
|
|
466
499
|
# The session's own model keeps its settings, so Ollama doesn't reload it.
|
|
467
500
|
options = self.options() if model == self.settings.model else None
|
|
468
|
-
|
|
469
|
-
with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
|
|
501
|
+
with self.vision_swap(model, path.name):
|
|
470
502
|
return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
|
|
471
503
|
except vision.VisionError as e:
|
|
472
504
|
raise ToolError(str(e)) from e
|
|
473
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
|
+
|
|
474
528
|
def describe_image_data(self, data: str, mime: str) -> str:
|
|
475
529
|
"""For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
|
|
476
530
|
model = self.vision_model()
|
|
@@ -478,7 +532,7 @@ class Agent:
|
|
|
478
532
|
return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
|
|
479
533
|
options = self.options() if model == self.settings.model else None
|
|
480
534
|
try:
|
|
481
|
-
with self.
|
|
535
|
+
with self.vision_swap(model, f"the {mime} image"):
|
|
482
536
|
text = vision.describe(
|
|
483
537
|
self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
|
|
484
538
|
)
|
|
@@ -624,9 +678,11 @@ class Agent:
|
|
|
624
678
|
if not started and batch and batch > SAFE_NUM_BATCH:
|
|
625
679
|
# Larger batches read prompts faster but need extra VRAM that isn't always free.
|
|
626
680
|
self.settings.num_batch = SAFE_NUM_BATCH
|
|
627
|
-
self.
|
|
628
|
-
f"
|
|
629
|
-
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,
|
|
630
686
|
)
|
|
631
687
|
yield from self.chat(messages, tools, think)
|
|
632
688
|
return
|
|
@@ -636,16 +692,19 @@ class Agent:
|
|
|
636
692
|
smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
|
|
637
693
|
self.settings.context = smaller
|
|
638
694
|
limits.record(self.settings.model, smaller)
|
|
639
|
-
self.
|
|
640
|
-
f"
|
|
641
|
-
|
|
642
|
-
"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,
|
|
643
700
|
)
|
|
644
701
|
yield from self.chat(messages, tools, think)
|
|
645
702
|
return
|
|
646
703
|
raise OllamaError(
|
|
647
|
-
f"{e}\
|
|
648
|
-
"
|
|
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)."
|
|
649
708
|
) from e
|
|
650
709
|
raise
|
|
651
710
|
|
|
@@ -659,6 +718,7 @@ class Agent:
|
|
|
659
718
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
660
719
|
md = MarkdownStreamer(self.console)
|
|
661
720
|
progress = Progress()
|
|
721
|
+
watch = self.before_request(progress)
|
|
662
722
|
status = None
|
|
663
723
|
printed_thinking = False
|
|
664
724
|
|
|
@@ -680,6 +740,9 @@ class Agent:
|
|
|
680
740
|
if self.cancel is not None and self.cancel.is_set():
|
|
681
741
|
raise KeyboardInterrupt
|
|
682
742
|
msg = chunk.get("message", {})
|
|
743
|
+
if watch is not None:
|
|
744
|
+
self.after_load(watch)
|
|
745
|
+
watch = None
|
|
683
746
|
if msg.get("thinking"):
|
|
684
747
|
thinking += msg["thinking"]
|
|
685
748
|
if self.on_delta:
|
|
@@ -714,6 +777,8 @@ class Agent:
|
|
|
714
777
|
raise
|
|
715
778
|
finally:
|
|
716
779
|
stop_spinner()
|
|
780
|
+
if watch is not None:
|
|
781
|
+
watch.stop()
|
|
717
782
|
if printed_thinking:
|
|
718
783
|
self.console.print()
|
|
719
784
|
md.flush()
|
|
@@ -732,10 +797,49 @@ class Agent:
|
|
|
732
797
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
733
798
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
734
799
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
+
self.sent_chars = conversation_chars(self.messages)
|
|
735
801
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
736
802
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
737
803
|
return message
|
|
738
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
|
+
|
|
739
843
|
def describe_call(self, name: str, args: dict) -> str:
|
|
740
844
|
if name in ("read_file", "write_file", "edit_file"):
|
|
741
845
|
path = self.tools.rel(self.tools.resolve(args.get("path", "")))
|
|
@@ -1071,6 +1175,7 @@ class Agent:
|
|
|
1071
1175
|
) # not enough: before the last request
|
|
1072
1176
|
context_tools.note_removed(self.messages, removed)
|
|
1073
1177
|
if saved:
|
|
1178
|
+
self.sent_chars = 0 # the conversation changed: the model reads it again
|
|
1074
1179
|
self.pruned += 1
|
|
1075
1180
|
self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
|
|
1076
1181
|
self.console.print(
|
|
@@ -1127,6 +1232,7 @@ class Agent:
|
|
|
1127
1232
|
self.skills_loaded.clear()
|
|
1128
1233
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1129
1234
|
self.summarized_size = self.ctx_used
|
|
1235
|
+
self.sent_chars = 0
|
|
1130
1236
|
self.reflected = len(self.messages)
|
|
1131
1237
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
1132
1238
|
|
|
@@ -199,6 +199,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
199
199
|
compact_at=cfg["compact_at"],
|
|
200
200
|
)
|
|
201
201
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
|
+
agent.hardware = hw
|
|
202
203
|
agent.interactive = options.interactive
|
|
203
204
|
if options.max_steps:
|
|
204
205
|
agent.max_steps = options.max_steps
|
|
@@ -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
|
|
|
@@ -935,8 +936,12 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
935
936
|
s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
|
|
936
937
|
elif backends.is_openai(agent.ollama):
|
|
937
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
|
|
938
940
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
939
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)
|
|
940
945
|
elif cmd == "/think":
|
|
941
946
|
s.think = (arg == "on") if arg in ("on", "off") else not s.think
|
|
942
947
|
c.print(f"Reasoning {'on' if s.think else 'off'}.")
|
|
@@ -1110,9 +1115,12 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1110
1115
|
if prompt:
|
|
1111
1116
|
run_safely(agent, prompt)
|
|
1112
1117
|
return
|
|
1113
|
-
preload(agent)
|
|
1114
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)
|
|
1115
1122
|
session = build_session(agent)
|
|
1123
|
+
preload(agent, session)
|
|
1116
1124
|
while True:
|
|
1117
1125
|
try:
|
|
1118
1126
|
agent.console.print()
|
|
@@ -1135,13 +1143,25 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1135
1143
|
agent.console.print("[dim]Bye.[/]")
|
|
1136
1144
|
|
|
1137
1145
|
|
|
1138
|
-
def preload(agent: Agent) -> None:
|
|
1139
|
-
"""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
|
|
1140
1151
|
|
|
1141
1152
|
def load() -> None:
|
|
1153
|
+
agent.load_state = "loading"
|
|
1154
|
+
refresh()
|
|
1142
1155
|
try:
|
|
1143
1156
|
agent.ollama.load(agent.settings.model, agent.options(), agent.settings.keep_alive)
|
|
1144
1157
|
except OllamaError:
|
|
1145
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()
|
|
1146
1166
|
|
|
1147
1167
|
threading.Thread(target=load, daemon=True).start()
|
|
@@ -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
|
|
@@ -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
|
|
@@ -70,6 +70,12 @@ def call(name: str, **arguments) -> dict:
|
|
|
70
70
|
return {"function": {"name": name, "arguments": arguments}}
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
@pytest.fixture(autouse=True)
|
|
74
|
+
def isolated_state(tmp_path_factory, monkeypatch):
|
|
75
|
+
"""Every test gets its own state folder, so none writes into the user's (approvals, sessions…)."""
|
|
76
|
+
monkeypatch.setattr("lcode.config.STATE_DIR", tmp_path_factory.mktemp("lcode-state"))
|
|
77
|
+
|
|
78
|
+
|
|
73
79
|
@pytest.fixture
|
|
74
80
|
def repo(tmp_path: Path) -> Path:
|
|
75
81
|
(tmp_path / "src" / "pkg").mkdir(parents=True)
|