lcode-cli 0.8.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CHANGELOG.md +20 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/PKG-INFO +4 -2
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/README.md +3 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/configuration.md +1 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/how-it-works.md +1 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/usage.md +46 -3
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/pyproject.toml +1 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/agent.py +12 -2
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/cli.py +4 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/config.py +2 -2
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/commands.py +2 -2
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/memory.py +1 -1
- lcode_cli-0.9.0/src/lcode/planning.py +138 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/repl.py +18 -2
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/tools.py +31 -1
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_mcp.py +15 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_memory.py +1 -0
- lcode_cli-0.9.0/tests/test_plan_mode.py +161 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.editorconfig +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.gitignore +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/AGENTS.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/LICENSE +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/SECURITY.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/agents.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/bench.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/changelog.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/contributing.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/index.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/installation.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/mcp.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/memory.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/models.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/install.sh +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/mkdocs.yml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/overrides/main.html +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/conftest.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_config.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/tests/test_web.py +0 -0
- {lcode_cli-0.8.0 → lcode_cli-0.9.0}/uv.lock +0 -0
|
@@ -6,6 +6,24 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-10-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Plan mode: a `plan` permission mode (Shift+Tab from `ask`, `/mode plan`, `lcode --plan`, or
|
|
14
|
+
`/plan <request>`) in which the model can read, search, run read-only commands, use the web and
|
|
15
|
+
read-only subagents, but can't change anything. It presents a plan with the new `present_plan`
|
|
16
|
+
tool, and you approve it (in `ask` or `auto-edit` mode), edit it, save it to `.lcode/plans/`, or
|
|
17
|
+
send it back with feedback. An approved plan fills the todo list and is kept through compaction;
|
|
18
|
+
`/plan` shows it. See [Plan mode](https://nasser1941.github.io/lcode/usage/#plan-mode).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `lcode mcp add` shows the default value in its questions again, such as `AWS region
|
|
23
|
+
[us-east-1]`; the terminal formatting swallowed it.
|
|
24
|
+
- The end-of-session "Worth remembering?" list shows each note's type (`[feedback]`, `[project]`, …),
|
|
25
|
+
which the same problem hid.
|
|
26
|
+
|
|
9
27
|
## [0.8.0] - 2026-10-05
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -246,7 +264,8 @@ First public release.
|
|
|
246
264
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
247
265
|
- One-line installer for Linux and macOS.
|
|
248
266
|
|
|
249
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
267
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.9.0...HEAD
|
|
268
|
+
[0.9.0]: https://github.com/nasser1941/lcode/compare/v0.8.0...v0.9.0
|
|
250
269
|
[0.8.0]: https://github.com/nasser1941/lcode/compare/v0.7.0...v0.8.0
|
|
251
270
|
[0.7.0]: https://github.com/nasser1941/lcode/compare/v0.6.1...v0.7.0
|
|
252
271
|
[0.6.1]: https://github.com/nasser1941/lcode/compare/v0.6.0...v0.6.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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/
|
|
@@ -80,6 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
80
80
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
81
81
|
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
82
82
|
Podman) that only sees your project, without network access unless you allow it.
|
|
83
|
+
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
84
|
+
you approve, edit or send it back before anything changes.
|
|
83
85
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
84
86
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
85
87
|
- **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
|
|
@@ -195,7 +197,7 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
195
197
|
|
|
196
198
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
197
199
|
|
|
198
|
-
- [
|
|
200
|
+
- [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
|
|
199
201
|
- [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
|
|
200
202
|
- Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
|
|
201
203
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
@@ -46,6 +46,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
46
46
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
47
47
|
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
48
48
|
Podman) that only sees your project, without network access unless you allow it.
|
|
49
|
+
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
50
|
+
you approve, edit or send it back before anything changes.
|
|
49
51
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
50
52
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
51
53
|
- **Subagents.** The model hands broad searches, planning and self-contained changes to subagents
|
|
@@ -161,7 +163,7 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
161
163
|
|
|
162
164
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
163
165
|
|
|
164
|
-
- [
|
|
166
|
+
- [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
|
|
165
167
|
- [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
|
|
166
168
|
- Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
|
|
167
169
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
@@ -20,7 +20,7 @@ lcode config path # print the file location
|
|
|
20
20
|
| `num_batch` | 512 | Prompt batch size. Larger reads prompts faster but needs more GPU memory |
|
|
21
21
|
| `keep_alive` | `30m` | How long Ollama keeps the model in memory after the last request |
|
|
22
22
|
| `ollama_host` | `http://localhost:11434` | Ollama server URL |
|
|
23
|
-
| `permission_mode` | `ask` | `ask`, `auto-edit` or `yolo` |
|
|
23
|
+
| `permission_mode` | `ask` | `ask`, `plan`, `auto-edit` or `yolo` |
|
|
24
24
|
| `think` | `true` | Let the model reason before answering |
|
|
25
25
|
| `web` | `on` | Web search and page fetching: `on`, `ask` (before each search/website) or `off` |
|
|
26
26
|
| `search_backend` | `auto` | `auto`, `ollama`, `brave`, `tavily` or `searxng`; `auto` uses the first one configured |
|
|
@@ -89,6 +89,7 @@ the Ollama server you configure, it only contacts the web when the model searche
|
|
|
89
89
|
| `bench.py` | `lcode bench`: the benchmark tasks, their checks and the reports |
|
|
90
90
|
| `vision.py` | Looking at images with a model that can see |
|
|
91
91
|
| `subagents.py` | Subagents: agent types, running them (also in parallel), worktrees for workers |
|
|
92
|
+
| `planning.py` | Plan mode: what it allows, presenting a plan and approving it |
|
|
92
93
|
| `memory.py` | Notes that carry over between sessions, and the end-of-session check |
|
|
93
94
|
| `sandbox.py` | The optional container for shell commands |
|
|
94
95
|
| `checkpoints.py` | Snapshots before the model changes files, for `/undo` and `/rewind` |
|
|
@@ -20,6 +20,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
|
|
|
20
20
|
| `-r, --repo DIR` | Work in another directory |
|
|
21
21
|
| `-c, --continue` | Continue the most recently used session in this directory |
|
|
22
22
|
| `--resume [SESSION]` | Resume a saved session: pick from a list, or give its number, name or id |
|
|
23
|
+
| `--plan` | Start in [plan mode](#plan-mode): agree on a plan before anything changes |
|
|
23
24
|
| `--auto-edit` | Apply file edits without asking (commands still ask) |
|
|
24
25
|
| `--yolo` | Never ask for permission |
|
|
25
26
|
| `--no-think` | Turn off the model's reasoning: faster, less accurate |
|
|
@@ -38,7 +39,7 @@ lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases
|
|
|
38
39
|
| ++esc++ ++enter++ or a trailing `\` | New line |
|
|
39
40
|
| ++ctrl+c++ | Interrupt the model or a running command |
|
|
40
41
|
| ++ctrl+d++ | Quit |
|
|
41
|
-
| ++shift+tab++ | Cycle permission mode: ask → auto-edit → yolo |
|
|
42
|
+
| ++shift+tab++ | Cycle permission mode: ask → plan → auto-edit → yolo |
|
|
42
43
|
| ++tab++ | Complete slash commands and `@` paths |
|
|
43
44
|
| `@path` | Attach a file (or a directory listing) to your message |
|
|
44
45
|
| `@name` | Ask for a [subagent](agents.md) of that type, e.g. `@plan` |
|
|
@@ -63,12 +64,13 @@ reasoning is on.
|
|
|
63
64
|
| `/memory [show\|edit\|delete N]` | The [notes](memory.md) lcode remembers |
|
|
64
65
|
| `/sandbox [network on\|off]` | The [sandbox](sandbox.md) for shell commands |
|
|
65
66
|
| `/mcp [tools\|login\|restart NAME]` | [MCP servers](mcp.md), their status and tools |
|
|
67
|
+
| `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
|
|
66
68
|
| `/compact [focus]` | Summarize the conversation to free context |
|
|
67
69
|
| `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
|
|
68
70
|
| `/model [name]`, `/models` | Switch model, list models |
|
|
69
71
|
| `/think [on\|off]` | Toggle reasoning |
|
|
70
72
|
| `/verbose` | Toggle showing the reasoning text |
|
|
71
|
-
| `/mode [ask\|auto-edit\|yolo]` | Set the permission mode |
|
|
73
|
+
| `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
|
|
72
74
|
| `/cd DIR` | Change the working directory |
|
|
73
75
|
| `/todos` | Show the model's task list |
|
|
74
76
|
| `/exit` | Quit |
|
|
@@ -90,6 +92,7 @@ reasoning is on.
|
|
|
90
92
|
| `view_image` | Look at an image: a model that can see describes it ([Images](#images)) |
|
|
91
93
|
| `agent` | Hand a task to a [subagent](agents.md) with its own context; only its report comes back |
|
|
92
94
|
| `memory` | Save, update or delete [notes](memory.md) that later sessions load |
|
|
95
|
+
| `present_plan` | In [plan mode](#plan-mode): show the plan and ask for approval |
|
|
93
96
|
|
|
94
97
|
lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
|
|
95
98
|
it was read, so the model always edits the current version.
|
|
@@ -127,12 +130,16 @@ image support off with `lcode config set vision_model off`.
|
|
|
127
130
|
| Mode | File edits | Shell commands |
|
|
128
131
|
|---|---|---|
|
|
129
132
|
| `ask` (default) | ask | ask, except read-only commands |
|
|
133
|
+
| `plan` | refused | only read-only commands ([Plan mode](#plan-mode)) |
|
|
130
134
|
| `auto-edit` | automatic | ask, except read-only commands |
|
|
131
135
|
| `yolo` | automatic | automatic |
|
|
132
136
|
|
|
137
|
+
++shift+tab++ cycles through them in this order, `/mode` sets one, and `--plan`, `--auto-edit` and
|
|
138
|
+
`--yolo` start in one.
|
|
139
|
+
|
|
133
140
|
Read-only commands run without asking: `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`,
|
|
134
141
|
`wc`, `diff`, `git status/log/diff/show/branch/blame` and similar, as long as they don't redirect
|
|
135
|
-
output, chain commands or run subshells.
|
|
142
|
+
output, chain commands or run subshells. A `cd <folder> &&` in front is fine.
|
|
136
143
|
|
|
137
144
|
When asked, answer ++y++ (once), ++a++ (always, for this command or for all edits, until you quit)
|
|
138
145
|
or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests with -x first`.
|
|
@@ -146,6 +153,42 @@ or ++n++. Text after ++n++ goes to the model as instructions: `n run the tests w
|
|
|
146
153
|
With the [sandbox](sandbox.md) on, `auto-edit` mode runs shell commands without asking too: they can
|
|
147
154
|
only reach the project, and `/undo` can take their changes back.
|
|
148
155
|
|
|
156
|
+
## Plan mode
|
|
157
|
+
|
|
158
|
+
Local models are slower than cloud models and make more mistakes, so on a bigger change it pays to
|
|
159
|
+
agree on the approach before the model starts editing. In **plan mode** the model can read, search,
|
|
160
|
+
run read-only commands, use the web and [explore and plan subagents](agents.md), but it can't
|
|
161
|
+
change anything. It ends by presenting a plan: numbered steps, the files it will change, the risks
|
|
162
|
+
and how it will verify the work.
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
❯ /plan add retries to the webhook handlers
|
|
166
|
+
…
|
|
167
|
+
╭─ Plan: Retries for webhook handlers ───────────────────────────────────────────╮
|
|
168
|
+
│ 1. Wrap the HTTP call in payments/webhooks.py:88 in a retry loop (3 tries, …) │
|
|
169
|
+
│ 2. Add tests for a timeout and a 500 in tests/test_webhooks.py │
|
|
170
|
+
│ Risks: duplicate deliveries if the endpoint isn't idempotent … │
|
|
171
|
+
│ Verify: pytest tests/test_webhooks.py │
|
|
172
|
+
╰────────────────────────────────────────────────────────────────────────────────╯
|
|
173
|
+
[y] run it, asking before changes · [a] run it, edits without asking (auto-edit)
|
|
174
|
+
[e] edit the plan · [s] save it to .lcode/plans/ · [n] keep planning (+ what to change)
|
|
175
|
+
>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
| Answer | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| ++y++ or ++enter++ | Approve: lcode switches to `ask` mode and the model starts on the plan right away |
|
|
181
|
+
| ++a++ | Approve and switch to `auto-edit`, so file edits don't ask |
|
|
182
|
+
| ++e++ | Edit the plan in your editor first; the model follows your version |
|
|
183
|
+
| ++s++ | Save it as `.lcode/plans/<title>.md`, for example to share it or commit it |
|
|
184
|
+
| ++n++ + text | Send it back: `n keep the old API working` |
|
|
185
|
+
|
|
186
|
+
After approval, the todo list is filled from the plan's numbered steps, and the plan is kept word
|
|
187
|
+
for word when the conversation is compacted. `/plan` shows the approved plan.
|
|
188
|
+
|
|
189
|
+
Start planning with `/plan <request>` (switches to plan mode and sends the request), with
|
|
190
|
+
++shift+tab++ (one step from `ask`), or with `lcode --plan`.
|
|
191
|
+
|
|
149
192
|
## Undo and checkpoints
|
|
150
193
|
|
|
151
194
|
Before the model first changes anything in a request (an edit, a new file, or a shell command that
|
|
@@ -71,6 +71,7 @@ ignore = [
|
|
|
71
71
|
"src/lcode/agent.py" = ["E501"] # the prompts are prose; wrapping them would change what the model sees
|
|
72
72
|
"src/lcode/memory.py" = ["E501"]
|
|
73
73
|
"src/lcode/subagents.py" = ["E501"]
|
|
74
|
+
"src/lcode/planning.py" = ["E501"]
|
|
74
75
|
|
|
75
76
|
[tool.pytest.ini_options]
|
|
76
77
|
testpaths = ["tests"]
|
|
@@ -21,7 +21,7 @@ from rich.markup import escape
|
|
|
21
21
|
from rich.panel import Panel
|
|
22
22
|
from rich.text import Text
|
|
23
23
|
|
|
24
|
-
from lcode import catalog, limits, sessions, subagents, vision, web
|
|
24
|
+
from lcode import catalog, limits, planning, sessions, subagents, vision, web
|
|
25
25
|
from lcode import memory as memory_notes
|
|
26
26
|
from lcode.checkpoints import Checkpoints
|
|
27
27
|
from lcode.config import format_tokens
|
|
@@ -32,6 +32,7 @@ from lcode.render import MarkdownStreamer
|
|
|
32
32
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
33
33
|
from lcode.tools import (
|
|
34
34
|
MEMORY_SCHEMA,
|
|
35
|
+
PRESENT_PLAN_SCHEMA,
|
|
35
36
|
SCHEMAS,
|
|
36
37
|
VIEW_IMAGE_SCHEMA,
|
|
37
38
|
WEB_FETCH_SCHEMA,
|
|
@@ -198,6 +199,7 @@ class Agent:
|
|
|
198
199
|
self._memory: memory_notes.Memory | None = None
|
|
199
200
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
200
201
|
self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
|
|
202
|
+
self.plan = "" # the plan the user approved; kept through compaction
|
|
201
203
|
# Set on subagents (see lcode.subagents):
|
|
202
204
|
self.allowed_tools: set[str] | None = None # None: every tool
|
|
203
205
|
self.read_only = False # only read-only shell commands
|
|
@@ -259,6 +261,9 @@ class Agent:
|
|
|
259
261
|
assert self._agent_types is not None
|
|
260
262
|
return self._agent_types[2]
|
|
261
263
|
|
|
264
|
+
def planning(self) -> bool:
|
|
265
|
+
return self.perms.mode == "plan"
|
|
266
|
+
|
|
262
267
|
def tool_names(self) -> set[str]:
|
|
263
268
|
return {s["function"]["name"] for s in self.tool_schemas()}
|
|
264
269
|
|
|
@@ -283,6 +288,8 @@ class Agent:
|
|
|
283
288
|
schemas.append(MEMORY_SCHEMA)
|
|
284
289
|
if self.settings.subagents:
|
|
285
290
|
schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
|
|
291
|
+
if self.planning():
|
|
292
|
+
schemas.append(PRESENT_PLAN_SCHEMA)
|
|
286
293
|
if self.mcp:
|
|
287
294
|
schemas += self.mcp.schemas(self.settings.context)
|
|
288
295
|
if self.allowed_tools is not None:
|
|
@@ -638,7 +645,8 @@ class Agent:
|
|
|
638
645
|
|
|
639
646
|
def _run_turn(self, user_text: str, max_steps: int = MAX_STEPS_PER_TURN) -> None:
|
|
640
647
|
self.prepare_mcp()
|
|
641
|
-
self.
|
|
648
|
+
note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
|
|
649
|
+
self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
|
|
642
650
|
malformed = 0
|
|
643
651
|
for _ in range(max_steps):
|
|
644
652
|
if self.cancel is not None and self.cancel.is_set():
|
|
@@ -777,6 +785,8 @@ class Agent:
|
|
|
777
785
|
{"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
|
|
778
786
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
779
787
|
]
|
|
788
|
+
if self.plan: # the approved plan stays word for word
|
|
789
|
+
self.messages[1]["content"] += f"\n\n[The plan the user approved; keep following it]\n\n{self.plan}"
|
|
780
790
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
781
791
|
self.reflected = len(self.messages)
|
|
782
792
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
@@ -519,7 +519,9 @@ def cmd_chat(args) -> None:
|
|
|
519
519
|
context, note = choose_context(ollama, model, spec, requested_ctx, hw)
|
|
520
520
|
if note:
|
|
521
521
|
console.print(f"[yellow]Context {format_tokens(context)}: {note}[/]")
|
|
522
|
-
mode =
|
|
522
|
+
mode = (
|
|
523
|
+
"yolo" if args.yolo else ("auto-edit" if args.auto_edit else ("plan" if args.plan else cfg["permission_mode"]))
|
|
524
|
+
)
|
|
523
525
|
num_batch = cfg["num_batch"]
|
|
524
526
|
if num_batch is None and spec and spec.num_batch:
|
|
525
527
|
if model == spec.local_name:
|
|
@@ -589,6 +591,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
589
591
|
metavar="SESSION",
|
|
590
592
|
help="resume a saved session: pick from a list, or give its number, name or id",
|
|
591
593
|
)
|
|
594
|
+
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
592
595
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
593
596
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
594
597
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
@@ -21,7 +21,7 @@ else:
|
|
|
21
21
|
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "lcode"
|
|
22
22
|
|
|
23
23
|
DEFAULT_MODEL = "qwen3.6-35b"
|
|
24
|
-
PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
24
|
+
PERMISSION_MODES = ("ask", "plan", "auto-edit", "yolo") # Shift+Tab cycles in this order
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
@@ -35,7 +35,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
35
35
|
"num_batch": (None, int, "prompt batch size; larger reads prompts faster but needs more VRAM"),
|
|
36
36
|
"keep_alive": ("30m", str, "how long Ollama keeps the model loaded after the last request"),
|
|
37
37
|
"ollama_host": ("http://localhost:11434", str, "Ollama server URL"),
|
|
38
|
-
"permission_mode": ("ask", str, "ask | auto-edit | yolo"),
|
|
38
|
+
"permission_mode": ("ask", str, "ask | plan | auto-edit | yolo"),
|
|
39
39
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
40
40
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
41
41
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
@@ -216,9 +216,9 @@ def _ask(spec: catalog.Input, console: Console) -> str | None:
|
|
|
216
216
|
source = " ".join(spec.from_command)
|
|
217
217
|
if Confirm.ask(f" Use the token from `{source}`?", default=True, console=console):
|
|
218
218
|
return suggestion
|
|
219
|
-
default = f" [{spec.default}]" if spec.default else ""
|
|
219
|
+
default = f" \\[{escape(spec.default)}]" if spec.default else "" # escaped, or rich takes it for markup
|
|
220
220
|
while True:
|
|
221
|
-
value = console.input(f" {spec.prompt}{default}: ", password=spec.secret).strip()
|
|
221
|
+
value = console.input(f" {escape(spec.prompt)}{default}: ", password=spec.secret).strip()
|
|
222
222
|
value = value or spec.default or ""
|
|
223
223
|
if value or spec.optional:
|
|
224
224
|
return value or None
|
|
@@ -507,7 +507,7 @@ def reflect(agent: Agent) -> None:
|
|
|
507
507
|
where = "every repository" if note.scope == "user" else "this repository"
|
|
508
508
|
update = memory.find(note.name, note.scope)
|
|
509
509
|
rows.append(
|
|
510
|
-
f"[cyan]{i}[/] [{note.type}] {escape(note.description)} [dim]({where}"
|
|
510
|
+
f"[cyan]{i}[/] \\[{note.type}] {escape(note.description)} [dim]({where}"
|
|
511
511
|
f"{', updates ' + escape(update.name) if update else ''})[/]"
|
|
512
512
|
+ (f"\n [dim]{escape(note.details)}[/]" if note.details else "")
|
|
513
513
|
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Plan mode: agree on an approach before anything changes.
|
|
2
|
+
|
|
3
|
+
In the `plan` permission mode the model can read, search, run read-only commands, use the web and
|
|
4
|
+
read-only subagents, but can't change anything. It ends by presenting a plan with the
|
|
5
|
+
present_plan tool. The user approves it (lcode switches to `ask` or `auto-edit`, fills the todo
|
|
6
|
+
list from the plan's steps and keeps the plan through compaction), edits it, saves it to
|
|
7
|
+
`.lcode/plans/`, or sends it back with feedback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from rich.markdown import Markdown
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
|
|
25
|
+
from lcode.checkpoints import work_tree_for
|
|
26
|
+
from lcode.memory import slugify
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lcode.agent import Agent
|
|
30
|
+
|
|
31
|
+
# Tools that can't change anything (bash is limited to read-only commands, agent to read-only types).
|
|
32
|
+
PLAN_MODE_TOOLS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
|
|
35
|
+
"agent", "memory", "present_plan", "mcp_find_tools",
|
|
36
|
+
}
|
|
37
|
+
) # fmt: skip
|
|
38
|
+
|
|
39
|
+
NOTE = """
|
|
40
|
+
|
|
41
|
+
[lcode] Plan mode is on: you can read, search, run read-only commands, use the web and read-only subagents, but you can't change anything yet. Work out how to do this, then call present_plan with: numbered steps, the files you'll change and how, the risks, and how you'll verify the result. Ask the user only about decisions that are genuinely theirs."""
|
|
42
|
+
|
|
43
|
+
BLOCKED = (
|
|
44
|
+
"Plan mode is on, so nothing can be changed yet. Finish working out the plan and present it with "
|
|
45
|
+
"present_plan; once the user approves it, you can make the changes."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
CHOICES = (
|
|
49
|
+
" [bold]\\[y][/] run it, asking before changes · [bold]\\[a][/] run it, edits without asking (auto-edit)\n"
|
|
50
|
+
" [bold]\\[e][/] edit the plan · [bold]\\[s][/] save it to .lcode/plans/ · [bold]\\[n][/] keep planning (+ what to change)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def steps(plan: str, limit: int = 20) -> list[str]:
|
|
55
|
+
"""The plan's numbered steps (top level only), for the todo list."""
|
|
56
|
+
found = []
|
|
57
|
+
for line in plan.splitlines():
|
|
58
|
+
m = re.match(r"^ {0,3}\d+[.)]\s+(.+)", line)
|
|
59
|
+
if m:
|
|
60
|
+
# Plain text for the todo list: no bold, code or emphasis marks (underscores in names stay).
|
|
61
|
+
text = m.group(1).replace("**", "").replace("__", "").replace("`", "")
|
|
62
|
+
text = re.sub(r"(?<!\w)[*_]([^*_]+)[*_](?!\w)", r"\1", text).strip().rstrip(":").strip()
|
|
63
|
+
found.append(text if len(text) <= 120 else text[:119].rstrip() + "…")
|
|
64
|
+
return found[:limit]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def edit_text(text: str) -> str:
|
|
68
|
+
"""Let the user edit `text` in their editor; returns the result."""
|
|
69
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("nano" if shutil.which("nano") else "vi")
|
|
70
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
|
|
71
|
+
f.write(text)
|
|
72
|
+
path = Path(f.name)
|
|
73
|
+
try:
|
|
74
|
+
subprocess.call([*shlex.split(editor), str(path)])
|
|
75
|
+
return path.read_text()
|
|
76
|
+
finally:
|
|
77
|
+
path.unlink(missing_ok=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save(agent: Agent, title: str, plan: str) -> Path:
|
|
81
|
+
folder = work_tree_for(agent.cwd) / ".lcode" / "plans"
|
|
82
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
path = folder / f"{slugify(title) or 'plan'}.md"
|
|
84
|
+
path.write_text(f"# {title}\n\n{plan.strip()}\n")
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def present(agent: Agent, title: str, plan: str) -> str:
|
|
89
|
+
"""Show the plan and let the user decide. Returns the tool result for the model."""
|
|
90
|
+
c = agent.console
|
|
91
|
+
title = " ".join(title.split()) or "Plan"
|
|
92
|
+
plan = plan.strip()
|
|
93
|
+
if not plan:
|
|
94
|
+
return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work."
|
|
95
|
+
edited = False
|
|
96
|
+
while True:
|
|
97
|
+
c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan"))
|
|
98
|
+
if not agent.interactive:
|
|
99
|
+
return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
|
|
100
|
+
c.print(CHOICES)
|
|
101
|
+
try:
|
|
102
|
+
answer = input(" > ").strip()
|
|
103
|
+
except EOFError:
|
|
104
|
+
return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
|
|
105
|
+
word = answer.lower()
|
|
106
|
+
if word in ("", "y", "yes"):
|
|
107
|
+
return approve(agent, title, plan, "ask", edited)
|
|
108
|
+
if word in ("a", "auto", "auto-edit"):
|
|
109
|
+
return approve(agent, title, plan, "auto-edit", edited)
|
|
110
|
+
if word in ("e", "edit"):
|
|
111
|
+
new = edit_text(plan).strip()
|
|
112
|
+
if new and new != plan:
|
|
113
|
+
plan, edited = new, True
|
|
114
|
+
continue
|
|
115
|
+
if word in ("s", "save"):
|
|
116
|
+
path = save(agent, title, plan)
|
|
117
|
+
c.print(f"[green]Saved to {escape(agent.tools.rel(path))}[/]")
|
|
118
|
+
continue
|
|
119
|
+
feedback = re.sub(r"^(no|n)\b[\s,:-]*", "", answer, flags=re.I).strip()
|
|
120
|
+
if feedback:
|
|
121
|
+
return f"The user wants changes to the plan: {feedback}\nStay in plan mode: revise the plan and present it again."
|
|
122
|
+
return "The user didn't approve the plan. Ask them what they'd like to change."
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def approve(agent: Agent, title: str, plan: str, mode: str, edited: bool) -> str:
|
|
126
|
+
agent.perms.mode = mode
|
|
127
|
+
agent.plan = f"# {title}\n\n{plan}"
|
|
128
|
+
todo = steps(plan)
|
|
129
|
+
if todo:
|
|
130
|
+
agent.tools.todos = [{"content": step, "status": "pending"} for step in todo]
|
|
131
|
+
agent.tools.show_todos()
|
|
132
|
+
how = "file edits no longer ask (auto-edit)" if mode == "auto-edit" else "changes ask for approval as usual"
|
|
133
|
+
agent.console.print(f"[green]Plan approved.[/] Mode: {mode} — {how}.")
|
|
134
|
+
result = f"The user approved the plan, and plan mode is off ({mode} mode: {how}). Carry it out now, step by step"
|
|
135
|
+
result += ", keeping the todo list up to date." if todo else "."
|
|
136
|
+
if edited:
|
|
137
|
+
result += f"\nThe user edited the plan first. This is the approved version:\n\n{plan}"
|
|
138
|
+
return result
|
|
@@ -18,6 +18,7 @@ from prompt_toolkit.formatted_text import HTML
|
|
|
18
18
|
from prompt_toolkit.history import FileHistory
|
|
19
19
|
from prompt_toolkit.key_binding import KeyBindings
|
|
20
20
|
from rich.console import Group
|
|
21
|
+
from rich.markdown import Markdown
|
|
21
22
|
from rich.markup import escape
|
|
22
23
|
from rich.panel import Panel
|
|
23
24
|
from rich.rule import Rule
|
|
@@ -55,7 +56,8 @@ COMMANDS = {
|
|
|
55
56
|
"/models": "List models and how they fit this machine",
|
|
56
57
|
"/think": "Toggle model reasoning on/off",
|
|
57
58
|
"/verbose": "Toggle showing the model's reasoning text",
|
|
58
|
-
"/
|
|
59
|
+
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
|
|
60
|
+
"/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
|
|
59
61
|
"/cd": "Change the working directory",
|
|
60
62
|
"/todos": "Show the current todo list",
|
|
61
63
|
"/exit": "Quit",
|
|
@@ -118,7 +120,7 @@ def build_session(agent: Agent) -> PromptSession:
|
|
|
118
120
|
def toolbar():
|
|
119
121
|
s = agent.settings
|
|
120
122
|
pct = 100 * agent.ctx_used / s.context
|
|
121
|
-
color = {"ask": "ansigreen", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
|
|
123
|
+
color = {"ask": "ansigreen", "plan": "ansicyan", "auto-edit": "ansiyellow", "yolo": "ansired"}[agent.perms.mode]
|
|
122
124
|
return HTML(
|
|
123
125
|
f" <b>{html.escape(s.model)}</b> · ctx {format_tokens(agent.ctx_used)}/{format_tokens(s.context)} "
|
|
124
126
|
f"({pct:.0f}%) · mode <{color}>{agent.perms.mode}</{color}> (shift+tab) · "
|
|
@@ -772,6 +774,20 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
772
774
|
elif cmd == "/verbose":
|
|
773
775
|
s.show_thinking = not s.show_thinking
|
|
774
776
|
c.print(f"Showing reasoning text: {'on' if s.show_thinking else 'off'}.")
|
|
777
|
+
elif cmd == "/plan":
|
|
778
|
+
if not arg:
|
|
779
|
+
if agent.plan:
|
|
780
|
+
c.print(Panel(Markdown(agent.plan), title="Approved plan", title_align="left", border_style="cyan"))
|
|
781
|
+
else:
|
|
782
|
+
c.print(
|
|
783
|
+
"No approved plan in this session. [bold]/plan <request>[/] works out a plan first, without "
|
|
784
|
+
"changing anything (or Shift+Tab to plan mode)."
|
|
785
|
+
)
|
|
786
|
+
return True
|
|
787
|
+
agent.perms.mode = "plan"
|
|
788
|
+
c.print("[cyan]Plan mode:[/] the model can look around but not change anything until you approve its plan.")
|
|
789
|
+
c.print(Rule(style="dim"))
|
|
790
|
+
run_safely(agent, arg)
|
|
775
791
|
elif cmd == "/mode":
|
|
776
792
|
if arg in PERMISSION_MODES:
|
|
777
793
|
agent.perms.mode = arg
|
|
@@ -24,6 +24,7 @@ from rich.text import Text
|
|
|
24
24
|
|
|
25
25
|
from lcode import web
|
|
26
26
|
from lcode.permissions import bash_key, is_read_only
|
|
27
|
+
from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
|
|
27
28
|
from lcode.sandbox import SandboxError
|
|
28
29
|
from lcode.vision import is_image
|
|
29
30
|
|
|
@@ -198,8 +199,22 @@ MEMORY_SCHEMA = _fn(
|
|
|
198
199
|
},
|
|
199
200
|
["action"],
|
|
200
201
|
)
|
|
202
|
+
PRESENT_PLAN_SCHEMA = _fn(
|
|
203
|
+
"present_plan",
|
|
204
|
+
"Plan mode: show the user your plan and ask for approval. Once approved, plan mode ends and you carry it out.",
|
|
205
|
+
{
|
|
206
|
+
"title": {"type": "string", "description": "A short name for the plan, e.g. 'Add retries to webhooks'"},
|
|
207
|
+
"plan": {
|
|
208
|
+
"type": "string",
|
|
209
|
+
"description": "Markdown: numbered steps, the files you'll change and how, the risks, and how you'll "
|
|
210
|
+
"verify the result",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
["title", "plan"],
|
|
214
|
+
)
|
|
201
215
|
TOOL_NAMES = {
|
|
202
|
-
s["function"]["name"]
|
|
216
|
+
s["function"]["name"]
|
|
217
|
+
for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA, PRESENT_PLAN_SCHEMA]
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
|
|
@@ -337,6 +352,10 @@ class Toolbox:
|
|
|
337
352
|
allowed = self.agent.allowed_tools
|
|
338
353
|
if allowed is not None and name not in allowed:
|
|
339
354
|
return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
|
|
355
|
+
if self.agent.planning() and name not in PLAN_MODE_TOOLS:
|
|
356
|
+
return f"Error: {BLOCKED}"
|
|
357
|
+
if name == "present_plan" and not self.agent.planning():
|
|
358
|
+
return "Error: plan mode is off, so there's nothing to present: carry on with the work."
|
|
340
359
|
if self.agent.mcp and self.agent.mcp.owns(name):
|
|
341
360
|
return self._mcp(name, args)
|
|
342
361
|
fn = getattr(self, f"t_{name}", None)
|
|
@@ -376,8 +395,17 @@ class Toolbox:
|
|
|
376
395
|
raise ToolError("subagents are turned off")
|
|
377
396
|
from lcode import subagents
|
|
378
397
|
|
|
398
|
+
kind = self.agent.agent_types().get(type)
|
|
399
|
+
if self.agent.planning() and kind and not kind.read_only:
|
|
400
|
+
raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
|
|
379
401
|
return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
|
|
380
402
|
|
|
403
|
+
# -- plan mode
|
|
404
|
+
def t_present_plan(self, title: str, plan: str) -> str:
|
|
405
|
+
from lcode.planning import present
|
|
406
|
+
|
|
407
|
+
return present(self.agent, title, plan)
|
|
408
|
+
|
|
381
409
|
# -- memory
|
|
382
410
|
def t_memory(
|
|
383
411
|
self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
|
|
@@ -605,6 +633,8 @@ class Toolbox:
|
|
|
605
633
|
# -- shell
|
|
606
634
|
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
607
635
|
sandbox = self.agent.sandbox
|
|
636
|
+
if self.agent.planning() and not is_read_only(command):
|
|
637
|
+
raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
|
|
608
638
|
if self.agent.read_only and not is_read_only(command):
|
|
609
639
|
raise ToolError(
|
|
610
640
|
"you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
|
|
@@ -556,3 +556,18 @@ def test_gpu_tools_run_to_completion(make_agent, tmp_path, monkeypatch):
|
|
|
556
556
|
agent.run_turn("make an image")
|
|
557
557
|
assert sent == [{"a": 1, "b": 2, "wait": True}]
|
|
558
558
|
agent.mcp.close()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def test_input_prompts_show_their_default(monkeypatch):
|
|
562
|
+
import io
|
|
563
|
+
|
|
564
|
+
from rich.console import Console
|
|
565
|
+
|
|
566
|
+
from lcode.mcp.catalog import Input
|
|
567
|
+
from lcode.mcp.commands import _ask
|
|
568
|
+
|
|
569
|
+
console = Console(file=io.StringIO(), force_terminal=False)
|
|
570
|
+
monkeypatch.delenv("AWS_REGION", raising=False)
|
|
571
|
+
monkeypatch.setattr("builtins.input", lambda *args: "")
|
|
572
|
+
assert _ask(Input("AWS_REGION", "AWS region", default="us-east-1"), console) == "us-east-1"
|
|
573
|
+
assert "AWS region [us-east-1]: " in console.file.getvalue() # not swallowed as rich markup
|
|
@@ -246,6 +246,7 @@ def test_reflection_with_memory_ask_saves_only_what_the_user_picks(make_agent, m
|
|
|
246
246
|
answers(monkeypatch, "2")
|
|
247
247
|
memory_notes.reflect(agent)
|
|
248
248
|
assert [n.name for n in agent.memory().notes()] == ["vpn"]
|
|
249
|
+
assert "1 [feedback] Use pnpm, not npm (this repository)" in output(agent)
|
|
249
250
|
|
|
250
251
|
|
|
251
252
|
def test_reflection_with_memory_ask_saves_nothing_without_approval(make_agent, monkeypatch):
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from conftest import output, reply
|
|
6
|
+
from lcode import config, planning
|
|
7
|
+
from lcode.hardware import Hardware
|
|
8
|
+
from lcode.permissions import Permissions
|
|
9
|
+
from lcode.repl import handle_command
|
|
10
|
+
|
|
11
|
+
HW = Hardware("linux", "x", 31, "GPU", 12)
|
|
12
|
+
PLAN = (
|
|
13
|
+
"1. Add `mul` to **src/pkg/math.py**\n2. Test it\n - with negative numbers\n\n"
|
|
14
|
+
"Risks: none.\nVerify: run the tests."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tool(name, **arguments):
|
|
19
|
+
return {"function": {"name": name, "arguments": arguments}}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def answers(monkeypatch, *replies):
|
|
23
|
+
queue = list(replies)
|
|
24
|
+
monkeypatch.setattr("builtins.input", lambda prompt="": queue.pop(0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def present(title="Add mul", plan=PLAN):
|
|
28
|
+
return tool("present_plan", title=title, plan=plan)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_plan_mode_blocks_every_change(make_agent, repo):
|
|
32
|
+
agent = make_agent(
|
|
33
|
+
[
|
|
34
|
+
reply(
|
|
35
|
+
tool_calls=[
|
|
36
|
+
tool("read_file", path="README.md"),
|
|
37
|
+
tool("bash", command="cd src && ls"),
|
|
38
|
+
tool("edit_file", path="README.md", old_string="Demo", new_string="X"),
|
|
39
|
+
tool("write_file", path="new.txt", content="x"),
|
|
40
|
+
tool("bash", command="touch made"),
|
|
41
|
+
tool("agent", type="worker", task="change things"),
|
|
42
|
+
]
|
|
43
|
+
),
|
|
44
|
+
reply("Here's what I found."),
|
|
45
|
+
],
|
|
46
|
+
mode="plan",
|
|
47
|
+
subagents=True,
|
|
48
|
+
)
|
|
49
|
+
agent.run_turn("add mul")
|
|
50
|
+
assert agent.messages[1]["content"].endswith(planning.NOTE)
|
|
51
|
+
assert "present_plan" in {t["function"]["name"] for t in agent.ollama.payloads[0]["tools"]}
|
|
52
|
+
results = [m["content"] for m in agent.messages if m["role"] == "tool"]
|
|
53
|
+
assert "# Demo" in results[0] and "pkg" in results[1]
|
|
54
|
+
assert all("Plan mode is on" in r for r in results[2:]), results[2:]
|
|
55
|
+
assert (repo / "README.md").read_text() == "# Demo\n"
|
|
56
|
+
assert not (repo / "new.txt").exists() and not (repo / "made").exists()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_approving_a_plan_switches_mode_and_fills_the_todo_list(make_agent, repo, monkeypatch):
|
|
60
|
+
agent = make_agent(
|
|
61
|
+
[
|
|
62
|
+
reply(tool_calls=[present()]),
|
|
63
|
+
reply(tool_calls=[tool("write_file", path="new.txt", content="x\n")]),
|
|
64
|
+
reply("Done."),
|
|
65
|
+
],
|
|
66
|
+
mode="plan",
|
|
67
|
+
)
|
|
68
|
+
answers(monkeypatch, "a")
|
|
69
|
+
agent.run_turn("add mul")
|
|
70
|
+
assert agent.perms.mode == "auto-edit"
|
|
71
|
+
assert "[y] run it, asking before changes · [a] run it, edits without asking" in output(agent)
|
|
72
|
+
assert agent.plan.startswith("# Add mul\n\n1. Add `mul`")
|
|
73
|
+
assert agent.tools.todos == [
|
|
74
|
+
{"content": "Add mul to src/pkg/math.py", "status": "pending"},
|
|
75
|
+
{"content": "Test it", "status": "pending"},
|
|
76
|
+
]
|
|
77
|
+
assert agent.messages[3]["content"].startswith("The user approved the plan, and plan mode is off (auto-edit mode")
|
|
78
|
+
assert (repo / "new.txt").read_text() == "x\n" # carried out in the same request
|
|
79
|
+
assert "present_plan" not in {t["function"]["name"] for t in agent.ollama.payloads[1]["tools"]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_y_approves_in_ask_mode(make_agent, monkeypatch):
|
|
83
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="plan")
|
|
84
|
+
answers(monkeypatch, "")
|
|
85
|
+
agent.run_turn("add mul")
|
|
86
|
+
assert agent.perms.mode == "ask"
|
|
87
|
+
assert "(ask mode: changes ask for approval as usual)" in agent.messages[3]["content"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_a_plan_sent_back_with_feedback_stays_in_plan_mode(make_agent, monkeypatch):
|
|
91
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("I'll revise it.")], mode="plan")
|
|
92
|
+
answers(monkeypatch, "add tests for overflow too") # starts with "a", but isn't an approval
|
|
93
|
+
agent.run_turn("add mul")
|
|
94
|
+
assert agent.perms.mode == "plan" and agent.plan == ""
|
|
95
|
+
assert agent.messages[3]["content"].startswith("The user wants changes to the plan: add tests for overflow too")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_edit_and_save_a_plan_before_approving(make_agent, repo, monkeypatch):
|
|
99
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="plan")
|
|
100
|
+
monkeypatch.setattr(planning, "edit_text", lambda text: text.replace("Test it", "Test it twice"))
|
|
101
|
+
answers(monkeypatch, "e", "s", "y")
|
|
102
|
+
agent.run_turn("add mul")
|
|
103
|
+
saved = repo / ".lcode" / "plans" / "add-mul.md"
|
|
104
|
+
assert "Test it twice" in saved.read_text()
|
|
105
|
+
result = agent.messages[3]["content"]
|
|
106
|
+
assert "The user edited the plan first" in result and "2. Test it twice" in result
|
|
107
|
+
assert agent.tools.todos[1]["content"] == "Test it twice"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_without_a_terminal_the_plan_waits_for_review(make_agent):
|
|
111
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("Waiting.")], mode="plan")
|
|
112
|
+
agent.interactive = False
|
|
113
|
+
agent.run_turn("add mul")
|
|
114
|
+
assert agent.perms.mode == "plan"
|
|
115
|
+
assert "review it later" in agent.messages[3]["content"]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_present_plan_outside_plan_mode_is_refused(make_agent):
|
|
119
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("ok")], mode="ask")
|
|
120
|
+
agent.run_turn("go")
|
|
121
|
+
assert agent.messages[3]["content"].startswith("Error: plan mode is off")
|
|
122
|
+
assert "present_plan" not in {t["function"]["name"] for t in agent.ollama.payloads[0]["tools"]}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_the_approved_plan_survives_compaction(make_agent):
|
|
126
|
+
agent = make_agent([reply("ok"), reply("Summary.")])
|
|
127
|
+
agent.run_turn("hi")
|
|
128
|
+
agent.plan = "# Add mul\n\n1. Add it"
|
|
129
|
+
agent.compact()
|
|
130
|
+
assert agent.messages[1]["content"].endswith(
|
|
131
|
+
"[The plan the user approved; keep following it]\n\n# Add mul\n\n1. Add it"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_plan_command(make_agent, monkeypatch):
|
|
136
|
+
agent = make_agent([reply(tool_calls=[present()]), reply("Starting.")], mode="ask")
|
|
137
|
+
handle_command(agent, "/plan", HW)
|
|
138
|
+
assert "No approved plan" in output(agent)
|
|
139
|
+
answers(monkeypatch, "y")
|
|
140
|
+
handle_command(agent, "/plan add mul", HW)
|
|
141
|
+
assert agent.messages[1]["content"].startswith("add mul") and planning.NOTE in agent.messages[1]["content"]
|
|
142
|
+
assert agent.perms.mode == "ask"
|
|
143
|
+
handle_command(agent, "/plan", HW)
|
|
144
|
+
assert "Approved plan" in output(agent)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_shift_tab_reaches_plan_mode_first():
|
|
148
|
+
perms = Permissions(Console(file=io.StringIO()), "ask")
|
|
149
|
+
seen = []
|
|
150
|
+
for _ in range(4):
|
|
151
|
+
perms.cycle()
|
|
152
|
+
seen.append(perms.mode)
|
|
153
|
+
assert seen == ["plan", "auto-edit", "yolo", "ask"]
|
|
154
|
+
assert config.coerce("permission_mode", "plan") == "plan"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_steps_come_from_the_numbered_lines():
|
|
158
|
+
assert planning.steps(PLAN) == ["Add mul to src/pkg/math.py", "Test it"]
|
|
159
|
+
real = "1. **`calc.py`** — add a `div(a, b)` function after `mul`:\n2. **`test_calc.py`** — add *two* tests:"
|
|
160
|
+
assert planning.steps(real) == ["calc.py — add a div(a, b) function after mul", "test_calc.py — add two tests"]
|
|
161
|
+
assert planning.steps("No numbers here") == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|