lcode-cli 0.28.0__tar.gz → 0.29.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.28.0 → lcode_cli-0.29.0}/CHANGELOG.md +54 -1
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/PKG-INFO +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/automation.md +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/configuration.md +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/usage.md +42 -3
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/agent.py +120 -4
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/api.py +4 -0
- lcode_cli-0.29.0/src/lcode/checks.py +160 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/cli.py +6 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/config.py +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/context.py +68 -0
- lcode_cli-0.29.0/src/lcode/permissions.py +260 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repl.py +1 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/tools.py +160 -33
- lcode_cli-0.29.0/tests/test_checks.py +192 -0
- lcode_cli-0.29.0/tests/test_robustness.py +221 -0
- lcode_cli-0.28.0/src/lcode/permissions.py +0 -138
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.editorconfig +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.gitignore +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/AGENTS.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/LICENSE +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/README.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/SECURITY.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/action.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/agents.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/bench.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/changelog.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/commands.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/contributing.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/editors.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/git.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/hooks.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/index.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/installation.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/lsp.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/mcp.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/memory.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/models.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/search.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/servers.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/README.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/install.sh +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/mkdocs.yml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/overrides/main.html +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/pyproject.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/residency.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/conftest.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_action.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_api.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_config.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_context.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_residency.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_update.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_web.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.28.0 → lcode_cli-0.29.0}/uv.lock +0 -0
|
@@ -6,6 +6,57 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.29.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- lcode runs the project's checks before calling work done. After a request that changed files, it
|
|
14
|
+
runs the tests (pytest, unittest, npm/pnpm/yarn/bun test, cargo test, go test or make test, found
|
|
15
|
+
from the project, or the command in `.lcode/settings.toml` or the `checks` setting). When they
|
|
16
|
+
fail, the model gets the output and keeps working, twice at most, and then says what still fails.
|
|
17
|
+
Checks the model already ran after its last change aren't repeated. `lcode -p --output json`
|
|
18
|
+
reports them in `checks`, and `lcode bench --checks off` compares. See
|
|
19
|
+
[Checks before done](https://nasser1941.github.io/lcode/usage/#checks-before-done).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- A tool call that Ollama couldn't read because its XML was out of order (`expected element type
|
|
24
|
+
<function> but have <parameter>`) ended the request with an error. Like other malformed calls,
|
|
25
|
+
it's now sent back to the model to make again.
|
|
26
|
+
|
|
27
|
+
## [0.28.1] - 2026-10-13
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
Found by testing lcode's tools on real files and commands (#118):
|
|
32
|
+
|
|
33
|
+
- Some commands that change things ran without asking, because they were taken for read-only:
|
|
34
|
+
`sort -o`, `uniq in out`, `tree -o`, `git branch -D` and other branch changes, `git remote
|
|
35
|
+
add/remove`, `git log/diff --output`, `rg --pre` (which runs a program), `find -fls`, `date -s`,
|
|
36
|
+
and `nvidia-smi` settings. Arguments are now checked. `2>&1` and `>/dev/null` no longer make a
|
|
37
|
+
read-only command ask.
|
|
38
|
+
- "Always allow" for a command also covered chains that started with it: allowing `npm test` let
|
|
39
|
+
`npm test && rm -rf build` run without asking. Now a chain needs its own "always".
|
|
40
|
+
- A JSON tool call that the model only showed as an example in its answer was run. Text is now
|
|
41
|
+
taken as a call only when the whole message is one.
|
|
42
|
+
- A change you made to a file while lcode asked to edit it was overwritten. lcode now notices and
|
|
43
|
+
doesn't apply the edit, and the model redoes it on the current content.
|
|
44
|
+
- `replace_all: "false"`, and other values sent as text, were taken as true. They now get the
|
|
45
|
+
type the tool declares.
|
|
46
|
+
- The shell timeout wasn't enforced while a command kept printing, so a watcher started without
|
|
47
|
+
`background` hung the request. Long output is no longer held in memory either: 400 MB of output
|
|
48
|
+
used to cost 2.5 GB, and now it goes to a file, keeping its start, end and error lines.
|
|
49
|
+
- One tool result could overflow the context window: `read_file` returned up to ~40K tokens,
|
|
50
|
+
however small the window, and compaction only looked at the previous request. A result now takes
|
|
51
|
+
at most a quarter of the window (`read_file` says where to read on), and lcode makes room based on
|
|
52
|
+
the size of the next request.
|
|
53
|
+
- Sessions were saved only when a request ended, so a crash lost the whole request. They're now
|
|
54
|
+
saved after every step, and atomically, so a crash while writing can't corrupt them.
|
|
55
|
+
- Editing a file with Windows line endings turned the whole file to Unix line endings, and editing a
|
|
56
|
+
file that isn't UTF-8 (Windows-1252 or Latin-1) replaced every accented letter in it with `�`,
|
|
57
|
+
not only in the edited line. Files now keep their encoding, line endings and byte order mark. A
|
|
58
|
+
character the encoding can't hold is refused instead of written wrong.
|
|
59
|
+
|
|
9
60
|
## [0.28.0] - 2026-10-13
|
|
10
61
|
|
|
11
62
|
### Added
|
|
@@ -599,7 +650,9 @@ First public release.
|
|
|
599
650
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
600
651
|
- One-line installer for Linux and macOS.
|
|
601
652
|
|
|
602
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
653
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.29.0...HEAD
|
|
654
|
+
[0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
|
|
655
|
+
[0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
|
|
603
656
|
[0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
|
|
604
657
|
[0.27.0]: https://github.com/nasser1941/lcode/compare/v0.26.0...v0.27.0
|
|
605
658
|
[0.26.0]: https://github.com/nasser1941/lcode/compare/v0.25.0...v0.26.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.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/
|
|
@@ -55,6 +55,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped becau
|
|
|
55
55
|
| `tool_calls` | Every tool call, with its arguments and output (the first 4,000 characters). `error` is true when the call failed or wasn't allowed |
|
|
56
56
|
| `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
|
|
57
57
|
| `usage` | Model requests and tokens; `prompt_tokens` includes tokens served from the prompt cache |
|
|
58
|
+
| `checks` | The [checks](usage.md#checks-before-done) that ran after the change: `command`, `exit_code` (`null` when they weren't allowed to run) and `by` (`lcode`, or `model` when the model ran them itself) |
|
|
58
59
|
| `error` | What went wrong, when `status` is `error` |
|
|
59
60
|
| `session_id` | Continue the conversation later with `lcode --resume <id>` |
|
|
60
61
|
|
|
@@ -43,6 +43,7 @@ lcode config path # print the file location
|
|
|
43
43
|
| `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
|
|
44
44
|
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
|
|
45
45
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
46
|
+
| `checks` | `auto` | Run the project's [checks](usage.md#checks-before-done) after a request changed files: `auto` (found from the project), `off`, or a command |
|
|
46
47
|
| `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
|
|
47
48
|
| `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
|
|
48
49
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
@@ -113,6 +113,42 @@ reasoning is on.
|
|
|
113
113
|
lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
|
|
114
114
|
it was read, so the model always edits the current version.
|
|
115
115
|
|
|
116
|
+
## Checks before done
|
|
117
|
+
|
|
118
|
+
Small models like to call work done after a syntax check. So after a request that changed files, lcode
|
|
119
|
+
runs the project's checks itself before the answer is final:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
◆ Checking the work: python -m unittest (from test files)
|
|
123
|
+
⚠ Checks fail (python -m unittest, exit code 1): the model gets the output to fix it.
|
|
124
|
+
◆ Checking the work: python -m unittest (from test files)
|
|
125
|
+
◆ Checks pass: python -m unittest
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- **When they fail,** the model gets the output and keeps working, twice at most. After that its answer
|
|
129
|
+
says what still fails instead of claiming success. A failure that the change didn't cause is
|
|
130
|
+
reported, not chased, and the checks run again only if the model changes something more.
|
|
131
|
+
- **When nothing could run** (no tests collected, or the test runner isn't installed), lcode says so
|
|
132
|
+
and doesn't send it to the model.
|
|
133
|
+
- **When the model already ran them** after its last change and they passed, they aren't run again.
|
|
134
|
+
- **Permission:** they run like any other command, so in `ask` mode the first run asks (++a++ allows
|
|
135
|
+
them for the session).
|
|
136
|
+
- **Programs** using `lcode -p --output json` see the runs in the result's `checks`.
|
|
137
|
+
|
|
138
|
+
**Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run
|
|
139
|
+
pytest -q` with a `uv.lock`), otherwise `python -m unittest` for `test_*.py` files. Then
|
|
140
|
+
`npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...`
|
|
141
|
+
and `make test`. To choose the command yourself, set it for the repository in
|
|
142
|
+
`.lcode/settings.toml`. That file is used once you trust the repository, like its hooks:
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
[checks]
|
|
146
|
+
command = "uv run pytest -q && uv run ruff check ."
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
|
|
150
|
+
turns checks off.
|
|
151
|
+
|
|
116
152
|
## Background commands
|
|
117
153
|
|
|
118
154
|
Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
|
|
@@ -224,11 +260,14 @@ image support off with `lcode config set vision_model off`.
|
|
|
224
260
|
`--yolo` start in one.
|
|
225
261
|
|
|
226
262
|
Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
|
|
227
|
-
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar
|
|
228
|
-
|
|
263
|
+
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar. Their arguments count too:
|
|
264
|
+
`sort -o file`, `git branch -D`, `git log --output=file`, `rg --pre` or `find -delete` change
|
|
265
|
+
things, so they ask. Pipes between read-only commands are fine, and so are `cd <folder> &&` in front
|
|
266
|
+
and `2>&1` or `>/dev/null`; anything else (`&&`, `;`, redirecting to a file, `$(…)`) asks.
|
|
229
267
|
|
|
230
268
|
When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
|
|
231
|
-
or ++n++.
|
|
269
|
+
or ++n++. "Always" covers the same program, or for a chain the same programs in the same order:
|
|
270
|
+
allowing `npm test` doesn't allow `npm test && rm -rf build`. For lasting answers, write [permission rules](hooks.md#permission-rules): allow lists and
|
|
232
271
|
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`.
|
|
233
272
|
|
|
234
273
|
!!! warning "yolo mode"
|
|
@@ -6,6 +6,7 @@ import base64
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import datetime as dt
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import platform
|
|
10
11
|
import re
|
|
11
12
|
import shutil
|
|
@@ -26,6 +27,7 @@ from rich.text import Text
|
|
|
26
27
|
|
|
27
28
|
from lcode import (
|
|
28
29
|
catalog,
|
|
30
|
+
checks,
|
|
29
31
|
codesearch,
|
|
30
32
|
extensions,
|
|
31
33
|
limits,
|
|
@@ -80,7 +82,15 @@ EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
|
80
82
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
81
83
|
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
82
84
|
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
83
|
-
MALFORMED_CALL = (
|
|
85
|
+
MALFORMED_CALL = (
|
|
86
|
+
"error parsing tool call",
|
|
87
|
+
# Go's XML decoder, for models that write calls as XML (Qwen3.6): a cut-off or misordered call
|
|
88
|
+
"XML syntax error",
|
|
89
|
+
"expected element type",
|
|
90
|
+
"closed by </",
|
|
91
|
+
)
|
|
92
|
+
RESULT_SHARE = 0.25 # of the context window, at most, for one tool result
|
|
93
|
+
MIN_RESULT_CHARS = 8000
|
|
84
94
|
READING_NOTE_TOKENS = 4000 # new input this large gets "Reading … new tokens" in the status line
|
|
85
95
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
86
96
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
@@ -111,7 +121,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
111
121
|
- A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
|
|
112
122
|
- To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
|
|
113
123
|
- For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
|
|
114
|
-
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.
|
|
124
|
+
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.{checks}
|
|
115
125
|
|
|
116
126
|
# Communication
|
|
117
127
|
- Be concise and direct; no filler. Use GitHub-flavored markdown.
|
|
@@ -265,6 +275,7 @@ class Settings:
|
|
|
265
275
|
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
266
276
|
compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
|
|
267
277
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
278
|
+
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
268
279
|
|
|
269
280
|
|
|
270
281
|
class Agent:
|
|
@@ -320,7 +331,11 @@ class Agent:
|
|
|
320
331
|
self.hardware = None # an lcode.hardware.Hardware, set by the CLI: for what fits on the GPU
|
|
321
332
|
self.made_room_for = "" # why the model was unloaded (the vision model, an MCP tool), for the reload line
|
|
322
333
|
self.load_state = "" # "loading" while the model loads in the background at startup (lcode.repl)
|
|
334
|
+
self.autosave = False # save the session after every step (sessions people resume; not the bench's)
|
|
335
|
+
self._check: tuple[Path, checks.Check | None] | None = None
|
|
336
|
+
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
323
337
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
338
|
+
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
324
339
|
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
325
340
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
326
341
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -358,6 +373,7 @@ class Agent:
|
|
|
358
373
|
code=self.code_prompt(),
|
|
359
374
|
map=self.map_prompt(),
|
|
360
375
|
look=" you look at with view_image" if self.vision_model() else "",
|
|
376
|
+
checks=self.checks_prompt(),
|
|
361
377
|
)
|
|
362
378
|
|
|
363
379
|
def repo_map(self) -> repomap.RepoMap | None:
|
|
@@ -576,6 +592,7 @@ class Agent:
|
|
|
576
592
|
self.tools.read_mtimes.clear()
|
|
577
593
|
self.reflected = len(self.messages)
|
|
578
594
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
595
|
+
self.counted_chars = None
|
|
579
596
|
|
|
580
597
|
def session_file(self) -> Path:
|
|
581
598
|
return sessions.sessions_dir() / f"{self.session_id}.json"
|
|
@@ -597,7 +614,10 @@ class Agent:
|
|
|
597
614
|
"messages": self.messages,
|
|
598
615
|
"checkpoints": self.checkpoints.to_json(),
|
|
599
616
|
}
|
|
600
|
-
|
|
617
|
+
# Written next to it and then renamed: a crash halfway leaves the previous version, not a broken file.
|
|
618
|
+
partial = f.with_name(f".{f.name}.partial")
|
|
619
|
+
partial.write_text(json.dumps(data))
|
|
620
|
+
os.replace(partial, f)
|
|
601
621
|
|
|
602
622
|
def new_session(self) -> None:
|
|
603
623
|
self.reset()
|
|
@@ -634,6 +654,7 @@ class Agent:
|
|
|
634
654
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
635
655
|
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
636
656
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
657
|
+
self.counted_chars = None
|
|
637
658
|
return note
|
|
638
659
|
|
|
639
660
|
def load_latest(self) -> bool:
|
|
@@ -797,11 +818,69 @@ class Agent:
|
|
|
797
818
|
self.usage["output_tokens"] += final.get("eval_count", 0)
|
|
798
819
|
self.usage["output_ns"] += final.get("eval_duration", 0)
|
|
799
820
|
self.ctx_used = final.get("prompt_eval_count", 0) + final.get("eval_count", 0)
|
|
800
|
-
self.sent_chars = conversation_chars(self.messages)
|
|
821
|
+
self.sent_chars = self.counted_chars = conversation_chars(self.messages)
|
|
801
822
|
seconds = final.get("eval_duration", 0) / 1e9
|
|
802
823
|
self.last_speed = final.get("eval_count", 0) / seconds if seconds else 0.0
|
|
803
824
|
return message
|
|
804
825
|
|
|
826
|
+
def checks_prompt(self) -> str:
|
|
827
|
+
check = self.project_check()
|
|
828
|
+
if check is None:
|
|
829
|
+
return ""
|
|
830
|
+
return (
|
|
831
|
+
f"\n- When you've changed files, lcode runs the project's checks (`{check.command}`) before your answer "
|
|
832
|
+
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
def project_check(self) -> checks.Check | None:
|
|
836
|
+
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
837
|
+
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
838
|
+
return None
|
|
839
|
+
root = project_root_or_cwd(self.cwd)
|
|
840
|
+
if self._check is None or self._check[0] != root or self._check[1] is None: # tests may appear meanwhile
|
|
841
|
+
self._check = (root, checks.find(root, self.settings.checks, self.settings.trust_project))
|
|
842
|
+
return self._check[1]
|
|
843
|
+
|
|
844
|
+
def model_ran_checks(self, command: str, result: str) -> bool:
|
|
845
|
+
"""Whether a command the model ran was the project's checks, and they passed."""
|
|
846
|
+
check = self.project_check()
|
|
847
|
+
if check is None or not checks.covers(check, command) or checks.exit_code(result) != 0:
|
|
848
|
+
return False
|
|
849
|
+
self.check_results.append({"command": check.command, "exit_code": 0, "by": "model"})
|
|
850
|
+
return True
|
|
851
|
+
|
|
852
|
+
def run_check(self, check: checks.Check, round_: int) -> str | None:
|
|
853
|
+
"""Run the checks after a change. Returns what to tell the model when they fail, else None."""
|
|
854
|
+
self.say(f"Checking the work: {check.command} (from {check.source})")
|
|
855
|
+
result = self.tools.run("bash", {"command": check.command, "timeout": checks.TIMEOUT})
|
|
856
|
+
code = checks.exit_code(result)
|
|
857
|
+
self.check_results.append({"command": check.command, "exit_code": code, "by": "lcode"})
|
|
858
|
+
if code is None:
|
|
859
|
+
self.say(f"The checks didn't run: {result.splitlines()[0][:200]}", warning=True)
|
|
860
|
+
return None
|
|
861
|
+
if code == 0:
|
|
862
|
+
self.say(f"Checks pass: {check.command}")
|
|
863
|
+
return None
|
|
864
|
+
if checks.no_tests(check, result):
|
|
865
|
+
self.say(f"The checks found no tests to run ({check.command}).")
|
|
866
|
+
return None
|
|
867
|
+
if checks.missing(check, result):
|
|
868
|
+
self.say(
|
|
869
|
+
f"The checks couldn't run ({check.command}): {result.splitlines()[0][:160]}. Install what they need, "
|
|
870
|
+
'or set the command: lcode config set checks "…" (or checks off).',
|
|
871
|
+
warning=True,
|
|
872
|
+
)
|
|
873
|
+
return None
|
|
874
|
+
if round_ >= checks.MAX_ROUNDS:
|
|
875
|
+
self.say(
|
|
876
|
+
f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
|
|
877
|
+
)
|
|
878
|
+
else:
|
|
879
|
+
self.say(
|
|
880
|
+
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
881
|
+
)
|
|
882
|
+
return checks.failure_note(check, result, round_)
|
|
883
|
+
|
|
805
884
|
def before_request(self, progress: Progress) -> residency.LoadWatch | None:
|
|
806
885
|
"""Set the status line for what comes before the first token: loading the model if it isn't in memory
|
|
807
886
|
(watched until it is), then reading the prompt. Returns the watch while the model loads."""
|
|
@@ -938,11 +1017,16 @@ class Agent:
|
|
|
938
1017
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
939
1018
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
940
1019
|
self.repeats.reset()
|
|
1020
|
+
self.check_results = []
|
|
1021
|
+
changed = verified = False # files changed in this request; the checks passed since
|
|
1022
|
+
check_rounds = 0
|
|
941
1023
|
malformed = 0
|
|
942
1024
|
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
943
1025
|
for step in range(self.max_steps):
|
|
944
1026
|
if self.cancel is not None and self.cancel.is_set():
|
|
945
1027
|
raise KeyboardInterrupt
|
|
1028
|
+
if step and self.autosave:
|
|
1029
|
+
self.save() # a crash or a closed terminal loses at most this step
|
|
946
1030
|
self.maybe_compact()
|
|
947
1031
|
try:
|
|
948
1032
|
reply = self.assistant_step()
|
|
@@ -971,6 +1055,8 @@ class Agent:
|
|
|
971
1055
|
reason = str(e).rsplit("err=", 1)[-1].strip()
|
|
972
1056
|
elif "XML syntax error" in str(e):
|
|
973
1057
|
reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
|
|
1058
|
+
elif "expected element type" in str(e) or "closed by </" in str(e):
|
|
1059
|
+
reason = str(e).split("Ollama error:", 1)[-1].strip() or "invalid XML"
|
|
974
1060
|
else:
|
|
975
1061
|
reason = "invalid JSON"
|
|
976
1062
|
self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
|
|
@@ -984,6 +1070,15 @@ class Agent:
|
|
|
984
1070
|
)
|
|
985
1071
|
continue
|
|
986
1072
|
if not calls:
|
|
1073
|
+
check = self.project_check() if changed and not verified else None
|
|
1074
|
+
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1075
|
+
check_rounds += 1
|
|
1076
|
+
feedback = self.run_check(check, check_rounds)
|
|
1077
|
+
if feedback:
|
|
1078
|
+
self.messages.append({"role": "user", "content": feedback})
|
|
1079
|
+
changed = False # run them again only if the model changes something more
|
|
1080
|
+
continue
|
|
1081
|
+
verified = True
|
|
987
1082
|
break
|
|
988
1083
|
done: dict[int, str] = {} # results of subagents that ran together
|
|
989
1084
|
together = [
|
|
@@ -1027,6 +1122,9 @@ class Agent:
|
|
|
1027
1122
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
1028
1123
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
1029
1124
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
1125
|
+
budget = self.result_budget()
|
|
1126
|
+
if len(result) > budget and name not in ("agent", "skill"):
|
|
1127
|
+
result = context_tools.shorten(result, budget, f"{name} result")
|
|
1030
1128
|
verdict = self.repeats.check(name, args, result)
|
|
1031
1129
|
if verdict.note:
|
|
1032
1130
|
result += verdict.note
|
|
@@ -1036,6 +1134,10 @@ class Agent:
|
|
|
1036
1134
|
)
|
|
1037
1135
|
if verdict.stop:
|
|
1038
1136
|
looping = self.describe_call(name, args)
|
|
1137
|
+
if name in ("edit_file", "write_file") and not result.startswith(REFUSED):
|
|
1138
|
+
changed, verified = True, False
|
|
1139
|
+
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
1140
|
+
verified = True # the model ran the checks itself, after its last change
|
|
1039
1141
|
tool_msg = {"role": "tool", "tool_name": name, "content": result}
|
|
1040
1142
|
if call.get("id"):
|
|
1041
1143
|
tool_msg["tool_call_id"] = call["id"]
|
|
@@ -1161,8 +1263,21 @@ class Agent:
|
|
|
1161
1263
|
limit = min(limit, self.settings.compact_at)
|
|
1162
1264
|
return limit
|
|
1163
1265
|
|
|
1266
|
+
def next_request_tokens(self) -> int:
|
|
1267
|
+
"""The size of the next request: what the model last counted, plus what was added since (tool output,
|
|
1268
|
+
attachments). Deciding on the last count alone let one large tool result overflow the window."""
|
|
1269
|
+
if self.counted_chars is None:
|
|
1270
|
+
return self.ctx_used
|
|
1271
|
+
return self.ctx_used + max(0, conversation_chars(self.messages) - self.counted_chars) // 3
|
|
1272
|
+
|
|
1273
|
+
def result_budget(self) -> int:
|
|
1274
|
+
"""Characters one tool result may take: a share of the window, so one large result can't fill it."""
|
|
1275
|
+
return max(MIN_RESULT_CHARS, int(self.settings.context * 3 * RESULT_SHARE))
|
|
1276
|
+
|
|
1164
1277
|
def maybe_compact(self) -> None:
|
|
1165
1278
|
limit = self.compact_limit()
|
|
1279
|
+
if self.next_request_tokens() > limit:
|
|
1280
|
+
self.ctx_used, self.counted_chars = self.next_request_tokens(), None
|
|
1166
1281
|
if self.ctx_used <= limit:
|
|
1167
1282
|
return
|
|
1168
1283
|
enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
|
|
@@ -1231,6 +1346,7 @@ class Agent:
|
|
|
1231
1346
|
)
|
|
1232
1347
|
self.skills_loaded.clear()
|
|
1233
1348
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
1349
|
+
self.counted_chars = None
|
|
1234
1350
|
self.summarized_size = self.ctx_used
|
|
1235
1351
|
self.sent_chars = 0
|
|
1236
1352
|
self.reflected = len(self.messages)
|
|
@@ -76,6 +76,7 @@ class Result:
|
|
|
76
76
|
tool_calls: list[dict] = field(default_factory=list) # {"id", "name", "arguments", "error", "output"}
|
|
77
77
|
files_changed: list[dict] = field(default_factory=list) # {"status": "A" | "M" | "D", "path"}
|
|
78
78
|
usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
|
|
79
|
+
checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
|
|
79
80
|
seconds: float = 0.0
|
|
80
81
|
error: str = ""
|
|
81
82
|
|
|
@@ -196,10 +197,12 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
196
197
|
notify_after=cfg["notify_after"],
|
|
197
198
|
secret_check=cfg["secret_check"],
|
|
198
199
|
repeat_limit=cfg["repeat_limit"],
|
|
200
|
+
checks=cfg["checks"],
|
|
199
201
|
compact_at=cfg["compact_at"],
|
|
200
202
|
)
|
|
201
203
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
202
204
|
agent.hardware = hw
|
|
205
|
+
agent.autosave = True
|
|
203
206
|
agent.interactive = options.interactive
|
|
204
207
|
if options.max_steps:
|
|
205
208
|
agent.max_steps = options.max_steps
|
|
@@ -302,6 +305,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
302
305
|
tool_calls=list(calls.values()),
|
|
303
306
|
files_changed=files,
|
|
304
307
|
usage=usage,
|
|
308
|
+
checks=list(agent.check_results),
|
|
305
309
|
seconds=round(time.monotonic() - started, 2),
|
|
306
310
|
error=error,
|
|
307
311
|
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Run the project's checks before calling work done.
|
|
2
|
+
|
|
3
|
+
Small models call work done after a syntax check, and telling them to verify isn't enough. So after a request
|
|
4
|
+
that changed files, lcode runs the project's check command itself (its tests, or a linter) before the answer
|
|
5
|
+
counts as final. When it fails, the output goes back to the model, which keeps working, at most `MAX_ROUNDS`
|
|
6
|
+
times; then the answer says plainly that the checks still fail.
|
|
7
|
+
|
|
8
|
+
The command is, in order: the repository's `.lcode/settings.toml` (`[checks] command = "…"`, used once the
|
|
9
|
+
repository is trusted, like its hooks), the user's `checks` setting, or what the project looks like: pytest,
|
|
10
|
+
unittest, npm test, cargo test, go test, make test. `checks = "off"` turns it off.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
MAX_ROUNDS = 2 # times the model gets failing checks back before lcode stops asking
|
|
22
|
+
TIMEOUT = 600 # seconds a check may run
|
|
23
|
+
OUTPUT_TAIL = 6000 # characters of a failing check's output the model gets back
|
|
24
|
+
EXIT = re.compile(r"\[exit code: (-?\d+)\]\s*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Check:
|
|
29
|
+
command: str
|
|
30
|
+
source: str # where it comes from, for the user: "pyproject.toml", "your configuration"…
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find(root: Path, setting: str, trusted: bool) -> Check | None:
|
|
34
|
+
"""The project's check command, or None when there's none or checks are off."""
|
|
35
|
+
setting = (setting or "auto").strip()
|
|
36
|
+
if setting == "off":
|
|
37
|
+
return None
|
|
38
|
+
if trusted:
|
|
39
|
+
from lcode.hooks import HookError, read_toml
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = read_toml(root / ".lcode" / "settings.toml")
|
|
43
|
+
except HookError:
|
|
44
|
+
data = {}
|
|
45
|
+
command = str((data.get("checks") or {}).get("command") or "").strip()
|
|
46
|
+
if command:
|
|
47
|
+
return Check(command, ".lcode/settings.toml")
|
|
48
|
+
if setting != "auto":
|
|
49
|
+
return Check(setting, "your configuration (checks)")
|
|
50
|
+
return detect(root)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def python() -> str:
|
|
54
|
+
return "python" if shutil.which("python") else "python3"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def text(path: Path) -> str:
|
|
58
|
+
try:
|
|
59
|
+
return path.read_text(errors="replace") if path.is_file() else ""
|
|
60
|
+
except OSError:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def python_tests(root: Path) -> bool:
|
|
65
|
+
"""Whether the project has Python test files (a few levels deep, outside environments and caches)."""
|
|
66
|
+
from lcode.tools import IGNORE_DIRS
|
|
67
|
+
|
|
68
|
+
def walk(folder: Path, depth: int) -> bool:
|
|
69
|
+
try:
|
|
70
|
+
entries = list(folder.iterdir())
|
|
71
|
+
except OSError:
|
|
72
|
+
return False
|
|
73
|
+
if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries):
|
|
74
|
+
return True
|
|
75
|
+
return depth > 0 and any(
|
|
76
|
+
walk(e, depth - 1)
|
|
77
|
+
for e in entries
|
|
78
|
+
if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".")
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return walk(root, 3)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect(root: Path) -> Check | None:
|
|
85
|
+
"""A check command from what the project looks like. Python checks need test files: pytest configured in a
|
|
86
|
+
project without tests "fails" with nothing to run."""
|
|
87
|
+
if python_tests(root):
|
|
88
|
+
configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
|
|
89
|
+
if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
|
|
90
|
+
runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q"
|
|
91
|
+
return Check(runner, "pytest in the project's configuration")
|
|
92
|
+
if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
|
|
93
|
+
return Check(f"{python()} -m unittest", "test files")
|
|
94
|
+
if (root / "tests").is_dir():
|
|
95
|
+
return Check(f"{python()} -m unittest discover -s tests -t .", "tests/")
|
|
96
|
+
package = text(root / "package.json")
|
|
97
|
+
if package:
|
|
98
|
+
try:
|
|
99
|
+
test = (json.loads(package).get("scripts") or {}).get("test") or ""
|
|
100
|
+
except ValueError:
|
|
101
|
+
test = ""
|
|
102
|
+
if test and "no test specified" not in test:
|
|
103
|
+
for lock, tool in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun")):
|
|
104
|
+
if (root / lock).is_file():
|
|
105
|
+
return Check(f"{tool} test", "package.json")
|
|
106
|
+
return Check("npm test", "package.json")
|
|
107
|
+
if (root / "Cargo.toml").is_file():
|
|
108
|
+
return Check("cargo test", "Cargo.toml")
|
|
109
|
+
if (root / "go.mod").is_file():
|
|
110
|
+
return Check("go test ./...", "go.mod")
|
|
111
|
+
if re.search(r"^test:", text(root / "Makefile"), re.M):
|
|
112
|
+
return Check("make test", "Makefile")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
NO_TESTS = 5 # pytest's and unittest's exit code when no test ran: not a failure
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def no_tests(check: Check, result: str) -> bool:
|
|
120
|
+
return exit_code(result) == NO_TESTS and bool(re.search(r"pytest|unittest", check.command))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def exit_code(result: str) -> int | None:
|
|
124
|
+
"""The exit code at the end of a bash tool result (None: it didn't run, e.g. not allowed)."""
|
|
125
|
+
found = EXIT.search(result)
|
|
126
|
+
return int(found.group(1)) if found else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def covers(check: Check, command: str) -> bool:
|
|
130
|
+
"""Whether a command the model ran is this check (`cd x && uv run pytest -q 2>&1 | tail` counts)."""
|
|
131
|
+
return " ".join(check.command.split()) in " ".join(command.split())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def failure_note(check: Check, result: str, round_: int) -> str:
|
|
135
|
+
output = result if len(result) <= OUTPUT_TAIL else "…" + result[-OUTPUT_TAIL:]
|
|
136
|
+
last = round_ >= MAX_ROUNDS
|
|
137
|
+
return (
|
|
138
|
+
f"[lcode] Your changes are done, but the project's checks fail: `{check.command}` "
|
|
139
|
+
f"(exit code {exit_code(result)}).\n\n{output}\n\n"
|
|
140
|
+
+ (
|
|
141
|
+
"This was the last check lcode runs for this request. Don't change anything more: tell the user what "
|
|
142
|
+
"still fails and why."
|
|
143
|
+
if last
|
|
144
|
+
else "Fix what your changes broke, then finish. If a failure isn't caused by your changes (it failed "
|
|
145
|
+
"before), don't chase it: say so in your answer."
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# The check command itself is missing (not the code under test): nothing the model can fix.
|
|
151
|
+
MISSING = re.compile(r"No module named '?pytest'?\s*$|command not found|^\S+: not found$", re.I | re.M)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def missing(check: Check, result: str) -> bool:
|
|
155
|
+
"""Whether the check couldn't run at all: its program isn't installed."""
|
|
156
|
+
code = exit_code(result)
|
|
157
|
+
if code == 127:
|
|
158
|
+
return True
|
|
159
|
+
head = result.splitlines()[:5] # a missing program says so first; test failures look different
|
|
160
|
+
return code not in (0, None) and any(MISSING.search(line) for line in head)
|
|
@@ -580,6 +580,7 @@ def cmd_bench(args) -> None:
|
|
|
580
580
|
checkpoints=False,
|
|
581
581
|
prune=not args.no_prune,
|
|
582
582
|
compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
|
|
583
|
+
checks=cfg["checks"] if args.checks is None else args.checks,
|
|
583
584
|
repo_map=args.repo_map,
|
|
584
585
|
embed_model=cfg["embed_model"] if args.code_search else "off",
|
|
585
586
|
)
|
|
@@ -774,6 +775,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
774
775
|
p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
|
|
775
776
|
p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
|
|
776
777
|
p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
|
|
778
|
+
p.add_argument(
|
|
779
|
+
"--checks",
|
|
780
|
+
metavar="auto|off|COMMAND",
|
|
781
|
+
help="run each task's checks before it counts as done (default: the checks setting)",
|
|
782
|
+
)
|
|
777
783
|
p.add_argument(
|
|
778
784
|
"--compact-at",
|
|
779
785
|
type=compact_tokens,
|
|
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
59
59
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
|
+
"checks": ("auto", str, "run the project's checks before calling work done: auto | off | a command"),
|
|
62
63
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
63
64
|
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
64
65
|
"compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
|