lcode-cli 0.33.1__tar.gz → 0.35.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.
Files changed (172) hide show
  1. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/CHANGELOG.md +50 -1
  2. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/bench.md +16 -0
  4. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/configuration.md +3 -0
  5. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/quickstart.md +1 -1
  6. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/usage.md +84 -0
  7. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/pyproject.toml +1 -0
  8. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/agent.py +64 -8
  10. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/api.py +5 -0
  11. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/backends.py +7 -2
  12. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/bench.py +53 -17
  13. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/cli.py +22 -1
  14. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/config.py +24 -0
  15. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/context.py +40 -15
  16. lcode_cli-0.35.0/src/lcode/escalate.py +315 -0
  17. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/repl.py +38 -1
  18. lcode_cli-0.35.0/src/lcode/screenshots.py +1098 -0
  19. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/tools.py +113 -1
  20. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/conftest.py +1 -1
  21. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_agent.py +19 -0
  22. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_bench.py +2 -0
  23. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_context.py +35 -0
  24. lcode_cli-0.35.0/tests/test_escalate.py +333 -0
  25. lcode_cli-0.35.0/tests/test_screenshots.py +386 -0
  26. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_vision.py +2 -2
  27. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.editorconfig +0 -0
  28. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/CODEOWNERS +0 -0
  29. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  30. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  31. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  32. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  33. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  34. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/dependabot.yml +0 -0
  35. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/workflows/ci.yml +0 -0
  36. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/workflows/docs.yml +0 -0
  37. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.github/workflows/release.yml +0 -0
  38. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.gitignore +0 -0
  39. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/.pre-commit-config.yaml +0 -0
  40. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/AGENTS.md +0 -0
  41. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/CODE_OF_CONDUCT.md +0 -0
  42. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/CONTRIBUTING.md +0 -0
  43. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/LICENSE +0 -0
  44. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/README.md +0 -0
  45. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/SECURITY.md +0 -0
  46. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/action.yml +0 -0
  47. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/agents.md +0 -0
  48. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/assets/demo.svg +0 -0
  49. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/assets/extra.css +0 -0
  50. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/automation.md +0 -0
  51. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/changelog.md +0 -0
  52. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/commands.md +0 -0
  53. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/contributing.md +0 -0
  54. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/editors.md +0 -0
  55. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/git.md +0 -0
  56. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/hooks.md +0 -0
  57. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/how-it-works.md +0 -0
  58. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/index.md +0 -0
  59. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/installation.md +0 -0
  60. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/lsp.md +0 -0
  61. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/mcp.md +0 -0
  62. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/memory.md +0 -0
  63. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/models.md +0 -0
  64. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/sandbox.md +0 -0
  65. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/search.md +0 -0
  66. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/servers.md +0 -0
  67. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/docs/troubleshooting.md +0 -0
  68. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/examples/README.md +0 -0
  69. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/examples/api/fix_tests.py +0 -0
  70. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/examples/commands/review.md +0 -0
  71. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/examples/skills/commit-message/SKILL.md +0 -0
  72. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  73. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/install.sh +0 -0
  74. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/mkdocs.yml +0 -0
  75. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/overrides/main.html +0 -0
  76. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/scripts/make_logos.py +0 -0
  77. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/scripts/record_demo.py +0 -0
  78. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/scripts/social-preview.html +0 -0
  79. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/__main__.py +0 -0
  80. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/acp.py +0 -0
  81. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/action.py +0 -0
  82. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/bench_backend.py +0 -0
  83. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/bench_runtime.py +0 -0
  84. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/catalog.py +0 -0
  85. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/checkpoints.py +0 -0
  86. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/checks.py +0 -0
  87. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/clipboard.py +0 -0
  88. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/codesearch.py +0 -0
  89. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/extensions.py +0 -0
  90. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/frontmatter.py +0 -0
  91. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/gitflow.py +0 -0
  92. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/hardware.py +0 -0
  93. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/hooks.py +0 -0
  94. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/jobs.py +0 -0
  95. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/limits.py +0 -0
  96. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/lsp.py +0 -0
  97. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/matching.py +0 -0
  98. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/__init__.py +0 -0
  99. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/auth.py +0 -0
  100. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/catalog.py +0 -0
  101. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/catalog.toml +0 -0
  102. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/client.py +0 -0
  103. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/commands.py +0 -0
  104. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/config.py +0 -0
  105. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/http.py +0 -0
  106. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/manager.py +0 -0
  107. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/protocol.py +0 -0
  108. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/server.py +0 -0
  109. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/mcp/stdio.py +0 -0
  110. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/memory.py +0 -0
  111. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/models.toml +0 -0
  112. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/notify.py +0 -0
  113. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/ollama.py +0 -0
  114. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/permissions.py +0 -0
  115. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/planning.py +0 -0
  116. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/render.py +0 -0
  117. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/repeats.py +0 -0
  118. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/repomap.py +0 -0
  119. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/residency.py +0 -0
  120. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/review.py +0 -0
  121. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/sandbox.py +0 -0
  122. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/secretscan.py +0 -0
  123. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/servers/__init__.py +0 -0
  124. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/servers/encord.py +0 -0
  125. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/servers/valohai.py +0 -0
  126. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/servers/xai.py +0 -0
  127. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/sessions.py +0 -0
  128. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/steer.py +0 -0
  129. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/subagents.py +0 -0
  130. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/update.py +0 -0
  131. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/vision.py +0 -0
  132. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/src/lcode/web.py +0 -0
  133. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/fake_lsp_server.py +0 -0
  134. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/fake_mcp_server.py +0 -0
  135. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_acp.py +0 -0
  136. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_action.py +0 -0
  137. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_api.py +0 -0
  138. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_backends.py +0 -0
  139. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_catalog.py +0 -0
  140. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_checkpoints.py +0 -0
  141. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_checks.py +0 -0
  142. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_cli.py +0 -0
  143. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_clipboard.py +0 -0
  144. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_codesearch.py +0 -0
  145. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_config.py +0 -0
  146. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_context_picker.py +0 -0
  147. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_extensions.py +0 -0
  148. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_gitflow.py +0 -0
  149. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_hooks.py +0 -0
  150. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_jobs.py +0 -0
  151. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_lsp.py +0 -0
  152. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_matching.py +0 -0
  153. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_mcp.py +0 -0
  154. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_memory.py +0 -0
  155. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_permissions.py +0 -0
  156. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_plan_mode.py +0 -0
  157. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_repeats.py +0 -0
  158. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_repomap.py +0 -0
  159. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_residency.py +0 -0
  160. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_review.py +0 -0
  161. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_robustness.py +0 -0
  162. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_sandbox.py +0 -0
  163. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_secretscan.py +0 -0
  164. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_servers.py +0 -0
  165. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_sessions.py +0 -0
  166. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_steer.py +0 -0
  167. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_subagents.py +0 -0
  168. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_tools.py +0 -0
  169. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_update.py +0 -0
  170. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_web.py +0 -0
  171. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/tests/test_xai.py +0 -0
  172. {lcode_cli-0.33.1 → lcode_cli-0.35.0}/uv.lock +0 -0
