lcode-cli 0.34.0__tar.gz → 0.35.1__tar.gz

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