lcode-cli 0.29.0__tar.gz → 0.30.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.29.0 → lcode_cli-0.30.0}/CHANGELOG.md +20 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/PKG-INFO +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/usage.md +30 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/pyproject.toml +2 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/acp.py +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/agent.py +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/bench.py +15 -7
- lcode_cli-0.30.0/src/lcode/matching.py +287 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/repeats.py +2 -2
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/subagents.py +1 -1
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/tools.py +127 -39
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_bench.py +10 -1
- lcode_cli-0.30.0/tests/test_matching.py +167 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_tools.py +6 -6
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.editorconfig +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.gitignore +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/AGENTS.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/LICENSE +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/README.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/SECURITY.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/action.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/agents.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/automation.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/bench.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/changelog.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/commands.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/configuration.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/contributing.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/editors.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/git.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/hooks.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/index.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/installation.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/lsp.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/mcp.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/memory.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/models.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/search.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/servers.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/examples/README.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/install.sh +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/mkdocs.yml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/overrides/main.html +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/checks.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/cli.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/repl.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/residency.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/conftest.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_action.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_api.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_checks.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_config.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_context.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_residency.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_robustness.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_update.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_web.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.29.0 → lcode_cli-0.30.0}/uv.lock +0 -0
|
@@ -6,6 +6,24 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.30.0] - 2026-10-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Edits that the model copied imperfectly land where they belong instead of failing: line numbers
|
|
14
|
+
copied from `read_file`, stray escapes, a block at the wrong indentation or spaces for tabs (the new
|
|
15
|
+
text is re-indented), dropped blank lines, and very close matches. A close match changes only the
|
|
16
|
+
lines the model meant to change, so an earlier edit its copy doesn't show survives. Ambiguous or
|
|
17
|
+
weak matches are refused, and the model is shown the closest lines with their numbers. See
|
|
18
|
+
[Imperfect edits](https://nasser1941.github.io/lcode/usage/#imperfect-edits).
|
|
19
|
+
- `apply_patch`: the model can apply a unified diff to several files at once. Hunks are found by
|
|
20
|
+
their text, so wrong `@@` line numbers don't matter, and a file changes only if every hunk fits.
|
|
21
|
+
- Common tool slips get a useful answer instead of an error: a `grep` pattern that isn't a valid regex
|
|
22
|
+
(`def add(`) is searched as plain text, a path that doesn't exist comes back with the closest
|
|
23
|
+
existing ones (`Did you mean: src/pkg/math_utils.py?`), `read_file` on a folder lists it, and
|
|
24
|
+
`list_dir` on a file says to read it.
|
|
25
|
+
- `lcode bench --json` breaks failed tool calls down by tool (`errors_by_tool`).
|
|
26
|
+
|
|
9
27
|
## [0.29.0] - 2026-10-13
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -650,7 +668,8 @@ First public release.
|
|
|
650
668
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
651
669
|
- One-line installer for Linux and macOS.
|
|
652
670
|
|
|
653
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
671
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.30.0...HEAD
|
|
672
|
+
[0.30.0]: https://github.com/nasser1941/lcode/compare/v0.29.0...v0.30.0
|
|
654
673
|
[0.29.0]: https://github.com/nasser1941/lcode/compare/v0.28.1...v0.29.0
|
|
655
674
|
[0.28.1]: https://github.com/nasser1941/lcode/compare/v0.28.0...v0.28.1
|
|
656
675
|
[0.28.0]: https://github.com/nasser1941/lcode/compare/v0.27.0...v0.28.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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/
|
|
@@ -92,7 +92,8 @@ reasoning is on.
|
|
|
92
92
|
|---|---|
|
|
93
93
|
| `read_file` | Read a file with line numbers (paged for large files) |
|
|
94
94
|
| `write_file` | Create or overwrite a file (shows a preview or diff) |
|
|
95
|
-
| `edit_file` | Replace
|
|
95
|
+
| `edit_file` | Replace a snippet in a file (shows a diff); [forgiving](#imperfect-edits) when the model copies it imperfectly |
|
|
96
|
+
| `apply_patch` | Apply a unified diff (as `git diff` writes it) to one or more files; hunks are found by their text |
|
|
96
97
|
| `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
|
|
97
98
|
| `glob` | Find files by pattern, newest first |
|
|
98
99
|
| `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
|
|
@@ -113,6 +114,34 @@ reasoning is on.
|
|
|
113
114
|
lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
|
|
114
115
|
it was read, so the model always edits the current version.
|
|
115
116
|
|
|
117
|
+
### Imperfect edits
|
|
118
|
+
|
|
119
|
+
Small models copy text imperfectly, and an edit that doesn't match exactly used to fail, often again
|
|
120
|
+
and again. When the text isn't in the file exactly, lcode tries, in order:
|
|
121
|
+
|
|
122
|
+
- **Copying slips:** removing the line numbers `read_file` shows, and undoing stray escapes such
|
|
123
|
+
as `\"`.
|
|
124
|
+
- **Looser line matches:** comparing lines without trailing spaces, then without indentation (a block
|
|
125
|
+
at the wrong level, or spaces where the file has tabs). The new text is re-indented to fit.
|
|
126
|
+
- **Blank lines:** ignoring blank lines the model dropped or added.
|
|
127
|
+
- **A very close match:** at least 90% alike, and clearly better than any other place. Only the lines
|
|
128
|
+
the model meant to change are changed, so an earlier edit that the model's copy doesn't show is
|
|
129
|
+
kept. If a changed line differs in the file by more than a typo, the edit is refused.
|
|
130
|
+
|
|
131
|
+
The result says how it matched (`Edited config.py (1 replacement(s), ignoring indentation)`), and the
|
|
132
|
+
diff shows the change as usual. A match that's ambiguous or too weak is never applied. Instead, the
|
|
133
|
+
model gets the closest lines with their numbers, so it can copy them exactly. `apply_patch` places
|
|
134
|
+
each hunk the same way, so wrong `@@` line numbers don't matter, and it changes a file only if every
|
|
135
|
+
hunk fits.
|
|
136
|
+
|
|
137
|
+
Other common slips get a useful answer instead of a bare error:
|
|
138
|
+
|
|
139
|
+
- **A pattern that isn't a valid regex** (`grep "def add("`) is searched as plain text, and the result
|
|
140
|
+
says so.
|
|
141
|
+
- **A path that doesn't exist** comes back with the closest existing ones: the same file name
|
|
142
|
+
elsewhere in the project, or a near name in the folder (`Did you mean: src/pkg/math_utils.py?`).
|
|
143
|
+
- **`read_file` on a folder** lists it, and **`list_dir` on a file** says to read it.
|
|
144
|
+
|
|
116
145
|
## Checks before done
|
|
117
146
|
|
|
118
147
|
Small models like to call work done after a syntax check. So after a request that changed files, lcode
|
|
@@ -82,7 +82,8 @@ ignore = [
|
|
|
82
82
|
"src/lcode/context.py" = ["E501"]
|
|
83
83
|
"src/lcode/lsp.py" = ["E501"]
|
|
84
84
|
"src/lcode/repomap.py" = ["E501"]
|
|
85
|
-
"src/lcode/bench_runtime.py" = ["E501"]
|
|
85
|
+
"src/lcode/bench_runtime.py" = ["E501"]
|
|
86
|
+
"tests/test_matching.py" = ["E501"] # file contents as test data # the task's files and hidden test
|
|
86
87
|
"src/lcode/codesearch.py" = ["E501"]
|
|
87
88
|
"src/lcode/bench_backend.py" = ["E501"]
|
|
88
89
|
"src/lcode/backends.py" = ["E501"]
|
|
@@ -44,7 +44,7 @@ MODES = [
|
|
|
44
44
|
]
|
|
45
45
|
KINDS = {
|
|
46
46
|
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
|
|
47
|
-
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
|
|
47
|
+
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit", "apply_patch": "edit",
|
|
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
|
|
@@ -1134,7 +1134,7 @@ class Agent:
|
|
|
1134
1134
|
)
|
|
1135
1135
|
if verdict.stop:
|
|
1136
1136
|
looping = self.describe_call(name, args)
|
|
1137
|
-
if name in ("edit_file", "write_file") and not result.startswith(REFUSED):
|
|
1137
|
+
if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
|
|
1138
1138
|
changed, verified = True, False
|
|
1139
1139
|
elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
|
|
1140
1140
|
verified = True # the model ran the checks itself, after its last change
|
|
@@ -789,6 +789,7 @@ class TaskResult:
|
|
|
789
789
|
steps: int = 0 # model responses
|
|
790
790
|
tool_errors: int = 0 # failed tool calls, including malformed ones
|
|
791
791
|
error: str | None = None # why the run stopped early: a timeout or an Ollama error
|
|
792
|
+
errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
|
|
792
793
|
|
|
793
794
|
|
|
794
795
|
@dataclass
|
|
@@ -861,14 +862,20 @@ class Elapsed:
|
|
|
861
862
|
return Text(f"{self.label} {time.monotonic() - self.start:.0f}s", style="dim")
|
|
862
863
|
|
|
863
864
|
|
|
865
|
+
def errors_by_tool(messages: list[dict]) -> dict[str, int]:
|
|
866
|
+
"""Failed tool calls by tool, and "malformed" for calls the model wrote unreadably."""
|
|
867
|
+
counts: dict[str, int] = {}
|
|
868
|
+
for m in messages:
|
|
869
|
+
if m.get("role") == "tool" and m.get("content", "").startswith("Error:"):
|
|
870
|
+
name = m.get("tool_name") or "?"
|
|
871
|
+
counts[name] = counts.get(name, 0) + 1
|
|
872
|
+
elif m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call"):
|
|
873
|
+
counts["malformed"] = counts.get("malformed", 0) + 1
|
|
874
|
+
return counts
|
|
875
|
+
|
|
876
|
+
|
|
864
877
|
def count_tool_errors(messages: list[dict]) -> int:
|
|
865
|
-
|
|
866
|
-
malformed = sum(
|
|
867
|
-
1
|
|
868
|
-
for m in messages
|
|
869
|
-
if m.get("role") == "user" and m.get("content", "").startswith("[lcode] Your last tool call")
|
|
870
|
-
)
|
|
871
|
-
return failed + malformed
|
|
878
|
+
return sum(errors_by_tool(messages).values())
|
|
872
879
|
|
|
873
880
|
|
|
874
881
|
def run_task(
|
|
@@ -933,6 +940,7 @@ def run_task(
|
|
|
933
940
|
detail=error or check.detail,
|
|
934
941
|
steps=agent.usage["requests"] - before.get("requests", 0),
|
|
935
942
|
tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
943
|
+
errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
|
|
936
944
|
error=error,
|
|
937
945
|
)
|
|
938
946
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Find where an edit goes when the model's text doesn't match the file exactly.
|
|
2
|
+
|
|
3
|
+
Small models copy text imperfectly: with the line numbers `read_file` shows, with spaces where the file has
|
|
4
|
+
tabs, a block at the wrong indentation, quotes still escaped, a blank line dropped, a letter wrong. An exact
|
|
5
|
+
match fails and the model often repeats the same failing edit. So when the exact text isn't there, lcode tries,
|
|
6
|
+
in order: removing line-number prefixes and stray escapes; comparing lines without trailing whitespace, without
|
|
7
|
+
indentation (and re-indenting the new text to fit), without blank lines; and, last, a very close match. It
|
|
8
|
+
only ever applies a match that is unique; anything ambiguous or weak is refused, with the closest lines and
|
|
9
|
+
their numbers, so the model can copy them exactly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from difflib import SequenceMatcher
|
|
17
|
+
|
|
18
|
+
LINE_NUMBER = re.compile(r"^\s*\d+(?:\t|→| \| |: )") # how read_file and other tools number lines
|
|
19
|
+
CLOSE_ENOUGH = 0.9 # similarity a loose match needs
|
|
20
|
+
CLEAR_LEAD = 0.05 # how much better than the next one it must be
|
|
21
|
+
MAX_SIMILARITY_LINES = 20_000 # bigger files: no similarity search
|
|
22
|
+
HINT_FLOOR = 0.5 # weaker candidates aren't worth showing
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Found:
|
|
27
|
+
text: str # the file with the edit applied
|
|
28
|
+
how: str # how the match was found, for the model ("ignoring indentation"); "" for an exact match
|
|
29
|
+
start_line: int # first matched line, 1-based
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Miss:
|
|
34
|
+
reason: str # "not found" or "ambiguous"
|
|
35
|
+
hint: str # the closest lines, numbered, or ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def numbered(lines: list[str], start: int) -> str:
|
|
39
|
+
return "\n".join(f"{start + i:6}\t{line.rstrip(chr(10))}" for i, line in enumerate(lines))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_line_numbers(text: str) -> str | None:
|
|
43
|
+
lines = text.split("\n")
|
|
44
|
+
content = [line for line in lines if line.strip()]
|
|
45
|
+
if not content or not all(LINE_NUMBER.match(line) for line in content):
|
|
46
|
+
return None
|
|
47
|
+
return "\n".join(LINE_NUMBER.sub("", line, count=1) if line.strip() else line for line in lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unescape(text: str, file_text: str) -> str | None:
|
|
51
|
+
"""`\\"`, `\\n` and `\\t` written literally, when the file doesn't contain them literally."""
|
|
52
|
+
changed = text
|
|
53
|
+
for literal, real in (('\\"', '"'), ("\\'", "'"), ("\\t", "\t")):
|
|
54
|
+
if literal in changed and literal not in file_text:
|
|
55
|
+
changed = changed.replace(literal, real)
|
|
56
|
+
if "\\n" in changed and "\n" not in changed and "\\n" not in file_text:
|
|
57
|
+
changed = changed.replace("\\n", "\n")
|
|
58
|
+
return changed if changed != text else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def indent_of(line: str) -> str:
|
|
62
|
+
return line[: len(line) - len(line.lstrip(" \t"))]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def width(indent: str) -> int:
|
|
66
|
+
return len(indent.expandtabs(4))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reindent(new: str, old_lines: list[str], file_lines: list[str]) -> str:
|
|
70
|
+
"""Move `new` from the old text's indentation to the file's, keeping its relative structure."""
|
|
71
|
+
old_first = next((line for line in old_lines if line.strip()), "")
|
|
72
|
+
file_first = next((line for line in file_lines if line.strip()), "")
|
|
73
|
+
new_first = next((line for line in new.split("\n") if line.strip()), "")
|
|
74
|
+
base_old, base_file = width(indent_of(old_first)), indent_of(file_first)
|
|
75
|
+
same_kind = ("\t" in base_file) == ("\t" in indent_of(new_first))
|
|
76
|
+
if base_old != width(base_file) and width(indent_of(new_first)) == width(base_file) and same_kind:
|
|
77
|
+
return new # the model got the old text's indentation wrong but wrote the new text in the file's
|
|
78
|
+
old_steps = sorted({width(indent_of(line)) - base_old for line in old_lines if line.strip()} - {0})
|
|
79
|
+
step_old = next((s for s in old_steps if s > 0), 4)
|
|
80
|
+
tabs = "\t" in base_file or any(indent_of(line).startswith("\t") for line in file_lines if line.strip())
|
|
81
|
+
file_steps = sorted({width(indent_of(line)) - width(base_file) for line in file_lines if line.strip()} - {0})
|
|
82
|
+
unit = "\t" if tabs else " " * next((s for s in file_steps if s > 0), step_old)
|
|
83
|
+
out = []
|
|
84
|
+
for line in new.split("\n"):
|
|
85
|
+
if not line.strip():
|
|
86
|
+
out.append(line)
|
|
87
|
+
continue
|
|
88
|
+
levels = round((width(indent_of(line)) - base_old) / step_old)
|
|
89
|
+
lead = base_file + unit * levels if levels >= 0 else base_file[: max(0, len(base_file) + levels * len(unit))]
|
|
90
|
+
out.append(lead + line.lstrip(" \t"))
|
|
91
|
+
return "\n".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def splice(lines: list[str], start: int, end: int, new: str) -> str:
|
|
95
|
+
"""Replace lines[start:end] with `new`, keeping the line break the region ended with."""
|
|
96
|
+
ending = "\n" if lines[end - 1].endswith("\n") else ""
|
|
97
|
+
body = new.rstrip("\n")
|
|
98
|
+
return "".join(lines[:start]) + (body + ending if body else "") + "".join(lines[end:])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def line_matches(file_lines: list[str], old_lines: list[str], key) -> list[int]:
|
|
102
|
+
n = len(old_lines)
|
|
103
|
+
wanted = [key(line) for line in old_lines]
|
|
104
|
+
return [i for i in range(len(file_lines) - n + 1) if [key(line) for line in file_lines[i : i + n]] == wanted]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def locate(text: str, old: str, new: str) -> Found | Miss:
|
|
108
|
+
"""Where `old` goes in `text`, when it isn't there exactly; with `new` in its place."""
|
|
109
|
+
# 1. Copying mistakes that leave the text itself right.
|
|
110
|
+
for fix, how in ((strip_line_numbers, "without the line numbers"), (unescape, "with the escapes undone")):
|
|
111
|
+
fixed = fix(old) if fix is strip_line_numbers else unescape(old, text)
|
|
112
|
+
if fixed is None:
|
|
113
|
+
continue
|
|
114
|
+
fixed_new = (fix(new) if fix is strip_line_numbers else unescape(new, text)) or new
|
|
115
|
+
if text.count(fixed) == 1:
|
|
116
|
+
start = text.index(fixed)
|
|
117
|
+
return Found(text.replace(fixed, fixed_new, 1), how, text.count("\n", 0, start) + 1)
|
|
118
|
+
old, new = fixed, fixed_new # the looser steps below work on the fixed text
|
|
119
|
+
file_lines = text.splitlines(keepends=True)
|
|
120
|
+
old_lines = old.strip("\n").split("\n")
|
|
121
|
+
if not old_lines or not any(line.strip() for line in old_lines):
|
|
122
|
+
return Miss("not found", "")
|
|
123
|
+
plain = [line.rstrip("\r\n") for line in file_lines]
|
|
124
|
+
# 2. Line by line, looser each time.
|
|
125
|
+
for key, how, indent in (
|
|
126
|
+
(lambda s: s.rstrip(), "ignoring trailing spaces", False),
|
|
127
|
+
(lambda s: s.strip(), "ignoring indentation", True),
|
|
128
|
+
):
|
|
129
|
+
hits = line_matches(plain, old_lines, key)
|
|
130
|
+
if len(hits) == 1:
|
|
131
|
+
i = hits[0]
|
|
132
|
+
region = plain[i : i + len(old_lines)]
|
|
133
|
+
body = reindent(new.strip("\n"), old_lines, region) if indent else new.strip("\n")
|
|
134
|
+
return Found(splice(file_lines, i, i + len(old_lines), body), how, i + 1)
|
|
135
|
+
if len(hits) > 1:
|
|
136
|
+
return Miss("ambiguous", "\n\n".join(numbered(plain[i : i + len(old_lines)], i + 1) for i in hits[:3]))
|
|
137
|
+
# Without blank lines: map the non-blank lines back to the file.
|
|
138
|
+
solid = [line for line in old_lines if line.strip()]
|
|
139
|
+
content = [(i, line) for i, line in enumerate(plain) if line.strip()]
|
|
140
|
+
keys = [line.strip() for _, line in content]
|
|
141
|
+
wanted = [line.strip() for line in solid]
|
|
142
|
+
hits = [j for j in range(len(keys) - len(wanted) + 1) if keys[j : j + len(wanted)] == wanted]
|
|
143
|
+
first, last = (content[hits[0]][0], content[hits[0] + len(wanted) - 1][0]) if len(hits) == 1 else (0, -1)
|
|
144
|
+
if len(hits) == 1 and last - first + 1 != len(old_lines):
|
|
145
|
+
body = reindent(new.strip("\n"), old_lines, plain[first : last + 1])
|
|
146
|
+
return Found(splice(file_lines, first, last + 1, body), "ignoring blank lines", first + 1)
|
|
147
|
+
# 3. A very close match of the same number of lines, clearly better than any other. Only the lines the model
|
|
148
|
+
# changed change: the rest stays as the file has it now, so an earlier edit the model's copy doesn't show
|
|
149
|
+
# survives. Where a changed line differs in the file by more than a typo, the edit is refused.
|
|
150
|
+
if len(plain) > MAX_SIMILARITY_LINES:
|
|
151
|
+
return Miss("not found", "")
|
|
152
|
+
n = len(old_lines)
|
|
153
|
+
target = "\n".join(line.strip() for line in old_lines)
|
|
154
|
+
scores = []
|
|
155
|
+
for j in range(len(plain) - n + 1):
|
|
156
|
+
if (old_lines[0].strip() and not plain[j].strip()) or (old_lines[-1].strip() and not plain[j + n - 1].strip()):
|
|
157
|
+
continue # a window that starts or ends on a blank line where the model's text doesn't
|
|
158
|
+
window = "\n".join(line.strip() for line in plain[j : j + n])
|
|
159
|
+
matcher = SequenceMatcher(None, target, window, autojunk=False)
|
|
160
|
+
if matcher.real_quick_ratio() < HINT_FLOOR or matcher.quick_ratio() < HINT_FLOOR:
|
|
161
|
+
continue
|
|
162
|
+
scores.append((matcher.ratio(), j))
|
|
163
|
+
scores.sort(reverse=True)
|
|
164
|
+
if not scores or scores[0][0] < HINT_FLOOR:
|
|
165
|
+
return Miss("not found", "")
|
|
166
|
+
best, j = scores[0]
|
|
167
|
+
runner_up = scores[1][0] if len(scores) > 1 else 0.0
|
|
168
|
+
if best >= CLOSE_ENOUGH and best - runner_up >= CLEAR_LEAD:
|
|
169
|
+
merged = merge(old_lines, new.strip("\n").split("\n"), plain[j : j + n])
|
|
170
|
+
if merged is not None:
|
|
171
|
+
body = "\n".join(merged)
|
|
172
|
+
return Found(splice(file_lines, j, j + n, body), f"at a close match ({best:.0%} alike)", j + 1)
|
|
173
|
+
# To show the model where to look, compare line by line: one long line doesn't outweigh the rest.
|
|
174
|
+
hint = max(scores[:20], key=lambda score: line_by_line(old_lines, plain[score[1] : score[1] + n]))[1]
|
|
175
|
+
return Miss("not found", numbered(plain[hint : hint + n], hint + 1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def line_by_line(old: list[str], region: list[str]) -> float:
|
|
179
|
+
pairs = list(zip(old, region, strict=False)) # the region may end early, at the end of the file
|
|
180
|
+
return sum(SequenceMatcher(None, a.strip(), b.strip(), autojunk=False).ratio() for a, b in pairs) / len(pairs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def merge(old: list[str], new: list[str], region: list[str]) -> list[str] | None:
|
|
184
|
+
"""Apply the model's change (old -> new) to the file's lines (`region`, aligned with `old`). None when a line
|
|
185
|
+
it changes differs in the file by more than a typo: the model's copy is out of date there."""
|
|
186
|
+
new = reindent("\n".join(new), old, region).split("\n")
|
|
187
|
+
out: list[str] = []
|
|
188
|
+
ops = SequenceMatcher(None, [line.strip() for line in old], [line.strip() for line in new], autojunk=False)
|
|
189
|
+
for tag, i1, i2, j1, j2 in ops.get_opcodes():
|
|
190
|
+
if tag == "equal":
|
|
191
|
+
out += region[i1:i2] # as the file has them
|
|
192
|
+
continue
|
|
193
|
+
for k in range(i1, i2):
|
|
194
|
+
alike = SequenceMatcher(None, old[k].strip(), region[k].strip(), autojunk=False).ratio()
|
|
195
|
+
if old[k].strip() != region[k].strip() and alike < CLOSE_ENOUGH:
|
|
196
|
+
return None
|
|
197
|
+
out += new[j1:j2]
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ----------------------------------------------------------------------------- unified diffs
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class Hunk:
|
|
206
|
+
header: str # the @@ line, for messages
|
|
207
|
+
old: str # its context and removed lines
|
|
208
|
+
new: str # its context and added lines
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class FilePatch:
|
|
213
|
+
path: str # without a/ and b/
|
|
214
|
+
hunks: list[Hunk]
|
|
215
|
+
new_file: bool = False
|
|
216
|
+
deleted: bool = False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def strip_prefix(path: str) -> str:
|
|
220
|
+
path = path.strip().split("\t")[0]
|
|
221
|
+
return path[2:] if path[:2] in ("a/", "b/") else path
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_patch(patch: str) -> list[FilePatch]:
|
|
225
|
+
"""A unified diff (as git diff writes it) as files and hunks. The @@ line numbers aren't trusted: hunks are
|
|
226
|
+
found by their text. Raises ValueError when it isn't a diff."""
|
|
227
|
+
files: list[FilePatch] = []
|
|
228
|
+
hunk: list[str] | None = None
|
|
229
|
+
current: FilePatch | None = None
|
|
230
|
+
|
|
231
|
+
def close() -> None:
|
|
232
|
+
nonlocal hunk
|
|
233
|
+
if hunk is not None and current is not None:
|
|
234
|
+
old = [line[1:] for line in hunk[1:] if line[:1] in (" ", "-")]
|
|
235
|
+
new = [line[1:] for line in hunk[1:] if line[:1] in (" ", "+")]
|
|
236
|
+
current.hunks.append(Hunk(hunk[0], "\n".join(old), "\n".join(new)))
|
|
237
|
+
hunk = None
|
|
238
|
+
|
|
239
|
+
lines = patch.replace("\r\n", "\n").split("\n")
|
|
240
|
+
i = 0
|
|
241
|
+
while i < len(lines):
|
|
242
|
+
line = lines[i]
|
|
243
|
+
if line.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
|
|
244
|
+
close()
|
|
245
|
+
old_path, new_path = line[4:], lines[i + 1][4:]
|
|
246
|
+
gone, born = old_path.strip() == "/dev/null", new_path.strip() == "/dev/null"
|
|
247
|
+
current = FilePatch(strip_prefix(old_path if born else new_path), [], new_file=gone, deleted=born)
|
|
248
|
+
files.append(current)
|
|
249
|
+
i += 2
|
|
250
|
+
continue
|
|
251
|
+
if line.startswith("@@") and current is not None:
|
|
252
|
+
close()
|
|
253
|
+
hunk = [line]
|
|
254
|
+
elif hunk is not None:
|
|
255
|
+
if line.startswith(("\\ No newline", "diff --git", "index ")):
|
|
256
|
+
pass
|
|
257
|
+
elif line == "" and i == len(lines) - 1:
|
|
258
|
+
pass # the patch's own final newline
|
|
259
|
+
else:
|
|
260
|
+
hunk.append(line if line[:1] in (" ", "-", "+") else " " + line) # a context line that lost its space
|
|
261
|
+
i += 1
|
|
262
|
+
close()
|
|
263
|
+
if not files or not any(f.hunks for f in files):
|
|
264
|
+
raise ValueError("no ---/+++ file headers with @@ hunks: write a unified diff, as git diff does")
|
|
265
|
+
return files
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def apply_hunks(text: str, hunks: list[Hunk]) -> tuple[str, list[str]]:
|
|
269
|
+
"""Apply hunks one after another; returns the text and how each loose match was made. Raises ValueError
|
|
270
|
+
naming the hunk that can't be placed."""
|
|
271
|
+
notes = []
|
|
272
|
+
for n, hunk in enumerate(hunks, 1):
|
|
273
|
+
if not hunk.old.strip():
|
|
274
|
+
raise ValueError(f"hunk {n} ({hunk.header}) has no context lines to place it by")
|
|
275
|
+
count = text.count(hunk.old)
|
|
276
|
+
if count == 1:
|
|
277
|
+
text = text.replace(hunk.old, hunk.new, 1)
|
|
278
|
+
continue
|
|
279
|
+
if count > 1:
|
|
280
|
+
raise ValueError(f"hunk {n} ({hunk.header}) matches {count} places: add more context lines")
|
|
281
|
+
found = locate(text, hunk.old, hunk.new)
|
|
282
|
+
if isinstance(found, Miss):
|
|
283
|
+
where = f" The closest text is:\n{found.hint}" if found.hint else ""
|
|
284
|
+
raise ValueError(f"hunk {n} ({hunk.header}) doesn't match the file.{where}")
|
|
285
|
+
text = found.text
|
|
286
|
+
notes.append(f"hunk {n} {found.how}")
|
|
287
|
+
return text, notes
|
|
@@ -82,7 +82,7 @@ class Repeats:
|
|
|
82
82
|
)
|
|
83
83
|
hint = (
|
|
84
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")
|
|
85
|
+
if failed and name in ("edit_file", "write_file", "apply_patch")
|
|
86
86
|
else ""
|
|
87
87
|
)
|
|
88
88
|
return Verdict(
|
|
@@ -97,7 +97,7 @@ def changes_things(name: str, args: dict, result: str) -> bool:
|
|
|
97
97
|
"""Whether the call may have changed files (or anything else a later call could see)."""
|
|
98
98
|
if name in UNCHANGING:
|
|
99
99
|
return False
|
|
100
|
-
if name in ("edit_file", "write_file"):
|
|
100
|
+
if name in ("edit_file", "write_file", "apply_patch"):
|
|
101
101
|
return not result.startswith(FAILED)
|
|
102
102
|
if name == "bash":
|
|
103
103
|
return not is_read_only(str(args.get("command", "")))
|
|
@@ -69,7 +69,7 @@ READ_ONLY_TOOLS = frozenset(
|
|
|
69
69
|
"search_code",
|
|
70
70
|
}
|
|
71
71
|
)
|
|
72
|
-
EDIT_TOOLS = frozenset({"write_file", "edit_file"})
|
|
72
|
+
EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
|
|
73
73
|
NOT_FOR_SUBAGENTS = frozenset({"agent", "memory"}) # no agents inside agents; only the main session remembers
|
|
74
74
|
MAX_STEPS = 200
|
|
75
75
|
MAX_UNTRACKED_BYTES = 10 * 1024 * 1024 # larger untracked files aren't copied into a worker's worktree
|