lcode-cli 0.35.0__tar.gz → 0.36.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.35.0 → lcode_cli-0.36.0}/CHANGELOG.md +44 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/PKG-INFO +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/automation.md +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/configuration.md +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/how-it-works.md +31 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/models.md +14 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/usage.md +13 -4
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/agent.py +118 -7
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/api.py +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/backends.py +3 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/bench.py +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/checks.py +71 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/cli.py +81 -21
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/repeats.py +37 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/repl.py +81 -32
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/subagents.py +6 -5
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/tools.py +10 -2
- lcode_cli-0.36.0/src/lcode/unreachable.py +186 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_backends.py +23 -0
- lcode_cli-0.36.0/tests/test_model_picker.py +94 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_residency.py +2 -2
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_sessions.py +2 -2
- lcode_cli-0.36.0/tests/test_stuck.py +250 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_subagents.py +1 -1
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.editorconfig +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.gitignore +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/AGENTS.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/LICENSE +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/README.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/SECURITY.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/action.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/agents.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/bench.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/changelog.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/commands.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/contributing.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/editors.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/git.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/hooks.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/index.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/installation.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/lsp.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/mcp.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/memory.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/search.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/servers.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/examples/README.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/install.sh +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/mkdocs.yml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/overrides/main.html +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/pyproject.toml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/scripts/make_logos.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/escalate.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/matching.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/residency.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/review.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/screenshots.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/steer.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/conftest.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_action.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_api.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_checks.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_config.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_context.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_escalate.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_matching.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_review.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_robustness.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_screenshots.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_steer.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_update.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_web.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.35.0 → lcode_cli-0.36.0}/uv.lock +0 -0
|
@@ -6,6 +6,47 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.36.0] - 2026-10-18
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `/models` and `/model` show a numbered list of models, and you switch by typing a number: no
|
|
14
|
+
second `/model <name>`. `/model 2` switches straight away, and a name still works. The list ends
|
|
15
|
+
with the other Ollama models you've installed that can call tools, and a model that isn't
|
|
16
|
+
installed says how to get it.
|
|
17
|
+
- `/context` says why it stops at the model's largest window (256K for qwen3.6-35b): Ollama caps the
|
|
18
|
+
window at what the model was trained on, whatever the memory, so 512K or 1M would only look larger.
|
|
19
|
+
|
|
20
|
+
## [0.35.1] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
Found in a real session that spent an hour on an Android build whose downloads this machine
|
|
25
|
+
couldn't reach, then ended without a word:
|
|
26
|
+
|
|
27
|
+
- **A site this machine can't reach** now stops the guessing. lcode tracks, per site, the calls that
|
|
28
|
+
failed to reach it (HTTP errors, names that don't resolve, refused or timed-out connections) and
|
|
29
|
+
the ones that got an answer. After three different failed calls and none that worked, the model is
|
|
30
|
+
told to check one address that must exist and, if that fails too, to tell you what fails and what
|
|
31
|
+
you could do. Two more and lcode stops the request (status `blocked`). Before, every new address
|
|
32
|
+
looked like a new attempt, so nothing stepped in.
|
|
33
|
+
- **Thinking in circles** is stopped as it streams. A model that repeats the same few paragraphs
|
|
34
|
+
used to think until the context was full (here 70,000 characters, about ten minutes behind the
|
|
35
|
+
spinner). Now the reply is dropped once its sentences keep repeating, and the model is asked for
|
|
36
|
+
its next step or what's blocking it.
|
|
37
|
+
- **A reply with no answer** no longer ends the request as if it were done. An empty reply, or one
|
|
38
|
+
that filled the context before it said anything, gets a second chance; the second time lcode stops
|
|
39
|
+
(status `loop`) and offers the stronger model first, when one is set. An answer the context cut off
|
|
40
|
+
midway now says so. OpenAI-compatible servers report a cut-off reply too.
|
|
41
|
+
- **A `cd` out of the project** no longer moves lcode there. A model that ran `cd /tmp` took the
|
|
42
|
+
session, the project checks and the memory notes with it, and the session was saved under `/tmp`,
|
|
43
|
+
so `lcode -c` in the project couldn't find it. Now a `cd` within the project is kept, and one out
|
|
44
|
+
of it isn't: the next command starts in the project again. Sessions are saved under the folder
|
|
45
|
+
lcode works in.
|
|
46
|
+
- **Scripts it never ran** are sent back once before the answer counts: a model that wrote
|
|
47
|
+
`build_android.sh` and called the job done never ran it, and its first line failed. It now runs it
|
|
48
|
+
and fixes what fails, or says plainly that it's untested and why.
|
|
49
|
+
|
|
9
50
|
## [0.35.0] - 2026-10-14
|
|
10
51
|
|
|
11
52
|
### Added
|
|
@@ -769,7 +810,9 @@ First public release.
|
|
|
769
810
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
770
811
|
- One-line installer for Linux and macOS.
|
|
771
812
|
|
|
772
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
813
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.36.0...HEAD
|
|
814
|
+
[0.36.0]: https://github.com/nasser1941/lcode/compare/v0.35.1...v0.36.0
|
|
815
|
+
[0.35.1]: https://github.com/nasser1941/lcode/compare/v0.35.0...v0.35.1
|
|
773
816
|
[0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
|
|
774
817
|
[0.34.0]: https://github.com/nasser1941/lcode/compare/v0.33.1...v0.34.0
|
|
775
818
|
[0.33.1]: https://github.com/nasser1941/lcode/compare/v0.33.0...v0.33.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.36.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/
|
|
@@ -50,7 +50,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped becau
|
|
|
50
50
|
|
|
51
51
|
| Field | |
|
|
52
52
|
|---|---|
|
|
53
|
-
| `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call) or `interrupted` |
|
|
53
|
+
| `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call, or its replies went nowhere), `blocked` (a site it needed couldn't be reached) or `interrupted` |
|
|
54
54
|
| `text` | The model's final answer |
|
|
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 |
|
|
@@ -48,7 +48,7 @@ lcode config path # print the file location
|
|
|
48
48
|
| `escalate_model` | — | A [stronger model](usage.md#a-stronger-model-when-stuck) to ask, with your OK, when the model is stuck: `anthropic/…`, `openai/…`, `xai/…` (keys from `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`), `ollama/<model>`, or `custom/<model>` with `escalate_url` |
|
|
49
49
|
| `escalate_url` | — | Its server, when it isn't the provider's usual one: another Ollama, or any OpenAI-compatible API |
|
|
50
50
|
| `escalate_price` | — | Its price in dollars per million tokens, input/output (e.g. `3/15`), for the cost shown before each use |
|
|
51
|
-
| `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 |
|
|
51
|
+
| `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, along with the checks for [unreachable sites](how-it-works.md#when-a-site-cant-be-reached) and [thinking in circles](how-it-works.md#when-a-reply-goes-nowhere) |
|
|
52
52
|
| `secret_check` | `true` | Before `git commit` and `git push`, look for passwords, keys and tokens they'd add ([details](usage.md#secrets-in-commits)) |
|
|
53
53
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
54
54
|
|
|
@@ -59,7 +59,37 @@ A result that changes isn't a repeat, so a test that's run again after a fix, or
|
|
|
59
59
|
that's checked while it runs, never adds up. A call that may have changed something, like an edit or
|
|
60
60
|
a shell command that isn't read-only, starts the count again for the other calls: checking the build
|
|
61
61
|
after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
|
|
62
|
-
with `0
|
|
62
|
+
with `0` (that also turns off the checks for unreachable sites and for thinking in circles, below).
|
|
63
|
+
|
|
64
|
+
### When a site can't be reached
|
|
65
|
+
|
|
66
|
+
A download that fails however its address is written usually means this machine can't reach the
|
|
67
|
+
site: a firewall, a proxy, a regional block, or no internet. No address the model tries will fix
|
|
68
|
+
that, and every attempt differs, so the repeat count above never fires. lcode keeps track, per site,
|
|
69
|
+
of the calls that failed to reach it (an HTTP error, a name that doesn't resolve, a refused or
|
|
70
|
+
timed-out connection) and of the ones that got an answer.
|
|
71
|
+
|
|
72
|
+
- **When three different calls failed for a site and none worked,** the result gets a note: check
|
|
73
|
+
one address on that site that must exist, and if that fails too, stop and tell you what fails
|
|
74
|
+
and what you could do (another network or a VPN, a proxy, a mirror, or running that step
|
|
75
|
+
somewhere else, such as CI).
|
|
76
|
+
- **Two more,** and lcode ends the request with `Stopped: every request to dl.google.com failed (5
|
|
77
|
+
calls: HTTP 404)…`. The JSON output's status is `blocked`.
|
|
78
|
+
|
|
79
|
+
A site that answered once in the request is never counted as out of reach, so a wrong address on a
|
|
80
|
+
working site doesn't add up. Local addresses (localhost, private networks, names without a dot)
|
|
81
|
+
aren't tracked.
|
|
82
|
+
|
|
83
|
+
### When a reply goes nowhere
|
|
84
|
+
|
|
85
|
+
Small models sometimes think in circles: a few paragraphs, over and over, until the context is full.
|
|
86
|
+
That takes minutes behind the spinner and ends without an answer. lcode watches the thinking as it
|
|
87
|
+
streams, and when its sentences keep repeating, it stops the model, drops that reply and asks for a
|
|
88
|
+
short one: the next tool call, or what's blocking it. An empty reply, or one that filled the context
|
|
89
|
+
before it said anything, gets the same second chance. The second time in a request, lcode stops it
|
|
90
|
+
(status `loop`), offering the [stronger model](usage.md#a-stronger-model-when-stuck) first when one
|
|
91
|
+
is set. An answer the context cut off midway is kept, with a warning to `/compact` or use a larger
|
|
92
|
+
`/context`.
|
|
63
93
|
|
|
64
94
|
## Subagents
|
|
65
95
|
|
|
@@ -60,9 +60,18 @@ when they fit entirely in GPU (or Apple unified) memory.
|
|
|
60
60
|
|
|
61
61
|
```text
|
|
62
62
|
❯ /models
|
|
63
|
-
|
|
63
|
+
Models for this machine: RTX 4080 Laptop (12 GB VRAM), 31 GB RAM
|
|
64
|
+
# Key Size Max ctx Fits here Speed Status
|
|
65
|
+
1 qwen3.6-35b 22.6 GB 256K 256K good (experts in RAM) installed, recommended, current
|
|
66
|
+
2 qwen3.8-27b …
|
|
67
|
+
9 my-model:7b installed, not in catalog
|
|
68
|
+
Switch to which model? (number or name, Enter to keep lcode-qwen3.6-35b): 9
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
`/model` shows the same list. `/model 9` or `/model qwen3.5-9b` switches straight away. The
|
|
72
|
+
list ends with the other Ollama models you've installed that can call tools. A model that isn't
|
|
73
|
+
installed yet says how to get it (`lcode setup <key>` in a shell).
|
|
74
|
+
|
|
66
75
|
### Any other Ollama model
|
|
67
76
|
|
|
68
77
|
```bash
|
|
@@ -107,6 +116,10 @@ Context window for lcode-qwen3.6-35b
|
|
|
107
116
|
Choose a number, a size like 96k, or press Enter to keep 256K: 2
|
|
108
117
|
```
|
|
109
118
|
|
|
119
|
+
The list stops at the largest window the model was trained on, and says so: Ollama caps the window
|
|
120
|
+
there whatever the memory, so a larger setting would only look larger. For qwen3.6-35b that's 256K;
|
|
121
|
+
512K and 1M are offered for models trained on them.
|
|
122
|
+
|
|
110
123
|
Sizes accept `k` and `m` suffixes (`128k` = 131,072 tokens, `1m` = 1,048,576). When the window is
|
|
111
124
|
85% full, lcode summarizes the conversation automatically so you can keep working; `/compact` does it
|
|
112
125
|
on demand.
|
|
@@ -76,7 +76,7 @@ reasoning is on.
|
|
|
76
76
|
| `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
|
|
77
77
|
| `/compact [focus]` | Summarize the conversation to free context |
|
|
78
78
|
| `/context [size]` | Show how full the context window is, what's using it (by category), and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
|
|
79
|
-
| `/model [name]
|
|
79
|
+
| `/models`, `/model [number or name]` | List the models and how they fit this machine, numbered: type a number to switch, or `/model 2`, `/model qwen3.5-9b` |
|
|
80
80
|
| `/think [on\|off]` | Toggle reasoning |
|
|
81
81
|
| `/verbose` | Toggle showing the reasoning text |
|
|
82
82
|
| `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
|
|
@@ -101,7 +101,7 @@ reasoning is on.
|
|
|
101
101
|
| `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
|
|
102
102
|
| `glob` | Find files by pattern, newest first |
|
|
103
103
|
| `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
|
|
104
|
-
| `bash` | Run a shell command with live output, a timeout and a persistent working directory; with `background`, keep it running ([details](#background-commands)) |
|
|
104
|
+
| `bash` | Run a shell command with live output, a timeout and a persistent working directory (a `cd` within the project is kept, one out of it isn't); with `background`, keep it running ([details](#background-commands)) |
|
|
105
105
|
| `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it |
|
|
106
106
|
| `todo_write` | Keep a visible task list for multi-step work, with notes (decisions, findings) that survive when old messages are removed; `/status` shows them |
|
|
107
107
|
| `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
|
|
@@ -183,6 +183,13 @@ command = "uv run pytest -q && uv run ruff check ."
|
|
|
183
183
|
Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
|
|
184
184
|
turns checks off.
|
|
185
185
|
|
|
186
|
+
**Scripts it never ran.** A model that writes a build or install script tends to call it done
|
|
187
|
+
without running it once, and the first thing you see is its first error. When a request wrote a
|
|
188
|
+
shell script (a `.sh` file, or a file with a shell shebang) and no command in it ran the script,
|
|
189
|
+
lcode sends it back once before the answer counts: run it and fix what fails, or say plainly that
|
|
190
|
+
it's untested and why, when running it would install, deploy or delete things. Making it executable
|
|
191
|
+
or reading it doesn't count as running it. `checks = "off"` turns this off too.
|
|
192
|
+
|
|
186
193
|
## A review before the answer
|
|
187
194
|
|
|
188
195
|
Models claim more than they did: half of a two-part request done, "it works now" without having
|
|
@@ -224,8 +231,10 @@ lcode config set escalate_price 3/15 # dollars per million tokens
|
|
|
224
231
|
```
|
|
225
232
|
|
|
226
233
|
**When.** lcode offers it when the [loop breaker](how-it-works.md#when-the-model-repeats-itself)
|
|
227
|
-
would stop the request,
|
|
228
|
-
|
|
234
|
+
would stop the request, when the model's replies [go nowhere](how-it-works.md#when-a-reply-goes-nowhere)
|
|
235
|
+
twice, and when the project's [checks](#checks-before-done) fail twice in a row after the model's
|
|
236
|
+
fixes, at most once per request. Not for a [site this machine can't reach](how-it-works.md#when-a-site-cant-be-reached):
|
|
237
|
+
a stronger model can't reach it either. You can also ask at any time: `/escalate`, or
|
|
229
238
|
`/escalate the window still grows` to say what's wrong.
|
|
230
239
|
|
|
231
240
|
**Nothing is sent without asking**, in any permission mode:
|
|
@@ -51,7 +51,7 @@ from lcode.mcp import McpManager
|
|
|
51
51
|
from lcode.ollama import Ollama, OllamaError
|
|
52
52
|
from lcode.permissions import LEADING_CD, Permissions
|
|
53
53
|
from lcode.render import MarkdownStreamer
|
|
54
|
-
from lcode.repeats import Repeats
|
|
54
|
+
from lcode.repeats import Circles, Repeats
|
|
55
55
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
56
56
|
from lcode.steer import NOTE_INTRO, Steering
|
|
57
57
|
from lcode.tools import (
|
|
@@ -71,6 +71,7 @@ from lcode.tools import (
|
|
|
71
71
|
tree,
|
|
72
72
|
truncate,
|
|
73
73
|
)
|
|
74
|
+
from lcode.unreachable import NOTE_AT, Sites
|
|
74
75
|
|
|
75
76
|
GPU_MEMORY_ERRORS = (
|
|
76
77
|
"out of memory",
|
|
@@ -80,6 +81,27 @@ GPU_MEMORY_ERRORS = (
|
|
|
80
81
|
)
|
|
81
82
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
82
83
|
MAX_STEPS_PER_TURN = 150
|
|
84
|
+
MAX_STALLS = 2 # replies that go round in circles or say nothing, per request, before lcode stops it
|
|
85
|
+
STALLED = {
|
|
86
|
+
"circles": "the reply went round in circles, so lcode stopped it",
|
|
87
|
+
"cut_off": "the reply filled the context before it said anything",
|
|
88
|
+
"": "the reply was empty",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def stall_note(trouble: str) -> str:
|
|
93
|
+
"""What the model is told after a reply that went round in circles or ended without a word."""
|
|
94
|
+
first = {
|
|
95
|
+
"circles": "Your last reply went round in circles (the same thoughts over and over), so lcode stopped it.",
|
|
96
|
+
"cut_off": "Your last reply filled the rest of the context before you said anything.",
|
|
97
|
+
"": "Your last reply was empty.",
|
|
98
|
+
}[trouble]
|
|
99
|
+
return (
|
|
100
|
+
f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
|
|
101
|
+
"tell the user in a few lines what you did, what fails, and what they could do."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
83
105
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
84
106
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
85
107
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
@@ -319,6 +341,9 @@ class Agent:
|
|
|
319
341
|
self.ollama = ollama
|
|
320
342
|
self.settings = settings
|
|
321
343
|
self.cwd = cwd.resolve()
|
|
344
|
+
# The folder lcode works in: where it started, or where /cd or a resumed session took it. A shell
|
|
345
|
+
# command may move the working directory around its project (self.cwd), but not out of it.
|
|
346
|
+
self.workspace = self.cwd
|
|
322
347
|
self.perms = Permissions(self.console, settings.permission_mode)
|
|
323
348
|
self.tools = Toolbox(self)
|
|
324
349
|
self.session_id = self.new_session_id()
|
|
@@ -377,8 +402,10 @@ class Agent:
|
|
|
377
402
|
self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
|
|
378
403
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
379
404
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
380
|
-
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
405
|
+
self.turn_status = "" # how the last request ended: success, max_steps, loop, blocked
|
|
381
406
|
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
407
|
+
self.sites = Sites(NOTE_AT if settings.repeat_limit else 0) # notices a site this machine can't reach
|
|
408
|
+
self.reply_trouble = "" # what went wrong with the last reply: "circles", "cut_off" or ""
|
|
382
409
|
self.response = None # the streaming response, so another thread can abort it
|
|
383
410
|
self.ctx_used = 0
|
|
384
411
|
self.last_speed = 0.0
|
|
@@ -648,7 +675,7 @@ class Agent:
|
|
|
648
675
|
f = self.session_file()
|
|
649
676
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
650
677
|
data = {
|
|
651
|
-
"cwd": str(self.
|
|
678
|
+
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
652
679
|
"model": self.settings.model,
|
|
653
680
|
"name": self.session_name,
|
|
654
681
|
"title": self.session_title,
|
|
@@ -687,7 +714,7 @@ class Agent:
|
|
|
687
714
|
saved_cwd = Path(info.cwd) if info.cwd else self.cwd
|
|
688
715
|
if saved_cwd != self.cwd:
|
|
689
716
|
if saved_cwd.is_dir():
|
|
690
|
-
self.cwd = saved_cwd.resolve()
|
|
717
|
+
self.cwd = self.workspace = saved_cwd.resolve()
|
|
691
718
|
note = f"Working directory is now {self.cwd}"
|
|
692
719
|
else:
|
|
693
720
|
note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
|
|
@@ -707,7 +734,7 @@ class Agent:
|
|
|
707
734
|
return note
|
|
708
735
|
|
|
709
736
|
def load_latest(self) -> bool:
|
|
710
|
-
latest = sessions.list_sessions(self.
|
|
737
|
+
latest = sessions.list_sessions(self.workspace, limit=1)
|
|
711
738
|
if latest:
|
|
712
739
|
self.load(latest[0])
|
|
713
740
|
return bool(latest)
|
|
@@ -805,8 +832,11 @@ class Agent:
|
|
|
805
832
|
status = None
|
|
806
833
|
|
|
807
834
|
start_spinner()
|
|
835
|
+
circles = Circles()
|
|
836
|
+
self.reply_trouble = ""
|
|
837
|
+
stream = self.chat(self.messages, self.tool_schemas(), self.settings.think)
|
|
808
838
|
try:
|
|
809
|
-
for chunk in
|
|
839
|
+
for chunk in stream:
|
|
810
840
|
if self.cancel is not None and self.cancel.is_set():
|
|
811
841
|
raise KeyboardInterrupt
|
|
812
842
|
msg = chunk.get("message", {})
|
|
@@ -822,6 +852,9 @@ class Agent:
|
|
|
822
852
|
stop_spinner() # raw text is printed as it streams
|
|
823
853
|
self.console.print(Text(msg["thinking"], style="dim italic"), end="")
|
|
824
854
|
printed_thinking = True
|
|
855
|
+
if self.settings.repeat_limit and circles.feed(msg["thinking"]):
|
|
856
|
+
self.reply_trouble = "circles"
|
|
857
|
+
break # closing the stream below stops the model
|
|
825
858
|
if msg.get("content"):
|
|
826
859
|
if printed_thinking:
|
|
827
860
|
self.console.print("\n")
|
|
@@ -846,9 +879,12 @@ class Agent:
|
|
|
846
879
|
self.messages.append({"role": "assistant", "content": content + "\n[interrupted by user]"})
|
|
847
880
|
raise
|
|
848
881
|
finally:
|
|
882
|
+
stream.close()
|
|
849
883
|
stop_spinner()
|
|
850
884
|
if watch is not None:
|
|
851
885
|
watch.stop()
|
|
886
|
+
if final.get("done_reason") == "length" and not self.reply_trouble:
|
|
887
|
+
self.reply_trouble = "cut_off" # it filled the context (or hit the output limit) mid-reply
|
|
852
888
|
if printed_thinking:
|
|
853
889
|
self.console.print()
|
|
854
890
|
md.flush()
|
|
@@ -918,6 +954,12 @@ class Agent:
|
|
|
918
954
|
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
919
955
|
)
|
|
920
956
|
|
|
957
|
+
def unrun_scripts(self, commands: list[str]) -> list[str]:
|
|
958
|
+
"""Shell scripts the model wrote in this request and never ran (only where the checks would run)."""
|
|
959
|
+
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
960
|
+
return []
|
|
961
|
+
return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
|
|
962
|
+
|
|
921
963
|
def project_check(self) -> checks.Check | None:
|
|
922
964
|
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
923
965
|
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
@@ -1106,6 +1148,7 @@ class Agent:
|
|
|
1106
1148
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1107
1149
|
self.turn_requests = [user_text.strip()]
|
|
1108
1150
|
self.repeats.reset()
|
|
1151
|
+
self.sites.reset()
|
|
1109
1152
|
self.check_results = []
|
|
1110
1153
|
self.review_results = None
|
|
1111
1154
|
self.escalations = []
|
|
@@ -1116,6 +1159,10 @@ class Agent:
|
|
|
1116
1159
|
check_rounds = 0
|
|
1117
1160
|
malformed = 0
|
|
1118
1161
|
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
1162
|
+
blocked = None # the site that couldn't be reached, when lcode stopped the request for it
|
|
1163
|
+
stalls = 0 # replies that went round in circles or ended without a word
|
|
1164
|
+
commands: list[str] = [] # shell commands run in this request
|
|
1165
|
+
scripts_noted = False # the model was told about scripts it wrote and never ran
|
|
1119
1166
|
for step in range(self.max_steps):
|
|
1120
1167
|
if self.cancel is not None and self.cancel.is_set():
|
|
1121
1168
|
raise KeyboardInterrupt
|
|
@@ -1165,9 +1212,46 @@ class Agent:
|
|
|
1165
1212
|
}
|
|
1166
1213
|
)
|
|
1167
1214
|
continue
|
|
1215
|
+
if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
|
|
1216
|
+
stalls += 1
|
|
1217
|
+
self.messages.pop() # nothing to keep: no answer, and circles would only fill the context
|
|
1218
|
+
if stalls < MAX_STALLS:
|
|
1219
|
+
self.console.print(
|
|
1220
|
+
Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
|
|
1221
|
+
)
|
|
1222
|
+
self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
|
|
1223
|
+
continue
|
|
1224
|
+
why = "its replies went round in circles or ended without an answer, twice in this request"
|
|
1225
|
+
if not escalated and self.can_escalate():
|
|
1226
|
+
escalated = True
|
|
1227
|
+
advice = escalate.run(self, why)
|
|
1228
|
+
if advice:
|
|
1229
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1230
|
+
stalls = 0
|
|
1231
|
+
continue
|
|
1232
|
+
self.turn_status = "loop"
|
|
1233
|
+
self.console.print(
|
|
1234
|
+
Text(
|
|
1235
|
+
"Stopped: the model's replies went round in circles or ended without an answer, twice in "
|
|
1236
|
+
"this request. Try again with a smaller step, or tell it what to do differently.",
|
|
1237
|
+
style="yellow",
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
return
|
|
1168
1241
|
if not calls:
|
|
1242
|
+
if self.reply_trouble == "cut_off":
|
|
1243
|
+
self.say(
|
|
1244
|
+
"The answer was cut off: the context window is full. /compact or a larger /context helps.",
|
|
1245
|
+
warning=True,
|
|
1246
|
+
)
|
|
1169
1247
|
if self.steering is not None and self.steering.waiting():
|
|
1170
1248
|
continue # the user added a note while the model wrote its answer: it reads the note first
|
|
1249
|
+
unrun = [] if scripts_noted else self.unrun_scripts(commands)
|
|
1250
|
+
if unrun:
|
|
1251
|
+
scripts_noted = True
|
|
1252
|
+
self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
|
|
1253
|
+
self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
|
|
1254
|
+
continue
|
|
1171
1255
|
check = self.project_check() if changed and not verified else None
|
|
1172
1256
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1173
1257
|
check_rounds += 1
|
|
@@ -1247,6 +1331,21 @@ class Agent:
|
|
|
1247
1331
|
)
|
|
1248
1332
|
if verdict.stop:
|
|
1249
1333
|
looping = self.describe_call(name, args)
|
|
1334
|
+
out_of_reach = self.sites.check(name, args, result)
|
|
1335
|
+
if out_of_reach.note and not looping:
|
|
1336
|
+
result += out_of_reach.note
|
|
1337
|
+
what = "stopping the request" if out_of_reach.stop else "told the model"
|
|
1338
|
+
self.console.print(
|
|
1339
|
+
Text(
|
|
1340
|
+
f" ⎿ {out_of_reach.calls} calls to {out_of_reach.site} failed "
|
|
1341
|
+
f"({out_of_reach.what}), none worked: {what}",
|
|
1342
|
+
style="yellow",
|
|
1343
|
+
)
|
|
1344
|
+
)
|
|
1345
|
+
if out_of_reach.stop:
|
|
1346
|
+
blocked = out_of_reach
|
|
1347
|
+
if name == "bash":
|
|
1348
|
+
commands.append(str(args.get("command", "")))
|
|
1250
1349
|
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1251
1350
|
changed, verified = True, False
|
|
1252
1351
|
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
@@ -1264,7 +1363,7 @@ class Agent:
|
|
|
1264
1363
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
1265
1364
|
}
|
|
1266
1365
|
)
|
|
1267
|
-
if looping:
|
|
1366
|
+
if looping or blocked:
|
|
1268
1367
|
for rest in calls[i + 1 :]:
|
|
1269
1368
|
skipped = {
|
|
1270
1369
|
"role": "tool",
|
|
@@ -1283,6 +1382,18 @@ class Agent:
|
|
|
1283
1382
|
self.repeats.reset()
|
|
1284
1383
|
looping = ""
|
|
1285
1384
|
continue
|
|
1385
|
+
if blocked:
|
|
1386
|
+
self.turn_status = "blocked"
|
|
1387
|
+
self.console.print(
|
|
1388
|
+
Text(
|
|
1389
|
+
f"Stopped: every request to {blocked.site} failed ({blocked.calls} calls: {blocked.what}) and "
|
|
1390
|
+
"none worked, so this machine probably can't reach it: a firewall, a proxy, a regional block "
|
|
1391
|
+
"or no internet. Check the connection (another network or a VPN, a proxy setting), or tell "
|
|
1392
|
+
"the model another way to get what it needs.",
|
|
1393
|
+
style="yellow",
|
|
1394
|
+
)
|
|
1395
|
+
)
|
|
1396
|
+
return
|
|
1286
1397
|
if looping:
|
|
1287
1398
|
self.turn_status = "loop"
|
|
1288
1399
|
self.console.print(
|
|
@@ -285,7 +285,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
285
285
|
|
|
286
286
|
try:
|
|
287
287
|
agent.run_turn(prompt)
|
|
288
|
-
status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
|
|
288
|
+
status = agent.turn_status if agent.turn_status in ("max_steps", "loop", "blocked") else "success"
|
|
289
289
|
except KeyboardInterrupt:
|
|
290
290
|
status, error = "interrupted", "interrupted before the request was done"
|
|
291
291
|
except ModelError as e:
|
|
@@ -302,6 +302,7 @@ class OpenAICompatible:
|
|
|
302
302
|
calls: dict[int, dict] = {}
|
|
303
303
|
usage: dict = {}
|
|
304
304
|
timings: dict = {} # llama-server measures prompt and generation time itself
|
|
305
|
+
finished = "" # why the reply ended, as Ollama's done_reason: "length" when it was cut off
|
|
305
306
|
splitter = ThinkSplitter()
|
|
306
307
|
first_token = None
|
|
307
308
|
with r:
|
|
@@ -323,6 +324,7 @@ class OpenAICompatible:
|
|
|
323
324
|
usage = chunk.get("usage") or usage
|
|
324
325
|
timings = chunk.get("timings") or timings
|
|
325
326
|
for choice in chunk.get("choices") or []:
|
|
327
|
+
finished = choice.get("finish_reason") or finished
|
|
326
328
|
delta = choice.get("delta") or {}
|
|
327
329
|
thinking = delta.get("reasoning_content") or delta.get("reasoning")
|
|
328
330
|
if thinking:
|
|
@@ -356,6 +358,7 @@ class OpenAICompatible:
|
|
|
356
358
|
"prompt_eval_duration": first_token - started,
|
|
357
359
|
"eval_count": int(usage.get("completion_tokens") or 0),
|
|
358
360
|
"eval_duration": max(1, ended - first_token),
|
|
361
|
+
"done_reason": "length" if finished == "length" else "stop",
|
|
359
362
|
}
|
|
360
363
|
if timings.get("prompt_ms") is not None and timings.get("predicted_ms"):
|
|
361
364
|
# The whole prompt counts (it's what fills the context), cached tokens included.
|
|
@@ -904,7 +904,7 @@ def run_task(
|
|
|
904
904
|
if agent is None:
|
|
905
905
|
agent = Agent(ollama, settings, folder, console=log)
|
|
906
906
|
else: # the same conversation moves on to this task's folder, as with /cd
|
|
907
|
-
agent.cwd = folder.resolve()
|
|
907
|
+
agent.cwd = agent.workspace = folder.resolve()
|
|
908
908
|
agent.messages[0]["content"] = agent.system_prompt()
|
|
909
909
|
if settings.embed_model != "off": # --code-search: index the task's folder first, outside the timer
|
|
910
910
|
from lcode import codesearch
|
|
@@ -158,3 +158,74 @@ def missing(check: Check, result: str) -> bool:
|
|
|
158
158
|
return True
|
|
159
159
|
head = result.splitlines()[:5] # a missing program says so first; test failures look different
|
|
160
160
|
return code not in (0, None) and any(MISSING.search(line) for line in head)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------------------------- scripts written, never run
|
|
164
|
+
|
|
165
|
+
SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh")
|
|
166
|
+
SHELL_SHEBANG = re.compile(r"^#!\S*?(?:/|env\s+)(?:ba|z|da|k)?sh\b")
|
|
167
|
+
RUNNERS = frozenset({"bash", "sh", "zsh", "dash", "ksh", "source", ".", "exec"})
|
|
168
|
+
PREFIXES = frozenset({"sudo", "time", "nohup", "env", "command", "nice"})
|
|
169
|
+
SEGMENTS = re.compile(r"&&|\|\||[;|\n&()]")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def unrun_scripts(root: Path, changed: list[str], commands: list[str]) -> list[str]:
|
|
173
|
+
"""Shell scripts written in this request that no command in it ran.
|
|
174
|
+
|
|
175
|
+
Small models write a build or install script and call it done without running it once; the first thing
|
|
176
|
+
the user then sees is its first error. Only shell scripts count (a .sh file, or a shell shebang): a Python
|
|
177
|
+
file with a shebang is as likely an app or a server that never ends.
|
|
178
|
+
"""
|
|
179
|
+
unrun = []
|
|
180
|
+
for rel in changed:
|
|
181
|
+
path = root / rel
|
|
182
|
+
if not is_shell_script(path):
|
|
183
|
+
continue
|
|
184
|
+
if not any(runs(command, path.name) for command in commands):
|
|
185
|
+
unrun.append(rel)
|
|
186
|
+
return unrun
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_shell_script(path: Path) -> bool:
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
return False
|
|
192
|
+
if path.suffix in SCRIPT_SUFFIXES:
|
|
193
|
+
return True
|
|
194
|
+
if path.suffix:
|
|
195
|
+
return False
|
|
196
|
+
try:
|
|
197
|
+
with path.open("rb") as f:
|
|
198
|
+
first = f.readline(200).decode("utf-8", "replace")
|
|
199
|
+
except OSError:
|
|
200
|
+
return False
|
|
201
|
+
return bool(SHELL_SHEBANG.match(first))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def runs(command: str, name: str) -> bool:
|
|
205
|
+
"""Whether a shell command runs the script called `name` (not just chmods, cats or commits it)."""
|
|
206
|
+
for segment in SEGMENTS.split(command):
|
|
207
|
+
words = segment.split()
|
|
208
|
+
while words and (("=" in words[0] and not words[0].startswith("=")) or words[0] in PREFIXES):
|
|
209
|
+
words.pop(0)
|
|
210
|
+
if words and words[0] == "timeout":
|
|
211
|
+
words = words[2:] if len(words) > 2 else []
|
|
212
|
+
if not words:
|
|
213
|
+
continue
|
|
214
|
+
program = words[0].strip("'\"")
|
|
215
|
+
if program in RUNNERS:
|
|
216
|
+
rest = [w.strip("'\"") for w in words[1:] if not w.startswith("-")]
|
|
217
|
+
program = rest[0] if rest else ""
|
|
218
|
+
if Path(program).name == name:
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def unrun_note(scripts: list[str]) -> str:
|
|
224
|
+
names = ", ".join(scripts)
|
|
225
|
+
it = "it" if len(scripts) == 1 else "them"
|
|
226
|
+
return (
|
|
227
|
+
f"[lcode] You wrote {names} in this request but never ran {it}. Run {it} now and fix what fails, so you know "
|
|
228
|
+
f"{it} works before you say so. If running {it} would change things outside the project (install packages, "
|
|
229
|
+
f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
|
|
230
|
+
"and why."
|
|
231
|
+
)
|