lcode-cli 0.27.0__tar.gz → 0.28.1__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.1}/CHANGELOG.md +60 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/PKG-INFO +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/quickstart.md +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/usage.md +33 -3
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/agent.py +153 -18
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/api.py +2 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/context.py +68 -0
- lcode_cli-0.28.1/src/lcode/permissions.py +260 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repl.py +25 -4
- lcode_cli-0.28.1/src/lcode/residency.py +167 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/tools.py +162 -36
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/conftest.py +6 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_agent.py +4 -3
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_mcp.py +2 -1
- lcode_cli-0.28.1/tests/test_residency.py +164 -0
- lcode_cli-0.28.1/tests/test_robustness.py +221 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_vision.py +5 -4
- lcode_cli-0.27.0/src/lcode/permissions.py +0 -138
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.editorconfig +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/dependabot.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.gitignore +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/AGENTS.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/LICENSE +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/README.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/SECURITY.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/action.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/agents.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/extra.css +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/automation.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/bench.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/changelog.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/commands.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/configuration.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/contributing.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/editors.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/git.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/hooks.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/how-it-works.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/index.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/installation.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/lsp.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/mcp.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/memory.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/models.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/sandbox.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/search.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/servers.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/README.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/commands/review.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/install.sh +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/mkdocs.yml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/overrides/main.html +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/pyproject.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/scripts/record_demo.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/scripts/social-preview.html +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/acp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/action.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/backends.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/cli.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/limits.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/memory.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/models.toml +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/notify.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/planning.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/render.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/update.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/vision.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/src/lcode/web.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_acp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_action.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_api.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_backends.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_bench.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_catalog.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_cli.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_config.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_context.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_extensions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_hooks.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_jobs.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_lsp.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_memory.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_permissions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_repeats.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_repomap.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_servers.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_sessions.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_subagents.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_tools.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_update.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_web.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/tests/test_xai.py +0 -0
- {lcode_cli-0.27.0 → lcode_cli-0.28.1}/uv.lock +0 -0
|
@@ -6,6 +6,63 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.28.1] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
Found by testing lcode's tools on real files and commands (#118):
|
|
14
|
+
|
|
15
|
+
- Some commands that change things ran without asking, because they were taken for read-only:
|
|
16
|
+
`sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
|
|
17
|
+
add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
|
|
18
|
+
and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
|
|
19
|
+
read-only command ask.
|
|
20
|
+
- "Always allow" for a command also covered chains that started with it: allowing `npm test` let
|
|
21
|
+
`npm test && rm -rf build` run without asking. Now a chain needs its own "always".
|
|
22
|
+
- A JSON tool call that the model only showed as an example in its answer was run. Text is now
|
|
23
|
+
taken as a call only when the whole message is one.
|
|
24
|
+
- A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
|
|
25
|
+
doesn't apply the edit, and the model redoes it on the current content.
|
|
26
|
+
- `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
|
|
27
|
+
type the tool declares.
|
|
28
|
+
- The shell timeout wasn't enforced while a command kept printing, so a watcher started without
|
|
29
|
+
`background` hung the request. Long output is no longer held in memory either: 400 MB of output
|
|
30
|
+
used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
|
|
31
|
+
- One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
|
|
32
|
+
however small the window, and compaction only looked at the previous request. A result now takes
|
|
33
|
+
at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
|
|
34
|
+
the size of the next request.
|
|
35
|
+
- Sessions were saved only when a request ended, so a crash lost the whole request. They're now
|
|
36
|
+
saved after every step, and atomically, so a crash while writing can't corrupt them.
|
|
37
|
+
- Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
|
|
38
|
+
file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
|
|
39
|
+
not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
|
|
40
|
+
character the encoding can't hold is refused instead of written wrong.
|
|
41
|
+
|
|
42
|
+
## [0.28.0] - 2026-10-13
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- lcode says what's happening under the hood, in a line, only when it happens:
|
|
47
|
+
- loading the model and then reading the conversation are named in the status line instead of
|
|
48
|
+
"Thinking";
|
|
49
|
+
- after a load, how long it took and how much of the model is on the GPU, with a warning when less
|
|
50
|
+
is than usual;
|
|
51
|
+
- at startup and on `/model`, a warning when a dense model won't fit on the GPU and will be slow,
|
|
52
|
+
with what to use instead;
|
|
53
|
+
- the vision model making the main model step aside, and the reload after it;
|
|
54
|
+
- an image tool taking the GPU;
|
|
55
|
+
- clearer explanations when Ollama runs out of memory.
|
|
56
|
+
|
|
57
|
+
The status bar shows when the model is still loading at startup. See
|
|
58
|
+
[What's happening under the hood](https://nasser1941.github.io/lcode/usage/#whats-happening-under-the-hood).
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- After `/model`, images were still described by the vision model chosen for the previous model.
|
|
63
|
+
- The tests no longer write into your lcode state folder; one of them had left about a hundred
|
|
64
|
+
entries in `extensions-approved.json`.
|
|
65
|
+
|
|
9
66
|
## [0.27.0] - 2026-10-10
|
|
10
67
|
|
|
11
68
|
### Changed
|
|
@@ -575,7 +632,9 @@ First public release.
|
|
|
575
632
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
576
633
|
- One-line installer for Linux and macOS.
|
|
577
634
|
|
|
578
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
635
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.28.1...HEAD
|
|
636
|
+
[0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
|
|
637
|
+
[0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
|
|
579
638
|
[0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
|
|
580
639
|
[0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
|
|
581
640
|
[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.1
|
|
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
|
|
@@ -197,11 +224,14 @@ image support off with `lcode config set vision_model off`.
|
|
|
197
224
|
`--yolo` start in one.
|
|
198
225
|
|
|
199
226
|
Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
|
|
200
|
-
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar
|
|
201
|
-
|
|
227
|
+
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
|
|
228
|
+
`sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
|
|
229
|
+
things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
|
|
230
|
+
and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
|
|
202
231
|
|
|
203
232
|
When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
|
|
204
|
-
or ++n++.
|
|
233
|
+
or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
|
|
234
|
+
allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
|
|
205
235
|
deny lists, per user or per repository; deny always wins, even in `yolo`. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
|
|
206
236
|
|
|
207
237
|
!!! warning "yolo mode"
|
|
@@ -3,8 +3,10 @@
|
|
|
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
|
|
9
|
+
import os
|
|
8
10
|
import platform
|
|
9
11
|
import re
|
|
10
12
|
import shutil
|
|
@@ -23,7 +25,20 @@ from rich.markup import escape
|
|
|
23
25
|
from rich.panel import Panel
|
|
24
26
|
from rich.text import Text
|
|
25
27
|
|
|
26
|
-
from lcode import
|
|
28
|
+
from lcode import (
|
|
29
|
+
catalog,
|
|
30
|
+
codesearch,
|
|
31
|
+
extensions,
|
|
32
|
+
limits,
|
|
33
|
+
notify,
|
|
34
|
+
planning,
|
|
35
|
+
repomap,
|
|
36
|
+
residency,
|
|
37
|
+
sessions,
|
|
38
|
+
subagents,
|
|
39
|
+
vision,
|
|
40
|
+
web,
|
|
41
|
+
)
|
|
27
42
|
from lcode import context as context_tools
|
|
28
43
|
from lcode import memory as memory_notes
|
|
29
44
|
from lcode.checkpoints import Checkpoints
|
|
@@ -53,7 +68,12 @@ from lcode.tools import (
|
|
|
53
68
|
truncate,
|
|
54
69
|
)
|
|
55
70
|
|
|
56
|
-
GPU_MEMORY_ERRORS = (
|
|
71
|
+
GPU_MEMORY_ERRORS = (
|
|
72
|
+
"out of memory",
|
|
73
|
+
"illegal memory access",
|
|
74
|
+
"cudamalloc failed",
|
|
75
|
+
"requires more system memory", # Ollama: the model and its context don't fit in RAM either
|
|
76
|
+
)
|
|
57
77
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
58
78
|
MAX_STEPS_PER_TURN = 150
|
|
59
79
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
@@ -62,6 +82,9 @@ REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's pe
|
|
|
62
82
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
63
83
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
64
84
|
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
85
|
+
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
86
|
+
MIN_RESULT_CHARS = 8000
|
|
87
|
+
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
65
88
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
66
89
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
67
90
|
# @path, @"path with spaces" or @'path'
|
|
@@ -171,6 +194,10 @@ def project_root_or_cwd(cwd: Path) -> Path:
|
|
|
171
194
|
return work_tree_for(cwd)
|
|
172
195
|
|
|
173
196
|
|
|
197
|
+
def conversation_chars(messages: list[dict]) -> int:
|
|
198
|
+
return sum(len(str(m.get("content") or "")) + len(str(m.get("thinking") or "")) for m in messages)
|
|
199
|
+
|
|
200
|
+
|
|
174
201
|
def call_arguments(call: dict) -> dict:
|
|
175
202
|
"""A tool call's arguments as a dict (some models send them as a JSON string)."""
|
|
176
203
|
args = call.get("function", {}).get("arguments") or {}
|
|
@@ -189,17 +216,22 @@ class Progress:
|
|
|
189
216
|
arrives, for example while the model writes a long file into a tool call.
|
|
190
217
|
"""
|
|
191
218
|
|
|
192
|
-
def __init__(self) -> None:
|
|
219
|
+
def __init__(self, phase: str = "Thinking") -> None:
|
|
193
220
|
self.started = self.last_chunk = time.time()
|
|
194
|
-
self.phase =
|
|
221
|
+
self.phase = phase
|
|
195
222
|
self.chars = 0
|
|
196
223
|
|
|
197
224
|
def update(self, phase: str, text: str) -> None:
|
|
198
225
|
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
199
226
|
|
|
227
|
+
def set_phase(self, phase: str) -> None:
|
|
228
|
+
"""What happens before the first token: loading the model, reading the prompt."""
|
|
229
|
+
self.phase, self.last_chunk = phase, time.time()
|
|
230
|
+
|
|
200
231
|
def __rich__(self) -> Text:
|
|
201
232
|
now = time.time()
|
|
202
|
-
|
|
233
|
+
streaming = self.phase in ("Thinking", "Writing") # loading and reading say what they are throughout
|
|
234
|
+
phase = self.phase if now - self.last_chunk < 3 or not streaming else "Working"
|
|
203
235
|
return Text.from_markup(
|
|
204
236
|
f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
|
|
205
237
|
)
|
|
@@ -288,6 +320,12 @@ class Agent:
|
|
|
288
320
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
289
321
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
290
322
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
323
|
+
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
324
|
+
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
325
|
+
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
326
|
+
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
327
|
+
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
328
|
+
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
291
329
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
292
330
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
293
331
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -465,12 +503,33 @@ class Agent:
|
|
|
465
503
|
image = vision.read_image(path)
|
|
466
504
|
# The session's own model keeps its settings, so Ollama doesn't reload it.
|
|
467
505
|
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"):
|
|
506
|
+
with self.vision_swap(model, path.name):
|
|
470
507
|
return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
|
|
471
508
|
except vision.VisionError as e:
|
|
472
509
|
raise ToolError(str(e)) from e
|
|
473
510
|
|
|
511
|
+
@contextlib.contextmanager
|
|
512
|
+
def vision_swap(self, model: str, what: str):
|
|
513
|
+
"""Look at an image with `model`, saying when it has to load and when the main model made room for it."""
|
|
514
|
+
main = self.settings.model
|
|
515
|
+
own = model == main
|
|
516
|
+
main_was_loaded = not own and residency.placement(self.ollama, main) is not None
|
|
517
|
+
if own or residency.placement(self.ollama, model) is not None:
|
|
518
|
+
text = f"Looking at {what} with {model}…"
|
|
519
|
+
else:
|
|
520
|
+
text = f"Loading the vision model {model} to look at {what}" + (
|
|
521
|
+
f"; {main} makes room and reloads afterwards…" if main_was_loaded else "…"
|
|
522
|
+
)
|
|
523
|
+
with self.console.status(text, spinner="dots"):
|
|
524
|
+
yield
|
|
525
|
+
if main_was_loaded and residency.placement(self.ollama, main) is None:
|
|
526
|
+
self.made_room_for = f"the vision model {model}"
|
|
527
|
+
self.say(f"{main} was unloaded to make room for {model}; it reloads for the next step.")
|
|
528
|
+
|
|
529
|
+
def say(self, text: str, warning: bool = False) -> None:
|
|
530
|
+
"""One line about what happens under the hood (loading, memory), not about the work itself."""
|
|
531
|
+
self.console.print(Text(f"{'⚠' if warning else '◆'} {text}", style="yellow" if warning else "dim cyan"))
|
|
532
|
+
|
|
474
533
|
def describe_image_data(self, data: str, mime: str) -> str:
|
|
475
534
|
"""For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
|
|
476
535
|
model = self.vision_model()
|
|
@@ -478,7 +537,7 @@ class Agent:
|
|
|
478
537
|
return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
|
|
479
538
|
options = self.options() if model == self.settings.model else None
|
|
480
539
|
try:
|
|
481
|
-
with self.
|
|
540
|
+
with self.vision_swap(model, f"the {mime} image"):
|
|
482
541
|
text = vision.describe(
|
|
483
542
|
self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
|
|
484
543
|
)
|
|
@@ -522,6 +581,7 @@ class Agent:
|
|
|
522
581
|
self.tools.read_mtimes.clear()
|
|
523
582
|
self.reflected = len(self.messages)
|
|
524
583
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
584
|
+
self.counted_chars = None
|
|
525
585
|
|
|
526
586
|
def session_file(self) -> Path:
|
|
527
587
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -543,7 +603,10 @@ class Agent:
|
|
|
543
603
|
"messages": self.messages,
|
|
544
604
|
"checkpoints": self.checkpoints.to_json(),
|
|
545
605
|
}
|
|
546
|
-
|
|
606
|
+
# Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
|
|
607
|
+
partial = f.with_name(f".{f.name}.partial")
|
|
608
|
+
partial.write_text(json.dumps(data))
|
|
609
|
+
os.replace(partial, f)
|
|
547
610
|
|
|
548
611
|
def new_session(self) -> None:
|
|
549
612
|
self.reset()
|
|
@@ -580,6 +643,7 @@ class Agent:
|
|
|
580
643
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
581
644
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
582
645
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
646
|
+
self.counted_chars = None
|
|
583
647
|
return note
|
|
584
648
|
|
|
585
649
|
def load_latest(self) -> bool:
|
|
@@ -624,9 +688,11 @@ class Agent:
|
|
|
624
688
|
if not started and batch and batch > SAFE_NUM_BATCH:
|
|
625
689
|
# Larger batches read prompts faster but need extra VRAM that isn't always free.
|
|
626
690
|
self.settings.num_batch = SAFE_NUM_BATCH
|
|
627
|
-
self.
|
|
628
|
-
f"
|
|
629
|
-
f"{SAFE_NUM_BATCH}
|
|
691
|
+
self.say(
|
|
692
|
+
f"The GPU ran out of memory reading the prompt in batches of {batch} tokens. Retrying with "
|
|
693
|
+
f"batches of {SAFE_NUM_BATCH}: a little slower to read, but it needs less memory. To start "
|
|
694
|
+
f"there next time: lcode config set num_batch {SAFE_NUM_BATCH}",
|
|
695
|
+
warning=True,
|
|
630
696
|
)
|
|
631
697
|
yield from self.chat(messages, tools, think)
|
|
632
698
|
return
|
|
@@ -636,16 +702,19 @@ class Agent:
|
|
|
636
702
|
smaller = max(catalog.MIN_USEFUL_CONTEXT, context // 2)
|
|
637
703
|
self.settings.context = smaller
|
|
638
704
|
limits.record(self.settings.model, smaller)
|
|
639
|
-
self.
|
|
640
|
-
f"
|
|
641
|
-
|
|
642
|
-
"time on this machine.
|
|
705
|
+
self.say(
|
|
706
|
+
f"{self.settings.model} didn't fit in memory with a {format_tokens(context)} context: the "
|
|
707
|
+
"context needs its own memory next to the model. Reloading it with "
|
|
708
|
+
f"{format_tokens(smaller)}; lcode will start there next time on this machine.",
|
|
709
|
+
warning=True,
|
|
643
710
|
)
|
|
644
711
|
yield from self.chat(messages, tools, think)
|
|
645
712
|
return
|
|
646
713
|
raise OllamaError(
|
|
647
|
-
f"{e}\
|
|
648
|
-
"
|
|
714
|
+
f"{e}\n{self.settings.model} ran out of memory. Close other programs that use the GPU (another "
|
|
715
|
+
"model, a game, an image generator), use a smaller context (/ctx "
|
|
716
|
+
f"{format_tokens(max(catalog.MIN_USEFUL_CONTEXT // 2, context // 2)).lower()}), or pick a smaller "
|
|
717
|
+
"model (/models)."
|
|
649
718
|
) from e
|
|
650
719
|
raise
|
|
651
720
|
|
|
@@ -659,6 +728,7 @@ class Agent:
|
|
|
659
728
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
660
729
|
md = MarkdownStreamer(self.console)
|
|
661
730
|
progress = Progress()
|
|
731
|
+
watch = self.before_request(progress)
|
|
662
732
|
status = None
|
|
663
733
|
printed_thinking = False
|
|
664
734
|
|
|
@@ -680,6 +750,9 @@ class Agent:
|
|
|
680
750
|
if self.cancel is not None and self.cancel.is_set():
|
|
681
751
|
raise KeyboardInterrupt
|
|
682
752
|
msg = chunk.get("message", {})
|
|
753
|
+
if watch is not None:
|
|
754
|
+
self.after_load(watch)
|
|
755
|
+
watch = None
|
|
683
756
|
if msg.get("thinking"):
|
|
684
757
|
thinking += msg["thinking"]
|
|
685
758
|
if self.on_delta:
|
|
@@ -714,6 +787,8 @@ class Agent:
|
|
|
714
787
|
raise
|
|
715
788
|
finally:
|
|
716
789
|
stop_spinner()
|
|
790
|
+
if watch is not None:
|
|
791
|
+
watch.stop()
|
|
717
792
|
if printed_thinking:
|
|
718
793
|
self.console.print()
|
|
719
794
|
md.flush()
|
|
@@ -732,10 +807,49 @@ class Agent:
|
|
|
732
807
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
733
808
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
734
809
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
810
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
735
811
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
736
812
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
737
813
|
return message
|
|
738
814
|
|
|
815
|
+
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
816
|
+
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
817
|
+
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
818
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama":
|
|
819
|
+
return None
|
|
820
|
+
model, context = self.settings.model, self.settings.context
|
|
821
|
+
fresh = max(0, conversation_chars(self.messages) - self.sent_chars) // 3
|
|
822
|
+
where = residency.placement(self.ollama, model)
|
|
823
|
+
if where is not None and (not where.context or where.context == context):
|
|
824
|
+
if fresh >= READING_NOTE_TOKENS:
|
|
825
|
+
progress.set_phase(f"Reading {format_tokens(fresh)} new tokens")
|
|
826
|
+
return None
|
|
827
|
+
total = conversation_chars(self.messages) // 3
|
|
828
|
+
if where is not None:
|
|
829
|
+
phase = f"Reloading {model} with a {format_tokens(context)} context"
|
|
830
|
+
elif self.made_room_for:
|
|
831
|
+
phase = f"Reloading {model} into memory (it made room for {self.made_room_for})"
|
|
832
|
+
else:
|
|
833
|
+
phase = f"Loading {model} into memory"
|
|
834
|
+
progress.set_phase(phase)
|
|
835
|
+
watch = residency.LoadWatch(
|
|
836
|
+
self.ollama, model, context, progress, f"Reading the conversation ({format_tokens(total)} tokens)"
|
|
837
|
+
)
|
|
838
|
+
watch.reload = where is not None or bool(self.made_room_for)
|
|
839
|
+
return watch
|
|
840
|
+
|
|
841
|
+
def after_load(self, watch: residency.LoadWatch) -> None:
|
|
842
|
+
"""The first token after a load: one line on how long it took and how the model fits."""
|
|
843
|
+
seconds = watch.stop()
|
|
844
|
+
self.made_room_for = ""
|
|
845
|
+
self.load_state = ""
|
|
846
|
+
where = residency.placement(self.ollama, self.settings.model)
|
|
847
|
+
dense = residency.is_moe(self.ollama, self.settings.model) is False
|
|
848
|
+
report = residency.load_report(
|
|
849
|
+
self.settings.model, seconds, where, self.settings.context, self.hardware, dense, watch.reload
|
|
850
|
+
)
|
|
851
|
+
self.say(report, warning=report.endswith(("closed.", "slow.")))
|
|
852
|
+
|
|
739
853
|
def describe_call(self, name: str, args: dict) -> str:
|
|
740
854
|
if name in ("read_file", "write_file", "edit_file"):
|
|
741
855
|
path = self.tools.rel(self.tools.resolve(args.get("path", "")))
|
|
@@ -839,6 +953,8 @@ class Agent:
|
|
|
839
953
|
for step in range(self.max_steps):
|
|
840
954
|
if self.cancel is not None and self.cancel.is_set():
|
|
841
955
|
raise KeyboardInterrupt
|
|
956
|
+
if step and self.autosave:
|
|
957
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
842
958
|
self.maybe_compact()
|
|
843
959
|
try:
|
|
844
960
|
reply = self.assistant_step()
|
|
@@ -923,6 +1039,9 @@ class Agent:
|
|
|
923
1039
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
924
1040
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
925
1041
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
1042
|
+
budget = self.result_budget()
|
|
1043
|
+
if len(result) > budget and name not in ("agent", "skill"):
|
|
1044
|
+
result = context_tools.shorten(result, budget, f"{name} result")
|
|
926
1045
|
verdict = self.repeats.check(name, args, result)
|
|
927
1046
|
if verdict.note:
|
|
928
1047
|
result += verdict.note
|
|
@@ -1057,8 +1176,21 @@ class Agent:
|
|
|
1057
1176
|
limit = min(limit, self.settings.compact_at)
|
|
1058
1177
|
return limit
|
|
1059
1178
|
|
|
1179
|
+
def next_request_tokens(self) -> int:
|
|
1180
|
+
"""The size of the next request: what the model last counted, plus what was added since (tool output,
|
|
1181
|
+
attachments). Deciding on the last count alone let one large tool result overflow the window."""
|
|
1182
|
+
if self.counted_chars is None:
|
|
1183
|
+
return self.ctx_used
|
|
1184
|
+
return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
|
|
1185
|
+
|
|
1186
|
+
def result_budget(self) -> int:
|
|
1187
|
+
"""Characters one tool result may take: a share of the window, so one large result can't fill it."""
|
|
1188
|
+
return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
|
|
1189
|
+
|
|
1060
1190
|
def maybe_compact(self) -> None:
|
|
1061
1191
|
limit = self.compact_limit()
|
|
1192
|
+
if self.next_request_tokens() > limit:
|
|
1193
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1062
1194
|
if self.ctx_used <= limit:
|
|
1063
1195
|
return
|
|
1064
1196
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1071,6 +1203,7 @@ class Agent:
|
|
|
1071
1203
|
) # not enough: before the last request
|
|
1072
1204
|
context_tools.note_removed(self.messages, removed)
|
|
1073
1205
|
if saved:
|
|
1206
|
+
self.sent_chars = 0 # the conversation changed: the model reads it again
|
|
1074
1207
|
self.pruned += 1
|
|
1075
1208
|
self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
|
|
1076
1209
|
self.console.print(
|
|
@@ -1126,7 +1259,9 @@ class Agent:
|
|
|
1126
1259
|
)
|
|
1127
1260
|
self.skills_loaded.clear()
|
|
1128
1261
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1262
|
+
self.counted_chars = None
|
|
1129
1263
|
self.summarized_size = self.ctx_used
|
|
1264
|
+
self.sent_chars = 0
|
|
1130
1265
|
self.reflected = len(self.messages)
|
|
1131
1266
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
1132
1267
|
|
|
@@ -199,6 +199,8 @@ 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
|
|
203
|
+
agent.autosave = True
|
|
202
204
|
agent.interactive = options.interactive
|
|
203
205
|
if options.max_steps:
|
|
204
206
|
agent.max_steps = options.max_steps
|
|
@@ -14,8 +14,10 @@ full output is saved to a file the model can read if it needs more.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import os
|
|
17
18
|
import re
|
|
18
19
|
import time
|
|
20
|
+
from collections import deque
|
|
19
21
|
from dataclasses import dataclass
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import TYPE_CHECKING
|
|
@@ -253,6 +255,72 @@ def shorten(text: str, limit: int, label: str = "output") -> str:
|
|
|
253
255
|
return f"{head}{note}{tail}{where}"
|
|
254
256
|
|
|
255
257
|
|
|
258
|
+
class OutputSpool:
|
|
259
|
+
"""Output collected line by line with bounded memory: once it's long, everything goes to a file, and only the
|
|
260
|
+
start, the end and the error-looking lines stay in memory. What `shorten` returns comes out the same."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, limit: int, label: str = "output") -> None:
|
|
263
|
+
self.limit, self.label = limit, label
|
|
264
|
+
self.head: list[str] = [] # everything, until the output gets long; then its start
|
|
265
|
+
self.tail: deque[str] = deque()
|
|
266
|
+
self.tail_chars = 0
|
|
267
|
+
self.errors: list[str] = []
|
|
268
|
+
self.chars = 0
|
|
269
|
+
self.file = None
|
|
270
|
+
self.path: Path | None = None
|
|
271
|
+
|
|
272
|
+
def add(self, line: str) -> None:
|
|
273
|
+
self.chars += len(line)
|
|
274
|
+
if self.file is None:
|
|
275
|
+
self.head.append(line)
|
|
276
|
+
if self.chars > self.limit * 2:
|
|
277
|
+
self._spool()
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
self.file.write(line)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
if ERROR_LINE.search(line) and len(self.errors) < MAX_ERROR_LINES:
|
|
284
|
+
self.errors.append(line.strip()[:300])
|
|
285
|
+
self.tail.append(line)
|
|
286
|
+
self.tail_chars += len(line)
|
|
287
|
+
while self.tail_chars > self.limit // 4 and len(self.tail) > 1:
|
|
288
|
+
self.tail_chars -= len(self.tail.popleft())
|
|
289
|
+
|
|
290
|
+
def _spool(self) -> None:
|
|
291
|
+
text = "".join(self.head)
|
|
292
|
+
self.path = save_output(text, self.label)
|
|
293
|
+
if self.path is not None:
|
|
294
|
+
try:
|
|
295
|
+
self.file = self.path.open("a")
|
|
296
|
+
except OSError:
|
|
297
|
+
self.file = None
|
|
298
|
+
if self.file is None: # nowhere to keep it: keep only the start and carry on counting
|
|
299
|
+
self.file = open(os.devnull, "w") # noqa: SIM115
|
|
300
|
+
head = text[: self.limit * 3 // 5]
|
|
301
|
+
self.head = [head]
|
|
302
|
+
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
if self.file is None:
|
|
305
|
+
return shorten("".join(self.head), self.limit, self.label)
|
|
306
|
+
self.file.close()
|
|
307
|
+
head, tail = "".join(self.head), "".join(self.tail)
|
|
308
|
+
cut = self.chars - len(head) - len(tail)
|
|
309
|
+
note = f"\n\n... [{cut} characters cut"
|
|
310
|
+
if self.errors:
|
|
311
|
+
note += f"; {len(self.errors)} line(s) from them that look like errors:]\n" + "\n".join(self.errors)
|
|
312
|
+
note += "\n[...]\n\n"
|
|
313
|
+
else:
|
|
314
|
+
note += "] ...\n\n"
|
|
315
|
+
where = (
|
|
316
|
+
f"\n[The full {self.label} ({self.chars} characters) is in {self.path}: read parts of it with read_file "
|
|
317
|
+
"if you need them.]"
|
|
318
|
+
if self.path
|
|
319
|
+
else ""
|
|
320
|
+
)
|
|
321
|
+
return f"{head}{note}{tail}{where}"
|
|
322
|
+
|
|
323
|
+
|
|
256
324
|
# ----------------------------------------------------------------------------- what uses the context
|
|
257
325
|
|
|
258
326
|
|