@@ -6,6 +6,53 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.35.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - A stronger model when stuck: with `escalate_model` set (a cloud model with your API key, such as
14
+ `anthropic/…`, `openai/…` or `xai/…`, or a bigger local one, `ollama/qwen3.8-27b`), lcode
15
+ offers to ask it for advice when the loop breaker would stop a request or the checks fail twice
16
+ in a row, and `/escalate` asks at any time. Nothing is sent without asking, in any permission
17
+ mode: you see why, what goes (your requests, the task sheet, the failure, the last steps and the
18
+ files in question, never the whole session), where, and the cost (`escalate_price`), and can
19
+ read it all first. Keys come only from environment variables and go only to their provider.
20
+ `lcode -p --escalate` allows it for one run, and `lcode bench --retry --escalate MODEL`
21
+ measures it. Measured on a 12 GB laptop with qwen3.6-35b and the bigger local qwen3.8-27b as
22
+ the advisor, on `runtime-bug` with a second try: 1 of 3 passed with its advice, as without it
23
+ (6 of 9 hidden-test problems fixed against 4), at about 9 minutes per question. A local advisor
24
+ helps a little; a cloud model is the one to try. See
25
+ [A stronger model when stuck](https://nasser1941.github.io/lcode/usage/#a-stronger-model-when-stuck).
26
+
27
+ ### Fixed
28
+
29
+ - Long command output can't fill the disk any more. lcode keeps the full text of long outputs so the
30
+ model can read parts of them, but a command that printed gigabytes was saved whole, and the
31
+ saved outputs had no total limit, only an age (7 days). Now one is kept to its first 20 MB (the
32
+ model still gets the end and the error lines), and all of them together to 200 MB, oldest first.
33
+
34
+ - A screenshot of a web app no longer starts its server a second time when one already answers at
35
+ the URL (one the model started itself, say), which used to fail on the busy port and put the
36
+ error in the result. A page that answers also counts when the server command itself exits.
37
+
38
+ ## [0.34.0] - 2026-10-14
39
+
40
+ ### Added
41
+
42
+ - Screenshots without a screen: a `screenshot` tool runs a desktop program or opens a web page the
43
+ way you would see it, saves the shot in `.lcode/screenshots/`, and returns what it shows with the
44
+ sizes behind it. For a Python Qt program, that's each window against the screen, the size its
45
+ layout needs, and the widgets that stick out or overlap. For a web page or a local server
46
+ (headless Chrome), it's the page's size, what scrolls sideways, and the console errors and
47
+ failed requests. Other desktop programs run on a virtual display (Xvfb). See
48
+ [Screenshots](https://nasser1941.github.io/lcode/usage/#screenshots).
49
+
50
+ ### Fixed
51
+
52
+ - In a project with a virtual environment (`.venv`, `venv` or `env`), the model is told to run its
53
+ Python (`.venv/bin/python`) rather than plain `python`, which lacks the project's packages. Found
54
+ when the model checked a PySide6 app with the system Python, failed, and stopped checking.
55
+
9
56
  ## [0.33.1] - 2026-10-14
10
57
 
11
58
  ### Changed
@@ -722,7 +769,9 @@ First public release.
722
769
  - `AGENTS.md` project instructions and `/init` to generate them.
723
770
  - One-line installer for Linux and macOS.
724
771
 
725
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.33.1...HEAD
772
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.35.0...HEAD
773
+ [0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
774
+ [0.34.0]: https://github.com/nasser1941/lcode/compare/v0.33.1...v0.34.0
726
775
  [0.33.1]: https://github.com/nasser1941/lcode/compare/v0.33.0...v0.33.1
727
776
  [0.33.0]: https://github.com/nasser1941/lcode/compare/v0.32.0...v0.33.0
728
777
  [0.32.0]: https://github.com/nasser1941/lcode/compare/v0.31.0...v0.32.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.33.1
3
+ Version: 0.35.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/
@@ -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,6 +45,9 @@ 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
+ | `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 |
48
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 |
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 |
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.33.1 — local coding agent │
15
+ │ lcode v0.35.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -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 |
@@ -106,6 +107,7 @@ reasoning is on.
106
107
  | `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
107
108
  | `web_fetch` | Read a web page or text file by URL as clean text |
108
109
  | `view_image` | Look at an image: a model that can see describes it ([Images](#images)) |
110
+ | `screenshot` | Run a desktop program or open a web page without a screen, and see it with its sizes ([Screenshots](#screenshots)) |
109
111
  | `agent` | Hand a task to a [subagent](agents.md) with its own context; only its report comes back |
110
112
  | `memory` | Save, update or delete [notes](memory.md) that later sessions load |
111
113
  | `repo_map` | The [repository map](search.md): important files, classes and functions, ranked (larger repositories; small ones get it in the instructions) |
@@ -207,6 +209,51 @@ The review is asked on top of the conversation rather than in a fresh one. The m
207
209
  the processed conversation cached, and a separate review would push it out, so the next request
208
210
  would have to read the whole conversation again. On a small GPU that's tens of seconds.
209
211
 
212
+ ## A stronger model when stuck
213
+
214
+ Some problems are beyond a local model. When it's stuck, lcode can ask a stronger one for
215
+ advice: a cloud model with your API key, or a bigger local model. The local model gets the advice
216
+ and carries on with its own tools. It's off until you choose the model:
217
+
218
+ ```bash
219
+ lcode config set escalate_model anthropic/<model> # with ANTHROPIC_API_KEY in your environment
220
+ lcode config set escalate_model openai/<model> # OPENAI_API_KEY
221
+ lcode config set escalate_model xai/<model> # XAI_API_KEY
222
+ lcode config set escalate_model ollama/qwen3.8-27b # a bigger local model: free, and nothing leaves
223
+ lcode config set escalate_price 3/15 # dollars per million tokens in/out, for the cost
224
+ ```
225
+
226
+ **When.** lcode offers it when the [loop breaker](how-it-works.md#when-the-model-repeats-itself)
227
+ would stop the request, and when the project's [checks](#checks-before-done) fail twice in a row
228
+ after the model's fixes, at most once per request. You can also ask at any time: `/escalate`, or
229
+ `/escalate the window still grows` to say what's wrong.
230
+
231
+ **Nothing is sent without asking**, in any permission mode:
232
+
233
+ ```text
234
+ ╭─ Ask claude-… for help? ──────────────────────────────────────────────────╮
235
+ │ Why: the project's checks (`pytest -q`) failed 2 times in a row │
236
+ │ What goes: your requests, the task sheet, the failure, the last steps │
237
+ │ and these files: wall.py, simulate.py (3.1K tokens) │
238
+ │ Where: claude-… (Anthropic), at api.anthropic.com │
239
+ │ Cost: about $0.009 to $0.071 (3.1K tokens in at $3/M, up to 4.1K out …) │
240
+ ╰───────────────────────────────────────────────────────────────────────────╯
241
+ Send it? [y]es / [n]o / [s]how what's sent:
242
+ ```
243
+
244
+ - **What goes:** your last requests, the [task sheet](how-it-works.md#context-management), why
245
+ it's stuck (the failing check's output, the repeated call), the last few steps, and the files the
246
+ request changed or read: never the whole session or the rest of the repository. Press `s` to read
247
+ all of it before you decide.
248
+ - **Keys** come from environment variables, are read when needed, and go only to their own
249
+ provider. lcode never stores them.
250
+ - **Cost.** The token count is always shown; the price only when you set `escalate_price`, since
251
+ prices change. A local model is free.
252
+ - **Another server:** `escalate_url` points at another Ollama (`ollama/<model>`) or any OpenAI
253
+ API (`custom/<model>`, key in `LCODE_ESCALATE_API_KEY`).
254
+ - `lcode -p` can't ask, so it doesn't escalate unless you add `--escalate`, which is your OK for
255
+ that run. `--output json` lists what was asked in `escalations`.
256
+
210
257
  ## Background commands
211
258
 
212
259
  Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
@@ -342,6 +389,43 @@ Loading a second model takes a moment: on a 12 GB GPU, describing an image with
342
389
  `qwen3.6:35b-a3b-coding` takes about a minute, and the next request reloads the main model. Turn
343
390
  image support off with `lcode config set vision_model off`.
344
391
 
392
+ ## Screenshots
393
+
394
+ Layout bugs are invisible in the code: a window bigger than the screen, panels cut off, a page that
395
+ scrolls sideways. With the `screenshot` tool the model runs the program, or opens the page, without
396
+ a screen and sees what you would, measured:
397
+
398
+ ```text
399
+ ● screenshot(python camera_wall.py)
400
+ ⎿ .lcode/screenshots/20261014-120211-camera-wall.png (Qt offscreen (PyQt6), 1366x768 screen)
401
+ ```
402
+
403
+ ```text
404
+ - Window "Camera Wall" (CameraWall): 1362x764 at (0, 0)
405
+ Its layout needs at least 1950x984, more than the screen has
406
+ Sticks out of the window: Panel #panel-4: 640x480 at (9, 385)
407
+ Overlap (190x480): Panel #panel-1: 640x480 at (9, 9) and Panel #panel-2: 640x480 at (459, 9)
408
+ ```
409
+
410
+ | What | How | Besides the picture |
411
+ |---|---|---|
412
+ | A Python Qt program (PyQt5/6, PySide2/6) | Qt's offscreen platform, on a screen of the size asked for (Qt 6) | Each window's size and position, the size its layout needs, widgets that stick out or overlap |
413
+ | Any other desktop program (Linux) | A virtual display, Xvfb | The size and position of each window |
414
+ | A web page, an `.html` file or a local server | Headless Chrome, Chromium or Edge (Firefox takes the picture only) | The page's size against the window and what sticks out sideways, console errors, failed requests |
415
+
416
+ - For a web app, the model gives the command that starts the server and the page's URL: lcode
417
+ starts the server, waits for the page to answer, takes the shot and stops the server.
418
+ - A model that can see describes the picture with the model's question in mind (see
419
+ [Which model looks](#images)). Without one, the measurements still come back.
420
+ - Shots are saved in `.lcode/screenshots/` in the project, which keeps itself out of git, so you
421
+ can look too.
422
+ - Running a program asks for permission like any command. A web page that isn't local follows the
423
+ `web` setting. With the [sandbox](sandbox.md) on, programs can't be shown, since they don't run on
424
+ your machine.
425
+ - Needs: Xvfb for other desktop programs (`sudo apt install xvfb x11-apps`), and Chrome or
426
+ Chromium for web pages. On macOS only Qt programs and web pages work, since there's no virtual
427
+ display.
428
+
345
429
  ## Permissions
346
430
 
347
431
  | Mode | File edits | Shell commands |
@@ -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"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.33.1"
6
+ __version__ = "0.35.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -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,
@@ -48,7 +49,7 @@ from lcode.config import format_tokens
48
49
  from lcode.jobs import Jobs
49
50
  from lcode.mcp import McpManager
50
51
  from lcode.ollama import Ollama, OllamaError
51
- from lcode.permissions import Permissions
52
+ from lcode.permissions import LEADING_CD, Permissions
52
53
  from lcode.render import MarkdownStreamer
53
54
  from lcode.repeats import Repeats
54
55
  from lcode.sandbox import Sandbox, SandboxError, project_root
@@ -122,7 +123,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
122
123
  # Verifying your work
123
124
  - A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
124
125
  - To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
125
- - For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
126
+ - For what you can't see, like a GUI, a web page or a layout, use the screenshot tool: it runs the program (or opens the page) without a screen and returns what it shows, with the sizes of windows, widgets or the page measured{look}. Measure instead of assuming.
126
127
  - Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.{checks}
127
128
 
128
129
  # Communication
@@ -131,7 +132,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
131
132
 
132
133
  # Environment
133
134
  - Working directory: {cwd}
134
- - OS: {os}; shell: bash; Python command: `{python}`
135
+ - OS: {os}; shell: bash; Python command: {python}
135
136
  - Date: {date}
136
137
  - Git: {git}
137
138
 
@@ -218,6 +219,30 @@ def call_arguments(call: dict) -> dict:
218
219
  return args if isinstance(args, dict) else {}
219
220
 
220
221
 
222
+ VENV_DIRS = (".venv", "venv", "env")
223
+
224
+
225
+ def project_python(cwd: Path) -> Path | None:
226
+ """The project's virtual environment's Python (.venv, venv or env, here or up to the repository's root)."""
227
+ for folder in [cwd, *cwd.parents][:4]:
228
+ for name in VENV_DIRS:
229
+ path = folder / name / "bin" / "python"
230
+ if path.exists() and (folder / name / "pyvenv.cfg").exists():
231
+ return path
232
+ if (folder / ".git").exists():
233
+ break # not beyond the repository
234
+ return None
235
+
236
+
237
+ def python_line(cwd: Path) -> str:
238
+ """How the system prompt names the Python command."""
239
+ venv = project_python(cwd)
240
+ if venv is None:
241
+ return f"`{'python' if shutil.which('python') else 'python3'}`"
242
+ shown = venv.relative_to(cwd) if cwd in venv.parents else venv
243
+ return f"`{shown}` (the project's virtual environment: plain python lacks its packages)"
244
+
245
+
221
246
  class Progress:
222
247
  """The status line under a running request.
223
248
 
@@ -283,6 +308,9 @@ class Settings:
283
308
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
284
309
  checks: str = "off" # the project's checks after a change (lcode.checks): auto, off or a command
285
310
  review: str = "off" # check a change against the request before the answer (lcode.review): auto or off
311
+ escalate_model: str | None = None # a stronger model to ask when stuck (lcode.escalate): provider/model
312
+ escalate_url: str | None = None # its server, when not the provider's usual one
313
+ escalate_price: str | None = None # dollars per million tokens in/out, e.g. "3/15", for the cost estimate
286
314
 
287
315
 
288
316
  class Agent:
@@ -345,6 +373,8 @@ class Agent:
345
373
  self._check: tuple[Path, checks.Check | None] | None = None
346
374
  self.check_results: list[dict] = [] # the checks of the last request: command, exit code, who ran them
347
375
  self.review_results: list[str] | None = None # what the last request's review found (None: no review)
376
+ self.escalations: list[dict] = [] # the stronger model asked in the last request (lcode.escalate)
377
+ self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
348
378
  self.sent_chars = 0 # characters of the conversation the model had already read
349
379
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
350
380
  self.turn_status = "" # how the last request ended: success, max_steps, loop
@@ -372,7 +402,7 @@ class Agent:
372
402
  model=self.settings.model,
373
403
  cwd=self.cwd,
374
404
  os=f"{platform.system()} {platform.release()} ({platform.machine()})",
375
- python="python" if shutil.which("python") else "python3",
405
+ python=python_line(self.cwd),
376
406
  date=dt.date.today().isoformat(),
377
407
  git=git_info(self.cwd),
378
408
  tree=tree(self.cwd, depth=1, limit=120),
@@ -383,7 +413,7 @@ class Agent:
383
413
  skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
384
414
  code=self.code_prompt(),
385
415
  map=self.map_prompt(),
386
- look=" you look at with view_image" if self.vision_model() else "",
416
+ look="; look at other images with view_image" if self.vision_model() else "",
387
417
  checks=self.checks_prompt(),
388
418
  )
389
419
 
@@ -928,9 +958,8 @@ class Agent:
928
958
  )
929
959
  return None
930
960
  if round_ >= checks.MAX_ROUNDS:
931
- self.say(
932
- f"Checks still fail ({check.command}, exit code {code}): the model will say what's left.", warning=True
933
- )
961
+ then = "" if self.can_escalate() else ": the model will say what's left"
962
+ self.say(f"Checks still fail ({check.command}, exit code {code}){then}.", warning=True)
934
963
  else:
935
964
  self.say(
936
965
  f"Checks fail ({check.command}, exit code {code}): the model gets the output to fix it.", warning=True
@@ -1000,6 +1029,9 @@ class Agent:
1000
1029
  return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
1001
1030
  if name == "bash":
1002
1031
  return f"$ {str(args.get('command', ''))[:60]}"
1032
+ if name == "screenshot":
1033
+ what = args.get("url") or LEADING_CD.sub("", str(args.get("command") or ""))
1034
+ return f"screenshot({what[:60]})"
1003
1035
  if name in ("web_search", "web_fetch", "view_image"):
1004
1036
  return f"{name}({args.get('query') or args.get('url') or args.get('path') or ''})"
1005
1037
  if self.mcp and self.mcp.owns(name):
@@ -1076,6 +1108,8 @@ class Agent:
1076
1108
  self.repeats.reset()
1077
1109
  self.check_results = []
1078
1110
  self.review_results = None
1111
+ self.escalations = []
1112
+ escalated = False # the stronger model is asked at most once per request
1079
1113
  self.tools.before = {}
1080
1114
  reviewed = False
1081
1115
  changed = verified = False # files changed in this request; the checks passed since
@@ -1139,6 +1173,15 @@ class Agent:
1139
1173
  check_rounds += 1
1140
1174
  feedback = self.run_check(check, check_rounds)
1141
1175
  if feedback:
1176
+ if check_rounds >= checks.MAX_ROUNDS and not escalated and self.can_escalate():
1177
+ escalated = True
1178
+ why = f"the project's checks (`{check.command}`) failed {check_rounds} times in a row"
1179
+ advice = escalate.run(self, why, failure=feedback)
1180
+ if advice:
1181
+ self.messages.append({"role": "user", "content": advice})
1182
+ check_rounds -= 1 # the checks run once more after the advice
1183
+ changed = False
1184
+ continue
1142
1185
  self.messages.append({"role": "user", "content": feedback})
1143
1186
  changed = False # run them again only if the model changes something more
1144
1187
  continue
@@ -1232,6 +1275,14 @@ class Agent:
1232
1275
  skipped["tool_call_id"] = rest["id"]
1233
1276
  self.messages.append(skipped)
1234
1277
  break
1278
+ if looping and not escalated and self.can_escalate():
1279
+ escalated = True
1280
+ advice = escalate.run(self, f"it kept making the same call with the same result: {looping}")
1281
+ if advice:
1282
+ self.messages.append({"role": "user", "content": advice})
1283
+ self.repeats.reset()
1284
+ looping = ""
1285
+ continue
1235
1286
  if looping:
1236
1287
  self.turn_status = "loop"
1237
1288
  self.console.print(
@@ -1249,6 +1300,11 @@ class Agent:
1249
1300
  self.turn_status = "success"
1250
1301
  self.print_stats()
1251
1302
 
1303
+ def can_escalate(self) -> bool:
1304
+ """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
1305
+ model = (self.settings.escalate_model or "").strip().lower()
1306
+ return bool(model) and model != "off" and self.allowed_tools is None and not self.planning()
1307
+
1252
1308
  def steer(self) -> None:
1253
1309
  """Between two steps: pause if the user pressed Esc, then hand the model the notes typed meanwhile."""
1254
1310
  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)
@@ -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
  )
@@ -265,11 +265,16 @@ class OpenAICompatible:
265
265
  return self.chat({k: v for k, v in payload.items() if k not in ("format", "tools")}, timeout)
266
266
  if r.status_code != 200:
267
267
  raise self._failed(r.status_code, r.text)
268
- choice = (r.json().get("choices") or [{}])[0].get("message") or {}
268
+ data = r.json()
269
+ choice = (data.get("choices") or [{}])[0].get("message") or {}
269
270
  message: dict = {"role": "assistant", "content": strip_thinking(choice.get("content") or "")}
270
271
  if choice.get("tool_calls"):
271
272
  message["tool_calls"] = [self._ollama_call(c) for c in choice["tool_calls"]]
272
- return {"message": message}
273
+ reply: dict = {"message": message}
274
+ usage = data.get("usage") or {}
275
+ if usage.get("prompt_tokens") is not None: # as Ollama counts them
276
+ reply["prompt_eval_count"], reply["eval_count"] = usage["prompt_tokens"], usage.get("completion_tokens")
277
+ return reply
273
278
 
274
279
  @staticmethod
275
280
  def _ollama_call(call: dict) -> dict:
@@ -29,7 +29,7 @@ from rich.markup import escape
29
29
  from rich.table import Table
30
30
  from rich.text import Text
31
31
 
32
- from lcode import __version__
32
+ from lcode import __version__, escalate
33
33
  from lcode.agent import Agent, Settings
34
34
  from lcode.config import format_tokens
35
35
  from lcode.hardware import Hardware
@@ -790,6 +790,8 @@ class TaskResult:
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
792
  errors_by_tool: dict[str, int] = field(default_factory=dict) # which tools failed, "malformed" for unreadable calls
793
+ first_passed: bool | None = None # with --retry: whether the first try passed (None: no retry was needed)
794
+ escalations: int = 0 # times the stronger model was asked (--escalate)
793
795
 
794
796
 
795
797
  @dataclass
@@ -887,8 +889,13 @@ def run_task(
887
889
  keep: bool,
888
890
  usage: dict,
889
891
  agent: Agent | None = None,
892
+ retry: bool = False,
890
893
  ) -> TaskResult:
891
- """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session)."""
894
+ """Run one task in a new folder: in a new conversation, or continuing `agent`'s (--session).
895
+
896
+ With `retry`, a failed task gets one more try, as a user would ask: "That didn't fix it. Look again." With a
897
+ stronger model set (--escalate), its advice goes along, as with /escalate.
898
+ """
892
899
  folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
893
900
  try:
894
901
  for rel, content in task.files.items():
@@ -906,25 +913,28 @@ def run_task(
906
913
  if model:
907
914
  codesearch.Index(folder, model).update(ollama) # on the CPU: lcode's model stays loaded
908
915
  agent._code_index = None
916
+ agent.escalate_approved = True if settings.escalate_model else None # the --escalate flag is the OK
909
917
  before = dict(agent.usage)
910
918
  first = len(agent.messages)
911
- error = None
912
919
  started = time.monotonic()
913
- try:
914
- with time_limit(timeout):
915
- agent.run_turn(task.prompt)
916
- except TaskTimeout:
917
- error = f"timed out after {timeout:.0f}s"
918
- except OllamaError as e:
919
- error = str(e).splitlines()[0][:200]
920
+ escalations = 0
921
+ error = attempt(agent, task.prompt, timeout)
922
+ escalations += len(agent.escalations)
923
+ check = checked(task, folder, agent)
924
+ first_passed = None
925
+ if retry and not (check.passed and error is None):
926
+ first_passed = False
927
+ text = RETRY
928
+ if error is None and agent.can_escalate():
929
+ advice = escalate.run(agent, "the user says the fix didn't work")
930
+ escalations += 1 if advice else 0
931
+ text = f"{RETRY}\n\n{advice}" if advice else RETRY
932
+ error = attempt(agent, text, timeout)
933
+ escalations += len(agent.escalations)
934
+ check = checked(task, folder, agent)
920
935
  seconds = time.monotonic() - started
921
936
  for key, value in agent.usage.items():
922
937
  usage[key] = usage.get(key, 0) + value - before.get(key, 0)
923
- answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
924
- try:
925
- check = task.check(folder, answer)
926
- except Exception as e: # a check must never crash the benchmark
927
- check = Check(False, f"check failed: {type(e).__name__}: {e}")
928
938
  finally: # also when Ctrl+C stops the task
929
939
  from lcode.codesearch import index_dir
930
940
 
@@ -942,9 +952,34 @@ def run_task(
942
952
  tool_errors=count_tool_errors(agent.messages[first:] if len(agent.messages) > first else agent.messages),
943
953
  errors_by_tool=errors_by_tool(agent.messages[first:] if len(agent.messages) > first else agent.messages),
944
954
  error=error,
955
+ first_passed=first_passed,
956
+ escalations=escalations,
945
957
  )
946
958
 
947
959
 
960
+ RETRY = "That didn't fix it: it still doesn't work right. Look again."
961
+
962
+
963
+ def attempt(agent: Agent, text: str, timeout: float) -> str | None:
964
+ """One request of a task; why it stopped early, if it did."""
965
+ try:
966
+ with time_limit(timeout):
967
+ agent.run_turn(text)
968
+ except TaskTimeout:
969
+ return f"timed out after {timeout:.0f}s"
970
+ except OllamaError as e:
971
+ return str(e).splitlines()[0][:200]
972
+ return None
973
+
974
+
975
+ def checked(task: Task, folder: Path, agent: Agent) -> Check:
976
+ answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
977
+ try:
978
+ return task.check(folder, answer)
979
+ except Exception as e: # a check must never crash the benchmark
980
+ return Check(False, f"check failed: {type(e).__name__}: {e}")
981
+
982
+
948
983
  def prompt_speed(ollama: Ollama, settings: Settings, options: dict) -> float | None:
949
984
  """Prompt tokens per second for prompts the model hasn't seen: the faster of two readings.
950
985
 
@@ -998,6 +1033,7 @@ def run_model(
998
1033
  keep: bool = False,
999
1034
  verbose: bool = False,
1000
1035
  session: bool = False,
1036
+ retry: bool = False,
1001
1037
  ) -> ModelRun:
1002
1038
  run = ModelRun(name, settings.model, settings.context, settings.num_batch, settings.think, session=session)
1003
1039
  shared: Agent | None = None # with --session, one conversation for all tasks
@@ -1038,10 +1074,10 @@ def run_model(
1038
1074
  try:
1039
1075
  if verbose:
1040
1076
  console.rule(f"{task.id}: {task.title}")
1041
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1077
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1042
1078
  else:
1043
1079
  with console.status(Elapsed(f" {task.id:<13} {task.title}")):
1044
- result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared)
1080
+ result = run_task(ollama, settings, task, timeout, log, keep, run.usage, shared, retry)
1045
1081
  except KeyboardInterrupt:
1046
1082
  console.print(" [yellow]Stopped.[/]")
1047
1083
  run.interrupted = True