lithe-cli 0.1.0__tar.gz → 0.2.2__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.
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/PKG-INFO +75 -20
- lithe_cli-0.2.2/README.md +176 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli/__main__.py +1 -0
- lithe_cli-0.2.2/lithe_cli/agent.py +224 -0
- lithe_cli-0.2.2/lithe_cli/config.py +242 -0
- lithe_cli-0.2.2/lithe_cli/main.py +399 -0
- lithe_cli-0.2.2/lithe_cli/setup.py +257 -0
- lithe_cli-0.2.2/lithe_cli/ui.py +214 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/PKG-INFO +75 -20
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/SOURCES.txt +2 -0
- lithe_cli-0.2.2/tests/test_cli.py +511 -0
- lithe_cli-0.1.0/README.md +0 -121
- lithe_cli-0.1.0/lithe_cli/agent.py +0 -123
- lithe_cli-0.1.0/lithe_cli/config.py +0 -91
- lithe_cli-0.1.0/lithe_cli/main.py +0 -201
- lithe_cli-0.1.0/tests/test_cli.py +0 -145
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/LICENSE +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/pyproject.toml +0 -0
- {lithe_cli-0.1.0 → lithe_cli-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -49,13 +49,14 @@ Dynamic: license-file
|
|
|
49
49
|
|
|
50
50
|
[](https://pypi.org/project/lithe-cli/)
|
|
51
51
|
[](https://pypi.org/project/lithe-cli/)
|
|
52
|
-
[](https://pypi.org/project/lithe-cli/)
|
|
53
53
|
|
|
54
54
|
**lithe-cli** is the command-line interface for
|
|
55
55
|
[lithe](https://pypi.org/project/lithe/) — the storage-free ReAct agent
|
|
56
56
|
kernel. Installing it pulls in `lithe` automatically and gives you a `lithe`
|
|
57
57
|
command: chat with an agent that reads and writes files in your workspace,
|
|
58
|
-
|
|
58
|
+
runs code, searches the web through MCP tools, inspects stored runs, and
|
|
59
|
+
undoes a run's mutations.
|
|
59
60
|
|
|
60
61
|
```bash
|
|
61
62
|
pip install lithe-cli
|
|
@@ -64,7 +65,14 @@ pip install lithe-cli
|
|
|
64
65
|
## Configure
|
|
65
66
|
|
|
66
67
|
There is no default endpoint — the CLI refuses to run rather than silently
|
|
67
|
-
hitting some third-party URL.
|
|
68
|
+
hitting some third-party URL. The easiest way in is the wizard: on the
|
|
69
|
+
first `lithe chat` / `lithe run` (or any time, via `lithe config`) an
|
|
70
|
+
interactive terminal prompts for the three essentials and saves them to
|
|
71
|
+
`$LITHE_HOME/config.json` (mode 0600 — it holds the key). An optional
|
|
72
|
+
connectivity probe catches typos before your first turn.
|
|
73
|
+
|
|
74
|
+
To configure by hand instead, point the CLI at any OpenAI-compatible
|
|
75
|
+
endpoint:
|
|
68
76
|
|
|
69
77
|
```bash
|
|
70
78
|
export LITHE_API_KEY=sk-...
|
|
@@ -72,10 +80,19 @@ export LITHE_BASE_URL=https://your-endpoint/api/v1
|
|
|
72
80
|
export LITHE_MODEL=your-model
|
|
73
81
|
```
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
Resolution order is flag (`--api-key`, `--base-url`, `--model`) >
|
|
84
|
+
environment > saved config file, per key. The wizard never runs without
|
|
85
|
+
a TTY on both ends, so pipes and CI keep the hard refusal; `--no-setup`
|
|
86
|
+
restores that fail-fast behavior on terminals too. `lithe config --show`
|
|
87
|
+
peeks at the saved values with a masked key.
|
|
88
|
+
|
|
76
89
|
`LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
|
|
77
90
|
workspace defaults to the current directory (`--workspace` to change).
|
|
78
91
|
|
|
92
|
+
`lithe doctor` prints the effective configuration — endpoint (with a
|
|
93
|
+
masked key), config file, store, workspace, sandbox backend, skills, MCP
|
|
94
|
+
servers — so you can see what a run would use before starting one.
|
|
95
|
+
|
|
79
96
|
## Use
|
|
80
97
|
|
|
81
98
|
### One-shot task
|
|
@@ -84,10 +101,11 @@ workspace defaults to the current directory (`--workspace` to change).
|
|
|
84
101
|
$ cd my-project
|
|
85
102
|
$ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
86
103
|
⚒ read_file {"path": "README.md"}
|
|
87
|
-
✓ 已读取 README.md(120 行)
|
|
104
|
+
✓ 已读取 README.md(120 行) 0.1s
|
|
88
105
|
⚒ write_file {"path": "SUMMARY.md", "content": "..."}
|
|
89
|
-
✓ 写入 SUMMARY.md
|
|
106
|
+
✓ 写入 SUMMARY.md 0.0s
|
|
90
107
|
已把要点写入 SUMMARY.md。
|
|
108
|
+
── done · steps 3 · tokens 2100 · cost 0.0042
|
|
91
109
|
```
|
|
92
110
|
|
|
93
111
|
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
@@ -98,16 +116,40 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
98
116
|
|
|
99
117
|
```bash
|
|
100
118
|
$ lithe chat
|
|
101
|
-
|
|
102
|
-
lithe
|
|
119
|
+
╭──────────────────────────────────────╮
|
|
120
|
+
│ lithe 0.2.0 │
|
|
121
|
+
│ model your-model │
|
|
122
|
+
│ workdir /home/me/my-project │
|
|
123
|
+
│ /help 查看会话命令,/exit 退出 │
|
|
124
|
+
╰──────────────────────────────────────╯
|
|
125
|
+
lithe ❯ 把 a.txt 改成三行待办清单
|
|
103
126
|
⚒ write_file {"path": "a.txt", ...}
|
|
104
|
-
✓ 写入 a.txt
|
|
127
|
+
✓ 写入 a.txt 0.0s
|
|
105
128
|
已完成。
|
|
106
|
-
|
|
129
|
+
── done · steps 2 · tokens 180
|
|
130
|
+
lithe ❯ /exit
|
|
107
131
|
```
|
|
108
132
|
|
|
109
133
|
History carries across turns within a session (each turn is its own run in
|
|
110
|
-
the store, replayed as context for the next).
|
|
134
|
+
the store, replayed as context for the next). `/new` clears the session,
|
|
135
|
+
`/tools` lists the registered tools, `/help` lists the commands.
|
|
136
|
+
|
|
137
|
+
### Extra capabilities
|
|
138
|
+
|
|
139
|
+
The kernel ships these as bundles; the CLI grants them per flag:
|
|
140
|
+
|
|
141
|
+
| Flag | Tools granted | Notes |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
144
|
+
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
145
|
+
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
146
|
+
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
147
|
+
| `--mcp SPEC` | whatever the servers expose | JSON array/object or `@file` (env `LITHE_MCP`); stdio and streamable-http; failed servers degrade gracefully |
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
151
|
+
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
152
|
+
```
|
|
111
153
|
|
|
112
154
|
### Undo a run
|
|
113
155
|
|
|
@@ -116,35 +158,47 @@ configuration:
|
|
|
116
158
|
|
|
117
159
|
```bash
|
|
118
160
|
$ lithe runs # find the run
|
|
119
|
-
|
|
161
|
+
run status steps cost task
|
|
162
|
+
──────────── ────── ───── ──── ────────────────
|
|
163
|
+
abc123def456 done 3 0.01 总结 README.md …
|
|
120
164
|
$ lithe undo abc123def456
|
|
121
|
-
已撤销 2 个操作(run abc123def456)
|
|
165
|
+
✓ 已撤销 2 个操作(run abc123def456)
|
|
122
166
|
```
|
|
123
167
|
|
|
124
168
|
A file the run created is deleted; a file it overwrote is restored.
|
|
169
|
+
(`run_code` side effects are not revertible — its tool description warns
|
|
170
|
+
the model.)
|
|
125
171
|
|
|
126
172
|
### Inspect
|
|
127
173
|
|
|
128
174
|
```bash
|
|
129
175
|
$ lithe runs # stored runs (newest last)
|
|
130
176
|
$ lithe log abc123def456 # messages + actions of one run
|
|
177
|
+
$ lithe doctor # config + capability status
|
|
131
178
|
```
|
|
132
179
|
|
|
133
180
|
### Tools
|
|
134
181
|
|
|
135
182
|
```bash
|
|
136
183
|
$ lithe tools # what the agent can do
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
184
|
+
tool category description
|
|
185
|
+
──────────── ──────── ──────────────────────────────
|
|
186
|
+
apply_patch WRITE 以行级 patch 一次修改多个文件…
|
|
187
|
+
read_file READ 读取文件内容…
|
|
140
188
|
...
|
|
141
189
|
```
|
|
142
190
|
|
|
143
191
|
The default tool set is the workspace bundle (`read_file` / `write_file` /
|
|
144
192
|
`edit_file` / `list_files` / `search_files` / `glob_files` /
|
|
145
|
-
`apply_patch`) plus todos (`update_todos` / `list_todos`)
|
|
146
|
-
|
|
147
|
-
|
|
193
|
+
`apply_patch`) plus todos (`update_todos` / `list_todos`); the flags above
|
|
194
|
+
add capabilities. (MCP tools attach at run time, so `lithe tools` lists
|
|
195
|
+
them only after a session has started the servers.)
|
|
196
|
+
|
|
197
|
+
### Colors
|
|
198
|
+
|
|
199
|
+
Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
|
|
200
|
+
/ `--no-color` force either way. Rendering has zero dependencies — plain
|
|
201
|
+
text degrades cleanly through pipes and cron.
|
|
148
202
|
|
|
149
203
|
## Where things land
|
|
150
204
|
|
|
@@ -153,6 +207,7 @@ size-capped network fetch — for runs and chats.
|
|
|
153
207
|
| current dir (or `--workspace`) | the agent's sandboxed workspace — every tool path resolves strictly inside it |
|
|
154
208
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
155
209
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
210
|
+
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
156
211
|
|
|
157
212
|
## Design notes
|
|
158
213
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# lithe-cli
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
4
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
5
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
6
|
+
|
|
7
|
+
**lithe-cli** is the command-line interface for
|
|
8
|
+
[lithe](https://pypi.org/project/lithe/) — the storage-free ReAct agent
|
|
9
|
+
kernel. Installing it pulls in `lithe` automatically and gives you a `lithe`
|
|
10
|
+
command: chat with an agent that reads and writes files in your workspace,
|
|
11
|
+
runs code, searches the web through MCP tools, inspects stored runs, and
|
|
12
|
+
undoes a run's mutations.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install lithe-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
There is no default endpoint — the CLI refuses to run rather than silently
|
|
21
|
+
hitting some third-party URL. The easiest way in is the wizard: on the
|
|
22
|
+
first `lithe chat` / `lithe run` (or any time, via `lithe config`) an
|
|
23
|
+
interactive terminal prompts for the three essentials and saves them to
|
|
24
|
+
`$LITHE_HOME/config.json` (mode 0600 — it holds the key). An optional
|
|
25
|
+
connectivity probe catches typos before your first turn.
|
|
26
|
+
|
|
27
|
+
To configure by hand instead, point the CLI at any OpenAI-compatible
|
|
28
|
+
endpoint:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export LITHE_API_KEY=sk-...
|
|
32
|
+
export LITHE_BASE_URL=https://your-endpoint/api/v1
|
|
33
|
+
export LITHE_MODEL=your-model
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Resolution order is flag (`--api-key`, `--base-url`, `--model`) >
|
|
37
|
+
environment > saved config file, per key. The wizard never runs without
|
|
38
|
+
a TTY on both ends, so pipes and CI keep the hard refusal; `--no-setup`
|
|
39
|
+
restores that fail-fast behavior on terminals too. `lithe config --show`
|
|
40
|
+
peeks at the saved values with a masked key.
|
|
41
|
+
|
|
42
|
+
`LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
|
|
43
|
+
workspace defaults to the current directory (`--workspace` to change).
|
|
44
|
+
|
|
45
|
+
`lithe doctor` prints the effective configuration — endpoint (with a
|
|
46
|
+
masked key), config file, store, workspace, sandbox backend, skills, MCP
|
|
47
|
+
servers — so you can see what a run would use before starting one.
|
|
48
|
+
|
|
49
|
+
## Use
|
|
50
|
+
|
|
51
|
+
### One-shot task
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
$ cd my-project
|
|
55
|
+
$ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
56
|
+
⚒ read_file {"path": "README.md"}
|
|
57
|
+
✓ 已读取 README.md(120 行) 0.1s
|
|
58
|
+
⚒ write_file {"path": "SUMMARY.md", "content": "..."}
|
|
59
|
+
✓ 写入 SUMMARY.md 0.0s
|
|
60
|
+
已把要点写入 SUMMARY.md。
|
|
61
|
+
── done · steps 3 · tokens 2100 · cost 0.0042
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
65
|
+
usage/context gauges; `--max-steps` caps the tool loop;
|
|
66
|
+
`--context-window` enables fullness gauges.
|
|
67
|
+
|
|
68
|
+
### Interactive chat
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
$ lithe chat
|
|
72
|
+
╭──────────────────────────────────────╮
|
|
73
|
+
│ lithe 0.2.0 │
|
|
74
|
+
│ model your-model │
|
|
75
|
+
│ workdir /home/me/my-project │
|
|
76
|
+
│ /help 查看会话命令,/exit 退出 │
|
|
77
|
+
╰──────────────────────────────────────╯
|
|
78
|
+
lithe ❯ 把 a.txt 改成三行待办清单
|
|
79
|
+
⚒ write_file {"path": "a.txt", ...}
|
|
80
|
+
✓ 写入 a.txt 0.0s
|
|
81
|
+
已完成。
|
|
82
|
+
── done · steps 2 · tokens 180
|
|
83
|
+
lithe ❯ /exit
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
History carries across turns within a session (each turn is its own run in
|
|
87
|
+
the store, replayed as context for the next). `/new` clears the session,
|
|
88
|
+
`/tools` lists the registered tools, `/help` lists the commands.
|
|
89
|
+
|
|
90
|
+
### Extra capabilities
|
|
91
|
+
|
|
92
|
+
The kernel ships these as bundles; the CLI grants them per flag:
|
|
93
|
+
|
|
94
|
+
| Flag | Tools granted | Notes |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
97
|
+
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
98
|
+
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
99
|
+
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
100
|
+
| `--mcp SPEC` | whatever the servers expose | JSON array/object or `@file` (env `LITHE_MCP`); stdio and streamable-http; failed servers degrade gracefully |
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
104
|
+
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Undo a run
|
|
108
|
+
|
|
109
|
+
The bundled file and todo tools register reverters, so undo works with zero
|
|
110
|
+
configuration:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
$ lithe runs # find the run
|
|
114
|
+
run status steps cost task
|
|
115
|
+
──────────── ────── ───── ──── ────────────────
|
|
116
|
+
abc123def456 done 3 0.01 总结 README.md …
|
|
117
|
+
$ lithe undo abc123def456
|
|
118
|
+
✓ 已撤销 2 个操作(run abc123def456)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A file the run created is deleted; a file it overwrote is restored.
|
|
122
|
+
(`run_code` side effects are not revertible — its tool description warns
|
|
123
|
+
the model.)
|
|
124
|
+
|
|
125
|
+
### Inspect
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
$ lithe runs # stored runs (newest last)
|
|
129
|
+
$ lithe log abc123def456 # messages + actions of one run
|
|
130
|
+
$ lithe doctor # config + capability status
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Tools
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
$ lithe tools # what the agent can do
|
|
137
|
+
tool category description
|
|
138
|
+
──────────── ──────── ──────────────────────────────
|
|
139
|
+
apply_patch WRITE 以行级 patch 一次修改多个文件…
|
|
140
|
+
read_file READ 读取文件内容…
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The default tool set is the workspace bundle (`read_file` / `write_file` /
|
|
145
|
+
`edit_file` / `list_files` / `search_files` / `glob_files` /
|
|
146
|
+
`apply_patch`) plus todos (`update_todos` / `list_todos`); the flags above
|
|
147
|
+
add capabilities. (MCP tools attach at run time, so `lithe tools` lists
|
|
148
|
+
them only after a session has started the servers.)
|
|
149
|
+
|
|
150
|
+
### Colors
|
|
151
|
+
|
|
152
|
+
Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
|
|
153
|
+
/ `--no-color` force either way. Rendering has zero dependencies — plain
|
|
154
|
+
text degrades cleanly through pipes and cron.
|
|
155
|
+
|
|
156
|
+
## Where things land
|
|
157
|
+
|
|
158
|
+
| Path | Contents |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| current dir (or `--workspace`) | the agent's sandboxed workspace — every tool path resolves strictly inside it |
|
|
161
|
+
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
162
|
+
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
163
|
+
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
164
|
+
|
|
165
|
+
## Design notes
|
|
166
|
+
|
|
167
|
+
- The CLI is a thin host: it supplies tools, a system prompt, and the
|
|
168
|
+
kernel's `JsonlRunStore`; everything else (ReAct loop, streaming, budgets,
|
|
169
|
+
replay, undo engine) is reused from lithe.
|
|
170
|
+
- End-to-end behavior is tested offline against a scripted transport — no
|
|
171
|
+
test spends tokens.
|
|
172
|
+
- `python -m lithe_cli` works alongside the `lithe` console script.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Host wiring: tools, store, LLM config, and the event renderer.
|
|
2
|
+
|
|
3
|
+
Everything here maps lithe's host contract onto CLI defaults:
|
|
4
|
+
|
|
5
|
+
- workspace — the sandboxed file root the agent reads/writes (``--workspace``,
|
|
6
|
+
default the current directory);
|
|
7
|
+
- store — ``JsonlRunStore`` under ``~/.lithe/runs`` (``--store``), the
|
|
8
|
+
kernel's zero-database default;
|
|
9
|
+
- tools — the workspace bundle (read/write/edit/list/search/glob +
|
|
10
|
+
apply_patch) and todos, plus opt-in capabilities: ``--code`` (sandboxed
|
|
11
|
+
Python), ``--skills`` (markdown skill library), ``--vision`` (image
|
|
12
|
+
probe/analysis), ``--download`` (SSRF-guarded network fetch) and
|
|
13
|
+
``--mcp`` (external MCP servers);
|
|
14
|
+
- undo — the bundled tools register reverters, so ``lithe undo`` works with
|
|
15
|
+
zero configuration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from lithe import AgentContext, LLMConfig, ToolRegistry
|
|
28
|
+
from lithe.bundles import (
|
|
29
|
+
AgentHost,
|
|
30
|
+
JsonlRunStore,
|
|
31
|
+
JsonTodoStore,
|
|
32
|
+
Workspace,
|
|
33
|
+
undo_run,
|
|
34
|
+
)
|
|
35
|
+
from lithe.bundles.download import register_download_tools
|
|
36
|
+
from lithe.bundles.patch import register_apply_patch_tool
|
|
37
|
+
from lithe.bundles.workspace import register_file_tools
|
|
38
|
+
from lithe.bundles.todos import register_todo_tools
|
|
39
|
+
|
|
40
|
+
from .config import Config
|
|
41
|
+
from .ui import CYAN, GREEN, YELLOW, ui
|
|
42
|
+
|
|
43
|
+
SYSTEM_PROMPT_BASE = (
|
|
44
|
+
"你是运行在命令行里的助理,工作区是用户的当前目录。"
|
|
45
|
+
"用提供的文件工具完成任务(读写前先读、谨慎修改),"
|
|
46
|
+
"需要多步任务时先用 update_todos 列出计划,最后用简洁中文汇报结果。"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sandbox_backend() -> str:
|
|
51
|
+
"""bwrap when present (full isolation), else passthrough."""
|
|
52
|
+
return "bwrap" if shutil.which("bwrap") else "passthrough"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_system_prompt(cfg: Config) -> str:
|
|
56
|
+
"""Base prompt plus one line per enabled capability."""
|
|
57
|
+
extras = []
|
|
58
|
+
if cfg.code:
|
|
59
|
+
extras.append("可以用 run_code 执行 Python 来验证想法、计算或测试。")
|
|
60
|
+
if cfg.download:
|
|
61
|
+
extras.append("可以用 download_file 下载网络文件到工作区。")
|
|
62
|
+
if cfg.skills_dir is not None:
|
|
63
|
+
extras.append("可以先 load_skill 查看可用技能并按需加载规范。")
|
|
64
|
+
if not extras:
|
|
65
|
+
return SYSTEM_PROMPT_BASE
|
|
66
|
+
return SYSTEM_PROMPT_BASE + "".join(extras)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_llm(cfg: Config) -> LLMConfig:
|
|
70
|
+
return LLMConfig(
|
|
71
|
+
model=cfg.model or "unused",
|
|
72
|
+
base_url=cfg.base_url or "unused",
|
|
73
|
+
api_key=cfg.api_key or "unused",
|
|
74
|
+
timeout=cfg.timeout,
|
|
75
|
+
attempts=cfg.attempts,
|
|
76
|
+
stream=cfg.stream,
|
|
77
|
+
context_window=cfg.context_window,
|
|
78
|
+
transport=cfg.transport,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_registry(cfg: Config) -> ToolRegistry:
|
|
83
|
+
"""Register the CLI's tool set against the configured workspace."""
|
|
84
|
+
reg = ToolRegistry()
|
|
85
|
+
|
|
86
|
+
def workspace_for(ctx) -> Workspace:
|
|
87
|
+
return Workspace(cfg.workspace_dir.resolve())
|
|
88
|
+
|
|
89
|
+
register_file_tools(reg, workspace_for)
|
|
90
|
+
register_apply_patch_tool(reg, workspace_for)
|
|
91
|
+
todo_path = cfg.store_dir / f"todos-{cfg.user_id}.json"
|
|
92
|
+
register_todo_tools(reg, lambda ctx: JsonTodoStore(todo_path))
|
|
93
|
+
if cfg.download:
|
|
94
|
+
register_download_tools(reg, workspace_for)
|
|
95
|
+
if cfg.code:
|
|
96
|
+
from lithe.bundles.sandbox import CodeRunner, register_code_tools
|
|
97
|
+
|
|
98
|
+
runner = CodeRunner(sys.executable, backend=sandbox_backend())
|
|
99
|
+
register_code_tools(reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
|
|
100
|
+
if cfg.skills_dir is not None:
|
|
101
|
+
from lithe.bundles.skills import SkillLibrary, register_skill_tool
|
|
102
|
+
|
|
103
|
+
register_skill_tool(reg, SkillLibrary(cfg.skills_dir))
|
|
104
|
+
if cfg.vision:
|
|
105
|
+
from lithe.bundles.images import register_image_tools
|
|
106
|
+
|
|
107
|
+
register_image_tools(reg, workspace_for, llm_config=build_llm(cfg))
|
|
108
|
+
return reg
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_host(cfg: Config, reg: ToolRegistry) -> AgentHost:
|
|
112
|
+
"""Build the AgentHost; the store lands under cfg.store_dir."""
|
|
113
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
114
|
+
prompt = build_system_prompt(cfg)
|
|
115
|
+
return AgentHost(
|
|
116
|
+
reg,
|
|
117
|
+
build_llm(cfg),
|
|
118
|
+
store,
|
|
119
|
+
max_steps=cfg.max_steps,
|
|
120
|
+
build_system_prompt=lambda ctx, mode, anchor: prompt,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_event(
|
|
125
|
+
ev: dict,
|
|
126
|
+
verbose: bool = False,
|
|
127
|
+
stream: bool = False,
|
|
128
|
+
pending: dict | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Render one runtime event onto the terminal.
|
|
131
|
+
|
|
132
|
+
``pending`` maps tool-call ids to their start time so results can show
|
|
133
|
+
elapsed seconds. ``stream`` suppresses the full-text echo of the final
|
|
134
|
+
answer when its tokens were already streamed.
|
|
135
|
+
"""
|
|
136
|
+
t = ev.get("type")
|
|
137
|
+
pending = pending if pending is not None else {}
|
|
138
|
+
if t == "assistant_delta":
|
|
139
|
+
ui.delta(ev.get("text", ""))
|
|
140
|
+
pending["_deltas"] = pending.get("_deltas", 0) + 1
|
|
141
|
+
elif t == "assistant":
|
|
142
|
+
text = ev.get("text") or ""
|
|
143
|
+
# Streaming already showed every token; echoing the whole answer
|
|
144
|
+
# again would duplicate it.
|
|
145
|
+
if text and not (stream and pending.get("_deltas")):
|
|
146
|
+
ui.assistant(text)
|
|
147
|
+
elif t == "tool_call":
|
|
148
|
+
args = json.dumps(ev.get("args", {}), ensure_ascii=False)
|
|
149
|
+
ui.tool_call(str(ev.get("name")), args[:120])
|
|
150
|
+
if ev.get("id") is not None:
|
|
151
|
+
pending[ev["id"]] = time.monotonic()
|
|
152
|
+
elif t == "tool_result":
|
|
153
|
+
summary = (ev.get("summary") or "").replace("\n", " ")
|
|
154
|
+
started = pending.pop(ev.get("id"), None) if ev.get("id") else None
|
|
155
|
+
elapsed = (time.monotonic() - started) if started is not None else None
|
|
156
|
+
ui.tool_result(bool(ev.get("ok")), summary[:160], elapsed)
|
|
157
|
+
elif t == "error":
|
|
158
|
+
ui.error(str(ev.get("message", "")))
|
|
159
|
+
elif t == "cancelled":
|
|
160
|
+
ui.warn("已取消")
|
|
161
|
+
elif verbose and t == "usage":
|
|
162
|
+
print(
|
|
163
|
+
ui.usage(
|
|
164
|
+
ev.get("prompt_tokens"),
|
|
165
|
+
ev.get("completion_tokens"),
|
|
166
|
+
ev.get("context_tokens"),
|
|
167
|
+
ev.get("context_percent"),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
elif verbose and t == "reasoning":
|
|
171
|
+
digest = (ev.get("summary") or "").replace("\n", " ")
|
|
172
|
+
if digest:
|
|
173
|
+
print(ui.reasoning(digest[:120]))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def execute(
|
|
177
|
+
cfg: Config,
|
|
178
|
+
task: str,
|
|
179
|
+
history: list[dict] | None = None,
|
|
180
|
+
run_id: str | None = None,
|
|
181
|
+
) -> tuple[str, dict, Any]:
|
|
182
|
+
"""Run one task end-to-end; returns (run_id, done_event, host)."""
|
|
183
|
+
reg = build_registry(cfg)
|
|
184
|
+
host = build_host(cfg, reg)
|
|
185
|
+
rid = run_id or uuid.uuid4().hex[:12]
|
|
186
|
+
ctx = AgentContext(run_id=rid, user_id=cfg.user_id)
|
|
187
|
+
manager = None
|
|
188
|
+
if cfg.mcp_servers:
|
|
189
|
+
from lithe.bundles.mcp import MCPManager
|
|
190
|
+
|
|
191
|
+
manager = MCPManager(cfg.mcp_servers)
|
|
192
|
+
try:
|
|
193
|
+
report = await manager.attach(reg)
|
|
194
|
+
except Exception as exc: # degrade gracefully, like the bundle
|
|
195
|
+
ui.error(f"MCP attach 失败:{exc}")
|
|
196
|
+
report = {}
|
|
197
|
+
for name, res in report.items():
|
|
198
|
+
if isinstance(res, list):
|
|
199
|
+
print(ui.s(f" ⚙ MCP {name}: {len(res)} 个工具", CYAN))
|
|
200
|
+
else:
|
|
201
|
+
ui.error(f"MCP {name}: {res}")
|
|
202
|
+
ev: dict = {}
|
|
203
|
+
pending: dict[str, float] = {}
|
|
204
|
+
try:
|
|
205
|
+
async for ev in host.run(ctx, task, history=history):
|
|
206
|
+
render_event(ev, cfg.verbose, cfg.stream, pending)
|
|
207
|
+
if ev.get("type") == "done":
|
|
208
|
+
if cfg.stream and pending.get("_deltas"):
|
|
209
|
+
print() # close the streaming line before the footer
|
|
210
|
+
ui.footer(ev)
|
|
211
|
+
finally:
|
|
212
|
+
if manager is not None:
|
|
213
|
+
await manager.close()
|
|
214
|
+
return rid, ev, host
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def undo(cfg: Config, run_id: str) -> int:
|
|
218
|
+
"""Revert a run's mutations with the bundled tools' reverters."""
|
|
219
|
+
reg = build_registry(cfg)
|
|
220
|
+
host = build_host(cfg, reg)
|
|
221
|
+
report = await undo_run(host, run_id, cfg.user_id)
|
|
222
|
+
mark = ui.s("✓", GREEN) if report.reverted else ui.s("·", YELLOW)
|
|
223
|
+
print(f"{mark} 已撤销 {report.reverted} 个操作(run {run_id})")
|
|
224
|
+
return 0
|