lcode-cli 0.34.0__tar.gz → 0.35.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/CHANGELOG.md +62 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/PKG-INFO +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/automation.md +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/bench.md +16 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/configuration.md +4 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/how-it-works.md +31 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/quickstart.md +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/usage.md +56 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/pyproject.toml +1 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/agent.py +150 -10
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/api.py +6 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/backends.py +10 -2
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/bench.py +54 -18
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/checks.py +71 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/cli.py +22 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/config.py +24 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/context.py +40 -15
- lcode_cli-0.35.1/src/lcode/escalate.py +315 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/repeats.py +37 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/repl.py +39 -2
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/screenshots.py +21 -9
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/subagents.py +6 -5
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/tools.py +12 -3
- lcode_cli-0.35.1/src/lcode/unreachable.py +186 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/conftest.py +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_backends.py +9 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_bench.py +2 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_context.py +35 -0
- lcode_cli-0.35.1/tests/test_escalate.py +333 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_screenshots.py +37 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_sessions.py +2 -2
- lcode_cli-0.35.1/tests/test_stuck.py +250 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_subagents.py +1 -1
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.editorconfig +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/dependabot.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.gitignore +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/AGENTS.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/LICENSE +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/README.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/SECURITY.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/action.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/agents.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/assets/extra.css +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/changelog.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/commands.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/contributing.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/editors.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/git.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/hooks.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/index.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/installation.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/lsp.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/mcp.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/memory.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/models.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/sandbox.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/search.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/servers.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/examples/README.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/examples/commands/review.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/install.sh +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/mkdocs.yml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/overrides/main.html +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/scripts/make_logos.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/scripts/record_demo.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/scripts/social-preview.html +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/acp.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/action.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/limits.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/matching.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/memory.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/models.toml +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/notify.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/planning.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/render.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/residency.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/review.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/steer.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/update.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/vision.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/src/lcode/web.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_acp.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_action.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_agent.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_api.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_catalog.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_checks.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_cli.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_config.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_extensions.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_hooks.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_jobs.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_lsp.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_matching.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_mcp.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_memory.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_permissions.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_repeats.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_repomap.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_residency.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_review.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_robustness.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_servers.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_steer.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_tools.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_update.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_vision.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_web.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/tests/test_xai.py +0 -0
- {lcode_cli-0.34.0 → lcode_cli-0.35.1}/uv.lock +0 -0
|
@@ -6,6 +6,65 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.35.1] - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
Found in a real session that spent an hour on an Android build whose downloads this machine
|
|
14
|
+
couldn't reach, then ended without a word:
|
|
15
|
+
|
|
16
|
+
- **A site this machine can't reach** now stops the guessing. lcode tracks, per site, the calls that
|
|
17
|
+
failed to reach it (HTTP errors, names that don't resolve, refused or timed-out connections) and
|
|
18
|
+
the ones that got an answer. After three different failed calls and none that worked, the model is
|
|
19
|
+
told to check one address that must exist and, if that fails too, to tell you what fails and what
|
|
20
|
+
you could do. Two more and lcode stops the request (status `blocked`). Before, every new address
|
|
21
|
+
looked like a new attempt, so nothing stepped in.
|
|
22
|
+
- **Thinking in circles** is stopped as it streams. A model that repeats the same few paragraphs
|
|
23
|
+
used to think until the context was full (here 70,000 characters, about ten minutes behind the
|
|
24
|
+
spinner). Now the reply is dropped once its sentences keep repeating, and the model is asked for
|
|
25
|
+
its next step or what's blocking it.
|
|
26
|
+
- **A reply with no answer** no longer ends the request as if it were done. An empty reply, or one
|
|
27
|
+
that filled the context before it said anything, gets a second chance; the second time lcode stops
|
|
28
|
+
(status `loop`) and offers the stronger model first, when one is set. An answer the context cut off
|
|
29
|
+
midway now says so. OpenAI-compatible servers report a cut-off reply too.
|
|
30
|
+
- **A `cd` out of the project** no longer moves lcode there. A model that ran `cd /tmp` took the
|
|
31
|
+
session, the project checks and the memory notes with it, and the session was saved under `/tmp`,
|
|
32
|
+
so `lcode -c` in the project couldn't find it. Now a `cd` within the project is kept, and one out
|
|
33
|
+
of it isn't: the next command starts in the project again. Sessions are saved under the folder
|
|
34
|
+
lcode works in.
|
|
35
|
+
- **Scripts it never ran** are sent back once before the answer counts: a model that wrote
|
|
36
|
+
`build_android.sh` and called the job done never ran it, and its first line failed. It now runs it
|
|
37
|
+
and fixes what fails, or says plainly that it's untested and why.
|
|
38
|
+
|
|
39
|
+
## [0.35.0] - 2026-10-14
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- A stronger model when stuck: with `escalate_model` set (a cloud model with your API key, such as
|
|
44
|
+
`anthropic/…`, `openai/…` or `xai/…`, or a bigger local one, `ollama/qwen3.8-27b`), lcode
|
|
45
|
+
offers to ask it for advice when the loop breaker would stop a request or the checks fail twice
|
|
46
|
+
in a row, and `/escalate` asks at any time. Nothing is sent without asking, in any permission
|
|
47
|
+
mode: you see why, what goes (your requests, the task sheet, the failure, the last steps and the
|
|
48
|
+
files in question, never the whole session), where, and the cost (`escalate_price`), and can
|
|
49
|
+
read it all first. Keys come only from environment variables and go only to their provider.
|
|
50
|
+
`lcode -p --escalate` allows it for one run, and `lcode bench --retry --escalate MODEL`
|
|
51
|
+
measures it. Measured on a 12 GB laptop with qwen3.6-35b and the bigger local qwen3.8-27b as
|
|
52
|
+
the advisor, on `runtime-bug` with a second try: 1 of 3 passed with its advice, as without it
|
|
53
|
+
(6 of 9 hidden-test problems fixed against 4), at about 9 minutes per question. A local advisor
|
|
54
|
+
helps a little; a cloud model is the one to try. See
|
|
55
|
+
[A stronger model when stuck](https://nasser1941.github.io/lcode/usage/#a-stronger-model-when-stuck).
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Long command output can't fill the disk any more. lcode keeps the full text of long outputs so the
|
|
60
|
+
model can read parts of them, but a command that printed gigabytes was saved whole, and the
|
|
61
|
+
saved outputs had no total limit, only an age (7 days). Now one is kept to its first 20 MB (the
|
|
62
|
+
model still gets the end and the error lines), and all of them together to 200 MB, oldest first.
|
|
63
|
+
|
|
64
|
+
- A screenshot of a web app no longer starts its server a second time when one already answers at
|
|
65
|
+
the URL (one the model started itself, say), which used to fail on the busy port and put the
|
|
66
|
+
error in the result. A page that answers also counts when the server command itself exits.
|
|
67
|
+
|
|
9
68
|
## [0.34.0] - 2026-10-14
|
|
10
69
|
|
|
11
70
|
### Added
|
|
@@ -740,7 +799,9 @@ First public release.
|
|
|
740
799
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
741
800
|
- One-line installer for Linux and macOS.
|
|
742
801
|
|
|
743
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
802
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.35.1...HEAD
|
|
803
|
+
[0.35.1]: https://github.com/nasser1941/lcode/compare/v0.35.0...v0.35.1
|
|
804
|
+
[0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
|
|
744
805
|
[0.34.0]: https://github.com/nasser1941/lcode/compare/v0.33.1...v0.34.0
|
|
745
806
|
[0.33.1]: https://github.com/nasser1941/lcode/compare/v0.33.0...v0.33.1
|
|
746
807
|
[0.33.0]: https://github.com/nasser1941/lcode/compare/v0.32.0...v0.33.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.1
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -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 |
|
|
@@ -45,6 +45,22 @@ cause. Neither is in the default set, so results stay comparable with earlier ve
|
|
|
45
45
|
`--code-search` indexes each task's folder for [semantic search](search.md#semantic-code-search);
|
|
46
46
|
both are off in `lcode bench` by default, for the same reason.
|
|
47
47
|
|
|
48
|
+
### A second try, and a stronger model
|
|
49
|
+
|
|
50
|
+
`--retry` gives a failed task one more try, as a user would ask: "That didn't fix it: it still
|
|
51
|
+
doesn't work right. Look again." `--escalate MODEL` sets a [stronger model](usage.md#a-stronger-model-when-stuck)
|
|
52
|
+
that's asked when the model is stuck and, with `--retry`, before the second try, as `/escalate`
|
|
53
|
+
would: no questions asked, since the flag is your OK. Comparing the two shows what the stronger
|
|
54
|
+
model's advice adds to the same second chance:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
lcode bench --tasks runtime-bug --retry --rounds 3
|
|
58
|
+
lcode bench --tasks runtime-bug --retry --rounds 3 --escalate ollama/qwen3.8-27b
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The JSON report has, for each task, `first_passed` (with `--retry`, whether the first try passed)
|
|
62
|
+
and `escalations`.
|
|
63
|
+
|
|
48
64
|
### Long sessions
|
|
49
65
|
|
|
50
66
|
Each task normally starts a new conversation. `--session` runs them all in **one** conversation
|
|
@@ -45,7 +45,10 @@ lcode config path # print the file location
|
|
|
45
45
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
46
46
|
| `review` | `off` | Before the answer, [review a change](usage.md#a-review-before-the-answer) against the request and send problems back to the model: `auto` or `off` |
|
|
47
47
|
| `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 |
|
|
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
|
+
| `escalate_url` | — | Its server, when it isn't the provider's usual one: another Ollama, or any OpenAI-compatible API |
|
|
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, 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) |
|
|
49
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)) |
|
|
50
53
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
51
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
|
|
|
@@ -83,6 +83,7 @@ reasoning is on.
|
|
|
83
83
|
| `/cd DIR` | Change the working directory |
|
|
84
84
|
| `/todos` | Show the model's task list |
|
|
85
85
|
| `/status` | The task sheet: your latest requests, the plan and the model's notes, files changed, the last check |
|
|
86
|
+
| `/escalate` | Ask the [stronger model](#a-stronger-model-when-stuck) for advice on the work, with your OK; `/escalate <what's wrong>` says what to look at |
|
|
86
87
|
| `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one |
|
|
87
88
|
| `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it |
|
|
88
89
|
| `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base |
|
|
@@ -100,7 +101,7 @@ reasoning is on.
|
|
|
100
101
|
| `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
|
|
101
102
|
| `glob` | Find files by pattern, newest first |
|
|
102
103
|
| `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
|
|
103
|
-
| `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)) |
|
|
104
105
|
| `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it |
|
|
105
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 |
|
|
106
107
|
| `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
|
|
@@ -182,6 +183,13 @@ command = "uv run pytest -q && uv run ruff check ."
|
|
|
182
183
|
Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
|
|
183
184
|
turns checks off.
|
|
184
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
|
+
|
|
185
193
|
## A review before the answer
|
|
186
194
|
|
|
187
195
|
Models claim more than they did: half of a two-part request done, "it works now" without having
|
|
@@ -208,6 +216,53 @@ The review is asked on top of the conversation rather than in a fresh one. The m
|
|
|
208
216
|
the processed conversation cached, and a separate review would push it out, so the next request
|
|
209
217
|
would have to read the whole conversation again. On a small GPU that's tens of seconds.
|
|
210
218
|
|
|
219
|
+
## A stronger model when stuck
|
|
220
|
+
|
|
221
|
+
Some problems are beyond a local model. When it's stuck, lcode can ask a stronger one for
|
|
222
|
+
advice: a cloud model with your API key, or a bigger local model. The local model gets the advice
|
|
223
|
+
and carries on with its own tools. It's off until you choose the model:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
lcode config set escalate_model anthropic/<model> # with ANTHROPIC_API_KEY in your environment
|
|
227
|
+
lcode config set escalate_model openai/<model> # OPENAI_API_KEY
|
|
228
|
+
lcode config set escalate_model xai/<model> # XAI_API_KEY
|
|
229
|
+
lcode config set escalate_model ollama/qwen3.8-27b # a bigger local model: free, and nothing leaves
|
|
230
|
+
lcode config set escalate_price 3/15 # dollars per million tokens in/out, for the cost
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**When.** lcode offers it when the [loop breaker](how-it-works.md#when-the-model-repeats-itself)
|
|
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
|
|
238
|
+
`/escalate the window still grows` to say what's wrong.
|
|
239
|
+
|
|
240
|
+
**Nothing is sent without asking**, in any permission mode:
|
|
241
|
+
|
|
242
|
+
```text
|
|
243
|
+
╭─ Ask claude-… for help? ──────────────────────────────────────────────────╮
|
|
244
|
+
│ Why: the project's checks (`pytest -q`) failed 2 times in a row │
|
|
245
|
+
│ What goes: your requests, the task sheet, the failure, the last steps │
|
|
246
|
+
│ and these files: wall.py, simulate.py (3.1K tokens) │
|
|
247
|
+
│ Where: claude-… (Anthropic), at api.anthropic.com │
|
|
248
|
+
│ Cost: about $0.009 to $0.071 (3.1K tokens in at $3/M, up to 4.1K out …) │
|
|
249
|
+
╰───────────────────────────────────────────────────────────────────────────╯
|
|
250
|
+
Send it? [y]es / [n]o / [s]how what's sent:
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- **What goes:** your last requests, the [task sheet](how-it-works.md#context-management), why
|
|
254
|
+
it's stuck (the failing check's output, the repeated call), the last few steps, and the files the
|
|
255
|
+
request changed or read: never the whole session or the rest of the repository. Press `s` to read
|
|
256
|
+
all of it before you decide.
|
|
257
|
+
- **Keys** come from environment variables, are read when needed, and go only to their own
|
|
258
|
+
provider. lcode never stores them.
|
|
259
|
+
- **Cost.** The token count is always shown; the price only when you set `escalate_price`, since
|
|
260
|
+
prices change. A local model is free.
|
|
261
|
+
- **Another server:** `escalate_url` points at another Ollama (`ollama/<model>`) or any OpenAI
|
|
262
|
+
API (`custom/<model>`, key in `LCODE_ESCALATE_API_KEY`).
|
|
263
|
+
- `lcode -p` can't ask, so it doesn't escalate unless you add `--escalate`, which is your OK for
|
|
264
|
+
that run. `--output json` lists what was asked in `escalations`.
|
|
265
|
+
|
|
211
266
|
## Background commands
|
|
212
267
|
|
|
213
268
|
Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
|
|
@@ -82,6 +82,7 @@ ignore = [
|
|
|
82
82
|
"src/lcode/subagents.py" = ["E501"]
|
|
83
83
|
"src/lcode/planning.py" = ["E501"]
|
|
84
84
|
"src/lcode/review.py" = ["E501"] # the review prompt is prose
|
|
85
|
+
"src/lcode/escalate.py" = ["E501"] # the advisor prompt is prose
|
|
85
86
|
"src/lcode/extensions.py" = ["E501"]
|
|
86
87
|
"src/lcode/context.py" = ["E501"]
|
|
87
88
|
"src/lcode/lsp.py" = ["E501"]
|
|
@@ -29,6 +29,7 @@ from lcode import (
|
|
|
29
29
|
catalog,
|
|
30
30
|
checks,
|
|
31
31
|
codesearch,
|
|
32
|
+
escalate,
|
|
32
33
|
extensions,
|
|
33
34
|
limits,
|
|
34
35
|
notify,
|
|
@@ -50,7 +51,7 @@ from lcode.mcp import McpManager
|
|
|
50
51
|
from lcode.ollama import Ollama, OllamaError
|
|
51
52
|
from lcode.permissions import LEADING_CD, Permissions
|
|
52
53
|
from lcode.render import MarkdownStreamer
|
|
53
|
-
from lcode.repeats import Repeats
|
|
54
|
+
from lcode.repeats import Circles, Repeats
|
|
54
55
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
55
56
|
from lcode.steer import NOTE_INTRO, Steering
|
|
56
57
|
from lcode.tools import (
|
|
@@ -70,6 +71,7 @@ from lcode.tools import (
|
|
|
70
71
|
tree,
|
|
71
72
|
truncate,
|
|
72
73
|
)
|
|
74
|
+
from lcode.unreachable import NOTE_AT, Sites
|
|
73
75
|
|
|
74
76
|
GPU_MEMORY_ERRORS = (
|
|
75
77
|
"out of memory",
|
|
@@ -79,6 +81,27 @@ GPU_MEMORY_ERRORS = (
|
|
|
79
81
|
)
|
|
80
82
|
SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
|
|
81
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
|
+
|
|
82
105
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
83
106
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
84
107
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
@@ -307,6 +330,9 @@ class Settings:
|
|
|
307
330
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
308
331
|
checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
|
|
309
332
|
review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
|
|
333
|
+
escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
|
|
334
|
+
escalate_url: str | None = None # its server, when not the provider's usual one
|
|
335
|
+
escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
|
|
310
336
|
|
|
311
337
|
|
|
312
338
|
class Agent:
|
|
@@ -315,6 +341,9 @@ class Agent:
|
|
|
315
341
|
self.ollama = ollama
|
|
316
342
|
self.settings = settings
|
|
317
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
|
|
318
347
|
self.perms = Permissions(self.console, settings.permission_mode)
|
|
319
348
|
self.tools = Toolbox(self)
|
|
320
349
|
self.session_id = self.new_session_id()
|
|
@@ -369,10 +398,14 @@ class Agent:
|
|
|
369
398
|
self._check: tuple[Path, checks.Check | None] | None = None
|
|
370
399
|
self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
|
|
371
400
|
self.review_results: list[str] | None = None # what the last request's review found (None: no review)
|
|
401
|
+
self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
|
|
402
|
+
self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
|
|
372
403
|
self.sent_chars = 0 # characters of the conversation the model had already read
|
|
373
404
|
self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
|
|
374
|
-
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
|
|
375
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 ""
|
|
376
409
|
self.response = None # the streaming response, so another thread can abort it
|
|
377
410
|
self.ctx_used = 0
|
|
378
411
|
self.last_speed = 0.0
|
|
@@ -642,7 +675,7 @@ class Agent:
|
|
|
642
675
|
f = self.session_file()
|
|
643
676
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
644
677
|
data = {
|
|
645
|
-
"cwd": str(self.
|
|
678
|
+
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
646
679
|
"model": self.settings.model,
|
|
647
680
|
"name": self.session_name,
|
|
648
681
|
"title": self.session_title,
|
|
@@ -681,7 +714,7 @@ class Agent:
|
|
|
681
714
|
saved_cwd = Path(info.cwd) if info.cwd else self.cwd
|
|
682
715
|
if saved_cwd != self.cwd:
|
|
683
716
|
if saved_cwd.is_dir():
|
|
684
|
-
self.cwd = saved_cwd.resolve()
|
|
717
|
+
self.cwd = self.workspace = saved_cwd.resolve()
|
|
685
718
|
note = f"Working directory is now {self.cwd}"
|
|
686
719
|
else:
|
|
687
720
|
note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
|
|
@@ -701,7 +734,7 @@ class Agent:
|
|
|
701
734
|
return note
|
|
702
735
|
|
|
703
736
|
def load_latest(self) -> bool:
|
|
704
|
-
latest = sessions.list_sessions(self.
|
|
737
|
+
latest = sessions.list_sessions(self.workspace, limit=1)
|
|
705
738
|
if latest:
|
|
706
739
|
self.load(latest[0])
|
|
707
740
|
return bool(latest)
|
|
@@ -799,8 +832,11 @@ class Agent:
|
|
|
799
832
|
status = None
|
|
800
833
|
|
|
801
834
|
start_spinner()
|
|
835
|
+
circles = Circles()
|
|
836
|
+
self.reply_trouble = ""
|
|
837
|
+
stream = self.chat(self.messages, self.tool_schemas(), self.settings.think)
|
|
802
838
|
try:
|
|
803
|
-
for chunk in
|
|
839
|
+
for chunk in stream:
|
|
804
840
|
if self.cancel is not None and self.cancel.is_set():
|
|
805
841
|
raise KeyboardInterrupt
|
|
806
842
|
msg = chunk.get("message", {})
|
|
@@ -816,6 +852,9 @@ class Agent:
|
|
|
816
852
|
stop_spinner() # raw text is printed as it streams
|
|
817
853
|
self.console.print(Text(msg["thinking"], style="dim italic"), end="")
|
|
818
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
|
|
819
858
|
if msg.get("content"):
|
|
820
859
|
if printed_thinking:
|
|
821
860
|
self.console.print("\n")
|
|
@@ -840,9 +879,12 @@ class Agent:
|
|
|
840
879
|
self.messages.append({"role": "assistant", "content": content + "\n[interrupted by user]"})
|
|
841
880
|
raise
|
|
842
881
|
finally:
|
|
882
|
+
stream.close()
|
|
843
883
|
stop_spinner()
|
|
844
884
|
if watch is not None:
|
|
845
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
|
|
846
888
|
if printed_thinking:
|
|
847
889
|
self.console.print()
|
|
848
890
|
md.flush()
|
|
@@ -912,6 +954,12 @@ class Agent:
|
|
|
912
954
|
"is final, and gives you the failures to fix. Run them yourself while you work, too."
|
|
913
955
|
)
|
|
914
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
|
+
|
|
915
963
|
def project_check(self) -> checks.Check | None:
|
|
916
964
|
"""The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
|
|
917
965
|
if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
|
|
@@ -952,9 +1000,8 @@ class Agent:
|
|
|
952
1000
|
)
|
|
953
1001
|
return None
|
|
954
1002
|
if round_ >= checks.MAX_ROUNDS:
|
|
955
|
-
self.say
|
|
956
|
-
|
|
957
|
-
)
|
|
1003
|
+
then = "" if self.can_escalate() else ": the model will say what's left"
|
|
1004
|
+
self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
|
|
958
1005
|
else:
|
|
959
1006
|
self.say(
|
|
960
1007
|
f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
|
|
@@ -1101,14 +1148,21 @@ class Agent:
|
|
|
1101
1148
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
1102
1149
|
self.turn_requests = [user_text.strip()]
|
|
1103
1150
|
self.repeats.reset()
|
|
1151
|
+
self.sites.reset()
|
|
1104
1152
|
self.check_results = []
|
|
1105
1153
|
self.review_results = None
|
|
1154
|
+
self.escalations = []
|
|
1155
|
+
escalated = False # the stronger model is asked at most once per request
|
|
1106
1156
|
self.tools.before = {}
|
|
1107
1157
|
reviewed = False
|
|
1108
1158
|
changed = verified = False # files changed in this request; the checks passed since
|
|
1109
1159
|
check_rounds = 0
|
|
1110
1160
|
malformed = 0
|
|
1111
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
|
|
1112
1166
|
for step in range(self.max_steps):
|
|
1113
1167
|
if self.cancel is not None and self.cancel.is_set():
|
|
1114
1168
|
raise KeyboardInterrupt
|
|
@@ -1158,14 +1212,60 @@ class Agent:
|
|
|
1158
1212
|
}
|
|
1159
1213
|
)
|
|
1160
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
|
|
1161
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
|
+
)
|
|
1162
1247
|
if self.steering is not None and self.steering.waiting():
|
|
1163
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
|
|
1164
1255
|
check = self.project_check() if changed and not verified else None
|
|
1165
1256
|
if check is not None and check_rounds < checks.MAX_ROUNDS:
|
|
1166
1257
|
check_rounds += 1
|
|
1167
1258
|
feedback = self.run_check(check, check_rounds)
|
|
1168
1259
|
if feedback:
|
|
1260
|
+
if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
|
|
1261
|
+
escalated = True
|
|
1262
|
+
why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
|
|
1263
|
+
advice = escalate.run(self, why, failure=feedback)
|
|
1264
|
+
if advice:
|
|
1265
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1266
|
+
check_rounds -= 1 # the checks run once more after the advice
|
|
1267
|
+
changed = False
|
|
1268
|
+
continue
|
|
1169
1269
|
self.messages.append({"role": "user", "content": feedback})
|
|
1170
1270
|
changed = False # run them again only if the model changes something more
|
|
1171
1271
|
continue
|
|
@@ -1231,6 +1331,21 @@ class Agent:
|
|
|
1231
1331
|
)
|
|
1232
1332
|
if verdict.stop:
|
|
1233
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", "")))
|
|
1234
1349
|
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1235
1350
|
changed, verified = True, False
|
|
1236
1351
|
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
@@ -1248,7 +1363,7 @@ class Agent:
|
|
|
1248
1363
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
1249
1364
|
}
|
|
1250
1365
|
)
|
|
1251
|
-
if looping:
|
|
1366
|
+
if looping or blocked:
|
|
1252
1367
|
for rest in calls[i + 1 :]:
|
|
1253
1368
|
skipped = {
|
|
1254
1369
|
"role": "tool",
|
|
@@ -1259,6 +1374,26 @@ class Agent:
|
|
|
1259
1374
|
skipped["tool_call_id"] = rest["id"]
|
|
1260
1375
|
self.messages.append(skipped)
|
|
1261
1376
|
break
|
|
1377
|
+
if looping and not escalated and self.can_escalate():
|
|
1378
|
+
escalated = True
|
|
1379
|
+
advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
|
|
1380
|
+
if advice:
|
|
1381
|
+
self.messages.append({"role": "user", "content": advice})
|
|
1382
|
+
self.repeats.reset()
|
|
1383
|
+
looping = ""
|
|
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
|
|
1262
1397
|
if looping:
|
|
1263
1398
|
self.turn_status = "loop"
|
|
1264
1399
|
self.console.print(
|
|
@@ -1276,6 +1411,11 @@ class Agent:
|
|
|
1276
1411
|
self.turn_status = "success"
|
|
1277
1412
|
self.print_stats()
|
|
1278
1413
|
|
|
1414
|
+
def can_escalate(self) -> bool:
|
|
1415
|
+
"""Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
|
|
1416
|
+
model = (self.settings.escalate_model or "").strip().lower()
|
|
1417
|
+
return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
|
|
1418
|
+
|
|
1279
1419
|
def steer(self) -> None:
|
|
1280
1420
|
"""Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
|
|
1281
1421
|
steering = self.steering
|
|
@@ -78,6 +78,7 @@ class Result:
|
|
|
78
78
|
usage: dict = field(default_factory=dict) # requests, prompt_tokens, output_tokens
|
|
79
79
|
checks: list[dict] = field(default_factory=list) # {"command", "exit_code", "by": "lcode" | "model"}
|
|
80
80
|
review: list[str] | None = None # problems the review sent back ([] when it found none; None: no review)
|
|
81
|
+
escalations: list[dict] = field(default_factory=list) # a stronger model asked: model, why, tokens, seconds
|
|
81
82
|
seconds: float = 0.0
|
|
82
83
|
error: str = ""
|
|
83
84
|
|
|
@@ -200,6 +201,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
201
|
repeat_limit=cfg["repeat_limit"],
|
|
201
202
|
checks=cfg["checks"],
|
|
202
203
|
review=cfg["review"],
|
|
204
|
+
escalate_model=cfg["escalate_model"],
|
|
205
|
+
escalate_url=cfg["escalate_url"],
|
|
206
|
+
escalate_price=cfg["escalate_price"],
|
|
203
207
|
compact_at=cfg["compact_at"],
|
|
204
208
|
)
|
|
205
209
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
@@ -281,7 +285,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
281
285
|
|
|
282
286
|
try:
|
|
283
287
|
agent.run_turn(prompt)
|
|
284
|
-
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"
|
|
285
289
|
except KeyboardInterrupt:
|
|
286
290
|
status, error = "interrupted", "interrupted before the request was done"
|
|
287
291
|
except ModelError as e:
|
|
@@ -309,6 +313,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
309
313
|
usage=usage,
|
|
310
314
|
checks=list(agent.check_results),
|
|
311
315
|
review=agent.review_results,
|
|
316
|
+
escalations=list(agent.escalations),
|
|
312
317
|
seconds=round(time.monotonic() - started, 2),
|
|
313
318
|
error=error,
|
|
314
319
|
)
|