lcode-cli 0.22.0__tar.gz → 0.23.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.22.0 → lcode_cli-0.23.0}/CHANGELOG.md +19 -1
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/PKG-INFO +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/automation.md +3 -2
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/configuration.md +1 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/how-it-works.md +19 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/acp.py +6 -1
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/action.py +4 -2
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/agent.py +49 -7
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/api.py +5 -4
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/config.py +1 -0
- lcode_cli-0.23.0/src/lcode/repeats.py +104 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/subagents.py +8 -3
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_agent.py +17 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_api.py +8 -0
- lcode_cli-0.23.0/tests/test_repeats.py +88 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_subagents.py +14 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.editorconfig +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.gitignore +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/AGENTS.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/LICENSE +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/README.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/SECURITY.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/action.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/agents.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/bench.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/changelog.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/commands.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/contributing.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/editors.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/git.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/hooks.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/index.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/installation.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/lsp.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/mcp.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/memory.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/models.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/search.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/servers.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/docs/usage.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/examples/README.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/install.sh +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/mkdocs.yml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/overrides/main.html +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/pyproject.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/cli.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/repl.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/conftest.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_action.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_config.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_context.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_update.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_web.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.22.0 → lcode_cli-0.23.0}/uv.lock +0 -0
|
@@ -6,6 +6,23 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.23.0] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- lcode notices when the model goes round in circles, making the same tool call with the same result
|
|
14
|
+
again and again, as small models sometimes do. The third time in a request (the second, for a call
|
|
15
|
+
that failed) the result tells the model so; two more, and lcode ends the request (status `loop`,
|
|
16
|
+
exit code `4` with `lcode -p`). Results that change, and checks run after an edit, don't count.
|
|
17
|
+
`repeat_limit` changes the threshold; `0` turns it off. See
|
|
18
|
+
[When the model repeats itself](https://nasser1941.github.io/lcode/how-it-works/#when-the-model-repeats-itself).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A tool call Ollama can't parse because its XML is broken (`XML syntax error … unexpected EOF`, from
|
|
23
|
+
models such as Qwen3.6 that write their calls in XML) is now sent back to the model to make again,
|
|
24
|
+
like a call with broken JSON, instead of ending the request with an error.
|
|
25
|
+
|
|
9
26
|
## [0.22.0] - 2026-10-06
|
|
10
27
|
|
|
11
28
|
### Added
|
|
@@ -491,7 +508,8 @@ First public release.
|
|
|
491
508
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
492
509
|
- One-line installer for Linux and macOS.
|
|
493
510
|
|
|
494
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
511
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.23.0...HEAD
|
|
512
|
+
[0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
|
|
495
513
|
[0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
|
|
496
514
|
[0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
|
|
497
515
|
[0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.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/
|
|
@@ -23,7 +23,8 @@ lcode -p "Explain the retry logic" --output stream-json --max-steps 20
|
|
|
23
23
|
| `--allowed-tools LIST` | Only these tools, e.g. `"read_file,grep,glob,bash"` |
|
|
24
24
|
|
|
25
25
|
lcode's usual output goes to stderr, so stdout holds only the JSON. The exit code says how it
|
|
26
|
-
went: `0` success, `1` an error, `3` stopped at `--max-steps`, `
|
|
26
|
+
went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped because the model
|
|
27
|
+
[kept repeating itself](how-it-works.md#when-the-model-repeats-itself), `130` interrupted.
|
|
27
28
|
|
|
28
29
|
### The result
|
|
29
30
|
|
|
@@ -49,7 +50,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted
|
|
|
49
50
|
|
|
50
51
|
| Field | |
|
|
51
52
|
|---|---|
|
|
52
|
-
| `status` | `success`, `error`, `max_steps` or `interrupted` |
|
|
53
|
+
| `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call) or `interrupted` |
|
|
53
54
|
| `text` | The model's final answer |
|
|
54
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 |
|
|
55
56
|
| `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
|
|
@@ -42,6 +42,7 @@ lcode config path # print the file location
|
|
|
42
42
|
| `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
|
|
43
43
|
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
|
|
44
44
|
| `notify_after` | `30` | Seconds a request runs before it notifies |
|
|
45
|
+
| `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 |
|
|
45
46
|
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
|
|
46
47
|
|
|
47
48
|
Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
|
|
@@ -29,6 +29,25 @@ All tools return text, and failures come back to the model as `Error: …` messa
|
|
|
29
29
|
crashing the session, so the model can correct itself (for example re-read a file when an edit
|
|
30
30
|
didn't match).
|
|
31
31
|
|
|
32
|
+
### When the model repeats itself
|
|
33
|
+
|
|
34
|
+
Small models sometimes fall into a loop: they make a call, get a result, and make the very same call
|
|
35
|
+
again, and again. lcode counts, within a request, the calls that repeat an earlier one exactly: the
|
|
36
|
+
same tool, the same arguments and the same result.
|
|
37
|
+
|
|
38
|
+
- **At the third one** (the second, when the call failed) the result gets a note: the call returned
|
|
39
|
+
the same thing several times, so the model should use what it got, try something different, or
|
|
40
|
+
tell you what's blocking it.
|
|
41
|
+
- **Two more,** and lcode ends the request with
|
|
42
|
+
`Stopped: the model kept making the same call with the same result`. The JSON output's status is
|
|
43
|
+
`loop`.
|
|
44
|
+
|
|
45
|
+
A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
|
|
46
|
+
that's checked while it runs, never adds up. A call that may have changed something, like an edit or
|
|
47
|
+
a shell command that isn't read-only, starts the count again for the other calls: checking the build
|
|
48
|
+
after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
|
|
49
|
+
with `0`.
|
|
50
|
+
|
|
32
51
|
## Subagents
|
|
33
52
|
|
|
34
53
|
The `agent` tool starts a [subagent](agents.md): another agent loop with its own conversation, the
|
|
@@ -48,7 +48,12 @@ KINDS = {
|
|
|
48
48
|
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
|
|
49
49
|
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
|
|
50
50
|
} # fmt: skip
|
|
51
|
-
STOP = {
|
|
51
|
+
STOP = {
|
|
52
|
+
"success": "end_turn",
|
|
53
|
+
"max_steps": "max_turn_requests",
|
|
54
|
+
"loop": "max_turn_requests",
|
|
55
|
+
"interrupted": "cancelled",
|
|
56
|
+
}
|
|
52
57
|
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
|
|
53
58
|
METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
|
|
54
59
|
|
|
@@ -297,7 +297,7 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
297
297
|
)
|
|
298
298
|
with open_session(inputs, cwd) as session:
|
|
299
299
|
result = session.run(prompt)
|
|
300
|
-
if result.status not in ("success", "max_steps"):
|
|
300
|
+
if result.status not in ("success", "max_steps", "loop"):
|
|
301
301
|
raise ActionError(result.error or result.status)
|
|
302
302
|
reply = result.text.strip() or "(no answer)"
|
|
303
303
|
changed = git(cwd, "status", "--porcelain").strip()
|
|
@@ -336,6 +336,8 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
336
336
|
reply += f"\n\n{link}"
|
|
337
337
|
if result.status == "max_steps":
|
|
338
338
|
reply += f"\n\n(Stopped after {inputs.max_steps} steps.)"
|
|
339
|
+
elif result.status == "loop":
|
|
340
|
+
reply += "\n\n(Stopped: the model kept repeating the same call.)"
|
|
339
341
|
hub.comment(task.number, reply[:MAX_COMMENT] + footer(result))
|
|
340
342
|
|
|
341
343
|
|
|
@@ -349,7 +351,7 @@ def review(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
349
351
|
prompt = gitflow.review_prompt(session.agent, base)
|
|
350
352
|
session.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
|
|
351
353
|
result = session.run(prompt)
|
|
352
|
-
if result.status not in ("success", "max_steps"):
|
|
354
|
+
if result.status not in ("success", "max_steps", "loop"):
|
|
353
355
|
raise ActionError(result.error or result.status)
|
|
354
356
|
hub.comment(task.number, f"**lcode review**\n\n{result.text.strip()[:MAX_COMMENT]}{footer(result)}")
|
|
355
357
|
|
|
@@ -32,6 +32,7 @@ from lcode.mcp import McpManager
|
|
|
32
32
|
from lcode.ollama import Ollama, OllamaError
|
|
33
33
|
from lcode.permissions import Permissions
|
|
34
34
|
from lcode.render import MarkdownStreamer
|
|
35
|
+
from lcode.repeats import Repeats
|
|
35
36
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
36
37
|
from lcode.tools import (
|
|
37
38
|
LSP_SCHEMA,
|
|
@@ -57,7 +58,9 @@ MAX_STEPS_PER_TURN = 150
|
|
|
57
58
|
EVENT_OUTPUT = 4000 # characters of a tool's output in an event
|
|
58
59
|
# How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
|
|
59
60
|
REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
60
|
-
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
61
|
+
MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
|
|
62
|
+
# How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
|
|
63
|
+
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
61
64
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
62
65
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
63
66
|
PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
|
|
@@ -216,6 +219,7 @@ class Settings:
|
|
|
216
219
|
notify: bool = False # desktop notifications after long requests (lcode.notify)
|
|
217
220
|
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
218
221
|
max_parallel_agents: int = 1
|
|
222
|
+
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
219
223
|
|
|
220
224
|
|
|
221
225
|
class Agent:
|
|
@@ -267,7 +271,8 @@ class Agent:
|
|
|
267
271
|
# Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
|
|
268
272
|
self.plan_review: Callable[[str, str], str] | None = None
|
|
269
273
|
self.max_steps = MAX_STEPS_PER_TURN
|
|
270
|
-
self.turn_status = "" # how the last request ended: success, max_steps
|
|
274
|
+
self.turn_status = "" # how the last request ended: success, max_steps, loop
|
|
275
|
+
self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
|
|
271
276
|
self.response = None # the streaming response, so another thread can abort it
|
|
272
277
|
self.ctx_used = 0
|
|
273
278
|
self.last_speed = 0.0
|
|
@@ -809,7 +814,9 @@ class Agent:
|
|
|
809
814
|
self.turn_status = "running"
|
|
810
815
|
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
811
816
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
817
|
+
self.repeats.reset()
|
|
812
818
|
malformed = 0
|
|
819
|
+
looping = "" # the call the model kept repeating, when lcode stopped the request for it
|
|
813
820
|
for step in range(self.max_steps):
|
|
814
821
|
if self.cancel is not None and self.cancel.is_set():
|
|
815
822
|
raise KeyboardInterrupt
|
|
@@ -834,17 +841,22 @@ class Agent:
|
|
|
834
841
|
}
|
|
835
842
|
)
|
|
836
843
|
except OllamaError as e:
|
|
837
|
-
if
|
|
844
|
+
if not any(m in str(e) for m in MALFORMED_CALL) or malformed >= MAX_MALFORMED_CALL_RETRIES:
|
|
838
845
|
raise
|
|
839
846
|
malformed += 1
|
|
840
|
-
|
|
847
|
+
if "err=" in str(e):
|
|
848
|
+
reason = str(e).rsplit("err=", 1)[-1].strip()
|
|
849
|
+
elif "XML syntax error" in str(e):
|
|
850
|
+
reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
|
|
851
|
+
else:
|
|
852
|
+
reason = "invalid JSON"
|
|
841
853
|
self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
|
|
842
854
|
self.messages.append(
|
|
843
855
|
{
|
|
844
856
|
"role": "user",
|
|
845
|
-
"content": f"[lcode] Your last tool call could not be parsed ({reason}):
|
|
846
|
-
"
|
|
847
|
-
"make sure the whole content is included and properly escaped.",
|
|
857
|
+
"content": f"[lcode] Your last tool call could not be parsed ({reason}): it must be "
|
|
858
|
+
"complete and well-formed, with every argument closed. Make the call again. If it writes "
|
|
859
|
+
"a file, make sure the whole content is included and properly escaped.",
|
|
848
860
|
}
|
|
849
861
|
)
|
|
850
862
|
continue
|
|
@@ -892,6 +904,15 @@ class Agent:
|
|
|
892
904
|
self.console.print(Text(f" {result[:300]}", style="red"))
|
|
893
905
|
elif name in ("read_file", "grep", "glob", "list_dir"):
|
|
894
906
|
self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
|
|
907
|
+
verdict = self.repeats.check(name, args, result)
|
|
908
|
+
if verdict.note:
|
|
909
|
+
result += verdict.note
|
|
910
|
+
what = "stopping the request" if verdict.stop else "told the model"
|
|
911
|
+
self.console.print(
|
|
912
|
+
Text(f" ⎿ same call, same result, {verdict.count} times: {what}", style="yellow")
|
|
913
|
+
)
|
|
914
|
+
if verdict.stop:
|
|
915
|
+
looping = self.describe_call(name, args)
|
|
895
916
|
tool_msg = {"role": "tool", "tool_name": name, "content": result}
|
|
896
917
|
if call.get("id"):
|
|
897
918
|
tool_msg["tool_call_id"] = call["id"]
|
|
@@ -905,6 +926,27 @@ class Agent:
|
|
|
905
926
|
"output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
|
|
906
927
|
}
|
|
907
928
|
)
|
|
929
|
+
if looping:
|
|
930
|
+
for rest in calls[i + 1 :]:
|
|
931
|
+
skipped = {
|
|
932
|
+
"role": "tool",
|
|
933
|
+
"tool_name": rest.get("function", {}).get("name", ""),
|
|
934
|
+
"content": "Not run: lcode stopped the request before this call.",
|
|
935
|
+
}
|
|
936
|
+
if rest.get("id"):
|
|
937
|
+
skipped["tool_call_id"] = rest["id"]
|
|
938
|
+
self.messages.append(skipped)
|
|
939
|
+
break
|
|
940
|
+
if looping:
|
|
941
|
+
self.turn_status = "loop"
|
|
942
|
+
self.console.print(
|
|
943
|
+
Text(
|
|
944
|
+
f"Stopped: the model kept making the same call with the same result: {looping}. Tell it "
|
|
945
|
+
"what to do differently, or try again.",
|
|
946
|
+
style="yellow",
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
return
|
|
908
950
|
else:
|
|
909
951
|
self.turn_status = "max_steps"
|
|
910
952
|
self.console.print(f"[yellow]Stopped after {self.max_steps} steps.[/]")
|
|
@@ -35,8 +35,8 @@ if TYPE_CHECKING:
|
|
|
35
35
|
from lcode.hardware import Hardware
|
|
36
36
|
|
|
37
37
|
SCHEMA_VERSION = 1
|
|
38
|
-
STATUSES = ("success", "error", "max_steps", "interrupted")
|
|
39
|
-
EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "interrupted": 130}
|
|
38
|
+
STATUSES = ("success", "error", "max_steps", "loop", "interrupted")
|
|
39
|
+
EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "loop": 4, "interrupted": 130}
|
|
40
40
|
OLLAMA_INSTALL = {
|
|
41
41
|
"linux": "curl -fsSL https://ollama.com/install.sh | sh",
|
|
42
42
|
"darwin": "brew install ollama (or download it from https://ollama.com/download)",
|
|
@@ -69,7 +69,7 @@ class Options:
|
|
|
69
69
|
class Result:
|
|
70
70
|
"""How a request went. `to_json()` is what `lcode -p --output json` prints."""
|
|
71
71
|
|
|
72
|
-
status: str # success, error, max_steps or interrupted
|
|
72
|
+
status: str # success, error, max_steps, loop (stopped: the model kept repeating itself) or interrupted
|
|
73
73
|
text: str # the model's final answer
|
|
74
74
|
session_id: str
|
|
75
75
|
model: str
|
|
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
194
194
|
embed_model=cfg["embed_model"],
|
|
195
195
|
notify=cfg["notify"],
|
|
196
196
|
notify_after=cfg["notify_after"],
|
|
197
|
+
repeat_limit=cfg["repeat_limit"],
|
|
197
198
|
)
|
|
198
199
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
199
200
|
agent.interactive = options.interactive
|
|
@@ -272,7 +273,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
|
|
|
272
273
|
|
|
273
274
|
try:
|
|
274
275
|
agent.run_turn(prompt)
|
|
275
|
-
status =
|
|
276
|
+
status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
|
|
276
277
|
except KeyboardInterrupt:
|
|
277
278
|
status, error = "interrupted", "interrupted before the request was done"
|
|
278
279
|
except ModelError as e:
|
|
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
59
59
|
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
|
+
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
62
63
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
63
64
|
}
|
|
64
65
|
ENV_OVERRIDES = {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Notice when the model goes round in circles.
|
|
2
|
+
|
|
3
|
+
Small models sometimes fall into a loop: they make a tool call, get a result, and make the very same call
|
|
4
|
+
again, and again, until the user steps in. Within one request lcode counts the calls that repeat an earlier
|
|
5
|
+
one exactly: the same tool, the same arguments and the same result. At the `limit`-th one (a call that
|
|
6
|
+
failed: one sooner) the result gets a note that tells the model so, and if it still goes on, lcode ends the
|
|
7
|
+
request.
|
|
8
|
+
|
|
9
|
+
Results that change aren't repeats: a test run again after a fix, a background job read while it runs. And
|
|
10
|
+
a call that may have changed something (a file edit, a shell command that isn't read-only) starts the count
|
|
11
|
+
again for every other call, so checking a build after each edit never adds up. Its own repeats still count:
|
|
12
|
+
running the same failing command over and over is a loop.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from lcode.permissions import is_read_only
|
|
22
|
+
|
|
23
|
+
STOP_AFTER_NOTE = 2 # repeats after the note before the request ends
|
|
24
|
+
FAILED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
|
|
25
|
+
UNCHANGING = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"read_file",
|
|
28
|
+
"list_dir",
|
|
29
|
+
"glob",
|
|
30
|
+
"grep",
|
|
31
|
+
"view_image",
|
|
32
|
+
"repo_map",
|
|
33
|
+
"search_code",
|
|
34
|
+
"lsp",
|
|
35
|
+
"web_search",
|
|
36
|
+
"web_fetch",
|
|
37
|
+
"bash_output",
|
|
38
|
+
"todo_write",
|
|
39
|
+
"skill",
|
|
40
|
+
"mcp_find_tools",
|
|
41
|
+
}
|
|
42
|
+
) # tools that don't change files; anything else might
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Verdict:
|
|
47
|
+
count: int # how often this exact call got this exact result in the request
|
|
48
|
+
note: str = "" # to add to the tool result
|
|
49
|
+
stop: bool = False # end the request
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Repeats:
|
|
53
|
+
def __init__(self, limit: int = 3) -> None:
|
|
54
|
+
self.limit = limit # 0: off
|
|
55
|
+
self.seen: dict[str, int] = {}
|
|
56
|
+
|
|
57
|
+
def reset(self) -> None:
|
|
58
|
+
"""A new request."""
|
|
59
|
+
self.seen.clear()
|
|
60
|
+
|
|
61
|
+
def check(self, name: str, args: dict, result: str) -> Verdict:
|
|
62
|
+
"""Count this call and its result."""
|
|
63
|
+
if self.limit <= 0:
|
|
64
|
+
return Verdict(1)
|
|
65
|
+
key = hashlib.sha256(json.dumps([name, args, result], sort_keys=True, default=str).encode()).hexdigest()
|
|
66
|
+
count = self.seen.get(key, 0) + 1
|
|
67
|
+
if changes_things(name, args, result):
|
|
68
|
+
self.seen.clear()
|
|
69
|
+
self.seen[key] = count
|
|
70
|
+
failed = result.startswith(FAILED)
|
|
71
|
+
note_at = max(2, self.limit - 1) if failed else max(2, self.limit)
|
|
72
|
+
if count < note_at:
|
|
73
|
+
return Verdict(count)
|
|
74
|
+
same = "the same error" if failed else "the same result"
|
|
75
|
+
if count >= note_at + STOP_AFTER_NOTE:
|
|
76
|
+
return Verdict(
|
|
77
|
+
count,
|
|
78
|
+
f"\n\n[lcode] You made this exact call {count} times in this request and got {same} each "
|
|
79
|
+
"time, so lcode stopped the request here. Next time, don't repeat a call that didn't help: "
|
|
80
|
+
"change your approach, or tell the user what is blocking you.",
|
|
81
|
+
stop=True,
|
|
82
|
+
)
|
|
83
|
+
hint = (
|
|
84
|
+
"If it's an edit, read the file again and copy the text exactly. "
|
|
85
|
+
if failed and name in ("edit_file", "write_file")
|
|
86
|
+
else ""
|
|
87
|
+
)
|
|
88
|
+
return Verdict(
|
|
89
|
+
count,
|
|
90
|
+
f"\n\n[lcode] You made this exact call {count} times in this request and got {same} each time. "
|
|
91
|
+
f"Making it again won't change that. Use what it returned, or try something different. {hint}"
|
|
92
|
+
"If you're stuck, stop and tell the user what is blocking you.",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def changes_things(name: str, args: dict, result: str) -> bool:
|
|
97
|
+
"""Whether the call may have changed files (or anything else a later call could see)."""
|
|
98
|
+
if name in UNCHANGING:
|
|
99
|
+
return False
|
|
100
|
+
if name in ("edit_file", "write_file"):
|
|
101
|
+
return not result.startswith(FAILED)
|
|
102
|
+
if name == "bash":
|
|
103
|
+
return not is_read_only(str(args.get("command", "")))
|
|
104
|
+
return True # other tools (MCP, subagents…) might
|
|
@@ -486,12 +486,17 @@ class Run:
|
|
|
486
486
|
child._run_turn(self.task)
|
|
487
487
|
last = child.messages[-1]
|
|
488
488
|
if last.get("role") != "assistant" or last.get("tool_calls") or not last.get("content", "").strip():
|
|
489
|
-
# It ran out of steps: ask for what it has so far, without tools.
|
|
489
|
+
# It ran out of steps, or went round in circles: ask for what it has so far, without tools.
|
|
490
|
+
why = (
|
|
491
|
+
"You kept making the same call, so lcode stopped you."
|
|
492
|
+
if child.turn_status == "loop"
|
|
493
|
+
else "You've used all your steps."
|
|
494
|
+
)
|
|
490
495
|
child.messages.append(
|
|
491
496
|
{
|
|
492
497
|
"role": "user",
|
|
493
|
-
"content": "[lcode]
|
|
494
|
-
"
|
|
498
|
+
"content": f"[lcode] {why} Stop here and write your report: what you found or did so "
|
|
499
|
+
"far, and what's left.",
|
|
495
500
|
}
|
|
496
501
|
)
|
|
497
502
|
child.allowed_tools = set()
|
|
@@ -137,6 +137,23 @@ def test_malformed_tool_calls_are_retried(make_agent):
|
|
|
137
137
|
assert nudge["role"] == "user" and "unexpected end of JSON input" in nudge["content"]
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def test_malformed_xml_tool_calls_are_retried(make_agent):
|
|
141
|
+
agent = make_agent([reply("fixed it")])
|
|
142
|
+
calls = {"n": 0}
|
|
143
|
+
real = agent.ollama.chat_stream
|
|
144
|
+
|
|
145
|
+
def chat_stream(payload):
|
|
146
|
+
calls["n"] += 1
|
|
147
|
+
if calls["n"] == 1:
|
|
148
|
+
raise OllamaError("Ollama error: XML syntax error on line 16: unexpected EOF")
|
|
149
|
+
yield from real(payload)
|
|
150
|
+
|
|
151
|
+
agent.ollama.chat_stream = chat_stream
|
|
152
|
+
agent.run_turn("read status.txt")
|
|
153
|
+
assert agent.messages[-1]["content"] == "fixed it"
|
|
154
|
+
assert "(on line 16: unexpected EOF)" in agent.messages[-2]["content"]
|
|
155
|
+
|
|
156
|
+
|
|
140
157
|
def test_malformed_tool_calls_give_up_eventually(make_agent):
|
|
141
158
|
agent = make_agent()
|
|
142
159
|
|
|
@@ -68,6 +68,14 @@ def test_permission_requests_go_to_approve(server, repo):
|
|
|
68
68
|
assert session.run("write x").tool_calls[0]["output"] == NOT_ASKED and not (repo / "x.txt").exists()
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def test_a_model_that_repeats_itself_is_stopped(server, repo):
|
|
72
|
+
server.scripts = [reply(tool_calls=[call("read_file", path="README.md")]) for _ in range(6)]
|
|
73
|
+
with api.Session(repo) as session:
|
|
74
|
+
result = session.run("is the title right?")
|
|
75
|
+
assert result.status == "loop" and result.exit_code == 4 and len(result.tool_calls) == 5
|
|
76
|
+
assert "lcode stopped the request here" in result.tool_calls[-1]["output"]
|
|
77
|
+
|
|
78
|
+
|
|
71
79
|
def test_limits_and_failures(server, repo):
|
|
72
80
|
server.scripts = [reply(tool_calls=[call("read_file", path="README.md")]) for _ in range(3)]
|
|
73
81
|
with api.Session(repo, max_steps=2, allowed_tools=["read_file", "grep"]) as session:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from conftest import call, output, reply
|
|
2
|
+
from lcode.repeats import Repeats
|
|
3
|
+
|
|
4
|
+
GREP = {"pattern": "size_panels", "path": "."}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def counts(repeats, *calls):
|
|
8
|
+
"""(note given, stop) for each (name, args, result)."""
|
|
9
|
+
out = []
|
|
10
|
+
for name, args, result in calls:
|
|
11
|
+
verdict = repeats.check(name, args, result)
|
|
12
|
+
out.append(("stop" if verdict.stop else "note") if verdict.note else "")
|
|
13
|
+
return out
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_the_same_call_and_result_gets_a_note_then_stops():
|
|
17
|
+
repeats = Repeats()
|
|
18
|
+
same = ("grep", GREP, "camera_wall.py:350: def size_panels")
|
|
19
|
+
assert counts(repeats, *[same] * 5) == ["", "", "note", "note", "stop"]
|
|
20
|
+
verdict = Repeats().check(*same)
|
|
21
|
+
assert verdict.count == 1 and not verdict.note
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_a_failed_call_is_noted_sooner():
|
|
25
|
+
repeats = Repeats()
|
|
26
|
+
edit = ("edit_file", {"path": "a.py", "old_string": "x", "new_string": "y"}, "Error: old_string not found")
|
|
27
|
+
assert counts(repeats, *[edit] * 4) == ["", "note", "note", "stop"]
|
|
28
|
+
repeats = Repeats()
|
|
29
|
+
repeats.check(*edit)
|
|
30
|
+
assert "read the file again and copy the text exactly" in repeats.check(*edit).note
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_results_that_change_are_not_repeats():
|
|
34
|
+
repeats = Repeats()
|
|
35
|
+
runs = [("bash", {"command": "pytest -q"}, f"{n} failed\n[exit code: 1]") for n in (3, 2, 1, 1)]
|
|
36
|
+
assert counts(repeats, *runs) == ["", "", "", ""]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_change_starts_the_count_again_for_other_calls():
|
|
40
|
+
repeats = Repeats()
|
|
41
|
+
check = ("bash", {"command": "python -m py_compile app.py && echo OK"}, "OK\n[exit code: 0]")
|
|
42
|
+
for n in range(6):
|
|
43
|
+
edit = ("edit_file", {"path": "app.py", "old_string": f"v{n}", "new_string": f"v{n + 1}"}, "Edited app.py")
|
|
44
|
+
assert counts(repeats, edit, check) == ["", ""]
|
|
45
|
+
# A read-only look in between changes nothing: the repeats still add up.
|
|
46
|
+
look = ("bash", {"command": "git diff app.py | head -80"}, "diff --git a/app.py b/app.py")
|
|
47
|
+
failed = ("edit_file", {"path": "app.py", "old_string": "gone", "new_string": "x"}, "Error: old_string not found")
|
|
48
|
+
assert counts(repeats, failed, look, failed, look, failed, look) == ["", "", "note", "", "note", "note"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_command_that_may_change_things_still_counts_its_own_repeats():
|
|
52
|
+
repeats = Repeats()
|
|
53
|
+
run = ("bash", {"command": "cd /src && python app.py"}, "Traceback: KeyError\n[exit code: 1]")
|
|
54
|
+
assert counts(repeats, run, run, run) == ["", "", "note"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_new_request_and_limit_zero():
|
|
58
|
+
repeats = Repeats()
|
|
59
|
+
same = ("read_file", {"path": "a.py"}, "1\tx")
|
|
60
|
+
counts(repeats, same, same)
|
|
61
|
+
repeats.reset()
|
|
62
|
+
assert counts(repeats, same, same) == ["", ""]
|
|
63
|
+
assert counts(Repeats(0), *[same] * 10) == [""] * 10
|
|
64
|
+
assert counts(Repeats(5), *[same] * 7) == ["", "", "", "", "note", "note", "stop"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_the_agent_tells_the_model_and_then_stops(make_agent):
|
|
68
|
+
look = call("bash", command="git diff README.md | grep -c Demo")
|
|
69
|
+
agent = make_agent([reply(tool_calls=[dict(look)]) for _ in range(5)] + [reply("never reached")])
|
|
70
|
+
agent.run_turn("is it clean?")
|
|
71
|
+
results = [m["content"] for m in agent.messages if m["role"] == "tool"]
|
|
72
|
+
assert len(results) == 5 and agent.turn_status == "loop"
|
|
73
|
+
assert "[lcode] You made this exact call 3 times" in results[2] and "lcode stopped" not in results[2]
|
|
74
|
+
assert "lcode stopped the request here" in results[4]
|
|
75
|
+
assert len(agent.ollama.scripts) == 1 # the model wasn't asked again
|
|
76
|
+
text = output(agent)
|
|
77
|
+
assert "same call, same result, 3 times: told the model" in text
|
|
78
|
+
assert "Stopped: the model kept making the same call with the same result: $ git diff README.md" in text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_calls_after_the_stop_are_not_run(make_agent, repo):
|
|
82
|
+
look = call("read_file", path="README.md")
|
|
83
|
+
write = {"id": "w1", **call("write_file", path="later.txt", content="x")}
|
|
84
|
+
agent = make_agent([reply(tool_calls=[dict(look)]) for _ in range(4)] + [reply(tool_calls=[dict(look), write])])
|
|
85
|
+
agent.run_turn("go")
|
|
86
|
+
assert agent.turn_status == "loop" and not (repo / "later.txt").exists()
|
|
87
|
+
skipped = agent.messages[-1]
|
|
88
|
+
assert skipped["tool_call_id"] == "w1" and skipped["content"].startswith("Not run: lcode stopped")
|
|
@@ -384,3 +384,17 @@ def test_agents_command_lists_types_and_runs(make_agent):
|
|
|
384
384
|
handle_command(agent, "/agents 1", HW)
|
|
385
385
|
assert "task: Find where add is defined" in output(agent)
|
|
386
386
|
assert "● glob" in output(agent)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_a_subagent_that_repeats_itself_still_gives_a_report(make_agent):
|
|
390
|
+
agent = make_agent(
|
|
391
|
+
[reply(tool_calls=[delegate()])]
|
|
392
|
+
+ [reply(tool_calls=[tool("list_dir")]) for _ in range(5)]
|
|
393
|
+
+ [reply("I kept listing the top level and found nothing."), reply("ok")],
|
|
394
|
+
subagents=True,
|
|
395
|
+
)
|
|
396
|
+
agent.run_turn("go")
|
|
397
|
+
report = agent.ollama.payloads[6]
|
|
398
|
+
assert "You kept making the same call, so lcode stopped you." in report["messages"][-2]["content"]
|
|
399
|
+
assert "tools" not in report
|
|
400
|
+
assert "I kept listing the top level" in agent.messages[3]["content"] and agent.turn_status == "success"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|