mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
528
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b03c5149 | ||
|
|
9d830fb6b6 | ||
|
|
8423cf3eeb | ||
|
|
76f3eead42 | ||
|
|
949cfad548 | ||
|
|
e3de01c9f6 | ||
|
|
462a0dfb0f | ||
|
|
7aaac37bca | ||
|
|
91514ad0b1 | ||
|
|
a6b68178aa | ||
|
|
2099cb009e | ||
|
|
39a952ecce | ||
|
|
b1232fdaf4 | ||
|
|
cea8617096 | ||
|
|
ffb7ddfa1e | ||
|
|
c2071594cf | ||
|
|
cf96c4d5e9 | ||
|
|
cf00f537bd | ||
|
|
cfa49c6e78 | ||
|
|
c062e1af14 | ||
|
|
c77379099b | ||
|
|
ca873e4d17 | ||
|
|
63895fc101 | ||
|
|
770d89b430 | ||
|
|
afed32b013 | ||
|
|
8c68c6fe1e | ||
|
|
b76d54aae1 | ||
|
|
d35f99abfc | ||
|
|
d4f5abe004 | ||
|
|
07ad0bafa8 | ||
|
|
995cc44e89 | ||
|
|
7ac9a46978 | ||
|
|
fe0e65928d | ||
|
|
85097aa143 | ||
|
|
6de5a0c5ca | ||
|
|
8a48af7c74 | ||
|
|
b4adb29c2b | ||
|
|
6519737860 | ||
|
|
d4e0294734 | ||
|
|
681edfa6f3 | ||
|
|
ba86dccc8d | ||
|
|
5ed28a6744 | ||
|
|
aa70aa48f9 | ||
|
|
0fd4d0ab29 | ||
|
|
c1fd76add3 | ||
|
|
63a6d5d07d | ||
|
|
dcb37259fa | ||
|
|
88c38e9b38 | ||
|
|
37165b0db0 | ||
|
|
2116e32013 | ||
|
|
06f47fa540 | ||
|
|
905da8e34a | ||
|
|
5365bab088 | ||
|
|
f718c69b2d | ||
|
|
07f54c25e3 | ||
|
|
1a1e666625 | ||
|
|
297a9e5939 | ||
|
|
86f6558707 | ||
|
|
4916fc07ab | ||
|
|
11eb9d8cc8 | ||
|
|
6c9e3a2cc3 | ||
|
|
b7048cf76a | ||
|
|
b2759e8a6b | ||
|
|
1643aa7ef5 | ||
|
|
9f8c2cb1bf | ||
|
|
61afbffc89 | ||
|
|
9cdf17f5d5 | ||
|
|
3b14d59dcd | ||
|
|
a335ce07db | ||
|
|
87478b6e92 | ||
|
|
67648774e2 | ||
|
|
6e462e62a6 | ||
|
|
ed47cf1562 | ||
|
|
6b5820ea89 | ||
|
|
1e7518a207 | ||
|
|
4a3818e03c | ||
|
|
276cbd947f | ||
|
|
234e895e5a | ||
|
|
8c9110fee3 | ||
|
|
e864fba522 | ||
|
|
a65a6f334c | ||
|
|
3824206ab2 | ||
|
|
2c78976728 | ||
|
|
fe0717b385 | ||
|
|
791c7fd505 | ||
|
|
6d406d93c9 | ||
|
|
053722a2a2 | ||
|
|
45d1caba1d | ||
|
|
89acea6fe1 | ||
|
|
01e4f3762a | ||
|
|
c339ce8bba | ||
|
|
12b88d3872 | ||
|
|
8ccb2a451b | ||
|
|
20748bc4e3 | ||
|
|
93de413432 | ||
|
|
d349b65713 | ||
|
|
54b250430d | ||
|
|
5794d481c3 | ||
|
|
e206ee4e70 | ||
|
|
2eb7398f34 | ||
|
|
f75d3519db | ||
|
|
7f8c3453e1 | ||
|
|
edf78e7054 | ||
|
|
bd0dd85f44 | ||
|
|
469d004773 | ||
|
|
f3d1b9ca2d | ||
|
|
c111aaa7ee | ||
|
|
9adcd3d923 | ||
|
|
0a1c98c142 | ||
|
|
7675364eae | ||
|
|
052fdc132d | ||
|
|
47d498bab0 | ||
|
|
04328f8129 | ||
|
|
137fbf875e | ||
|
|
1ee4349a08 | ||
|
|
9a1d1e64c7 | ||
|
|
a1fbfd9f7b | ||
|
|
bda0c099ab | ||
|
|
ef14d1ea92 | ||
|
|
c9e014fdea | ||
|
|
d32f8961b1 | ||
|
|
45a6466c2c | ||
|
|
99e04c2da2 | ||
|
|
87602a74ea | ||
|
|
ad55f3ce37 | ||
|
|
e284592649 | ||
|
|
2154dc51d0 | ||
|
|
fe7d94359b | ||
|
|
10930f3902 | ||
|
|
6b7470646f | ||
|
|
21f58cbabf | ||
|
|
c9d3e74342 | ||
|
|
5bd3d1e0af | ||
|
|
af85c356b8 | ||
|
|
198fd9f869 | ||
|
|
45ace23580 | ||
|
|
b4f069800e | ||
|
|
3f8170e835 | ||
|
|
cb03d2c748 | ||
|
|
bd94fefd1a | ||
|
|
42d7ad34a4 | ||
|
|
bb3b449e09 | ||
|
|
55317094b6 | ||
|
|
6313ae9f4f | ||
|
|
4137be62a1 | ||
|
|
0b9ca744c2 | ||
|
|
7685a89dcc | ||
|
|
bbd4f4054b | ||
|
|
0ae28571e7 | ||
|
|
6851a6ebd4 | ||
|
|
7768672c5b | ||
|
|
207813d3b5 | ||
|
|
e0c2d28f90 | ||
|
|
8559458258 | ||
|
|
04dbf17426 | ||
|
|
a52a88455b | ||
|
|
3ab09075c5 | ||
|
|
4ddd639e67 | ||
|
|
444f488563 | ||
|
|
88143a8bf0 | ||
|
|
7204d88a4c | ||
|
|
1a21542d11 | ||
|
|
f531f1ce38 | ||
|
|
941a2541eb | ||
|
|
be8ac1e484 | ||
|
|
f074aa7d80 | ||
|
|
ea7f4679f1 | ||
|
|
c188e96f4e | ||
|
|
815f15993c | ||
|
|
4333d6f103 | ||
|
|
65d32ffd6c | ||
|
|
379785d365 | ||
|
|
883776358e | ||
|
|
28141ce20b | ||
|
|
460c62c0b0 | ||
|
|
e86133c434 | ||
|
|
6c59332a8a | ||
|
|
a7b8a9ed46 | ||
|
|
7e135b45f3 | ||
|
|
fa73448f6f | ||
|
|
8a231b6e4d | ||
|
|
ef5318ebdc | ||
|
|
8f68040f05 | ||
|
|
0f88927364 | ||
|
|
3f33ff3143 | ||
|
|
29e99d3742 | ||
|
|
01a0f5aaf3 | ||
|
|
77a6003255 | ||
|
|
25a477050d | ||
|
|
ea0516e655 | ||
|
|
18a230de75 | ||
|
|
c5e053f83b | ||
|
|
b68ae4f9bc | ||
|
|
97e3b360c2 | ||
|
|
4353f4680b | ||
|
|
73bf299a59 | ||
|
|
d04ad1a5b4 | ||
|
|
67b56cba74 | ||
|
|
105230cc34 | ||
|
|
dd014b50a7 | ||
|
|
595d789c6f | ||
|
|
cf35238834 | ||
|
|
ef9780719d | ||
|
|
cc70a2a79f | ||
|
|
f9806cc60f | ||
|
|
76877036f7 | ||
|
|
a710a7d6f7 | ||
|
|
fff38f11a7 | ||
|
|
5e51c5014f | ||
|
|
937f04ac86 | ||
|
|
9eade9be5f | ||
|
|
400369f0b0 | ||
|
|
f0c989ba2d | ||
|
|
70505bd1fc | ||
|
|
162b6ee5bd | ||
|
|
b1d29ede7d | ||
|
|
800d51ecac | ||
|
|
3f9fb63d4c | ||
|
|
1cfa48ad4a | ||
|
|
8a42a9c73a | ||
|
|
72c8a47ac9 | ||
|
|
122cf4213b | ||
|
|
8c4a74ee3c | ||
|
|
4ec111c1a9 | ||
|
|
8222f3c85f | ||
|
|
d76493a63a | ||
|
|
220de320fb | ||
|
|
33b1c6f601 | ||
|
|
10b52cfb3a | ||
|
|
5d034dc79c | ||
|
|
b8d7708171 | ||
|
|
e725649146 | ||
|
|
6a95877196 | ||
|
|
83e4bae7db | ||
|
|
58b5bb9204 | ||
|
|
c579551bb1 | ||
|
|
8b645135bc | ||
|
|
28011413bc | ||
|
|
614ea86a81 | ||
|
|
6d28db3248 | ||
|
|
0d1221bece | ||
|
|
8b9f93d7d1 | ||
|
|
a119c35b1e | ||
|
|
067e0c4a40 | ||
|
|
5283ceae85 | ||
|
|
00cc0da530 | ||
|
|
b19a744110 | ||
|
|
c9c69e4316 | ||
|
|
8a79eb1aaa | ||
|
|
979f038ded | ||
|
|
f38fd7d5d3 | ||
|
|
5af22042ec | ||
|
|
aecb5fbc33 | ||
|
|
34535b4e7c | ||
|
|
5abe06f808 | ||
|
|
64c7ff5fdc | ||
|
|
54bcdb5a62 | ||
|
|
ffdf05a603 | ||
|
|
fd9e57703c | ||
|
|
661ab00656 | ||
|
|
b941233138 | ||
|
|
acb0e853ff | ||
|
|
afef27dd6c | ||
|
|
f32007c83f | ||
|
|
09bde468eb | ||
|
|
2ebf5c4972 | ||
|
|
1ed2c9a213 | ||
|
|
55b550ee01 | ||
|
|
b690a48336 | ||
|
|
7178ea3f13 | ||
|
|
2a0cd19a74 | ||
|
|
c78421cf16 | ||
|
|
03be51ade5 | ||
|
|
c757c5466c | ||
|
|
5f4cfbcb16 | ||
|
|
f6d1dba32a | ||
|
|
2ec4044217 | ||
|
|
a6d5e4f3b5 | ||
|
|
ed48325346 | ||
|
|
56443ac6e2 | ||
|
|
21aa900d64 | ||
|
|
b0258e8b20 | ||
|
|
8493560976 | ||
|
|
8d2c31eb6a | ||
|
|
a6a489e0fa | ||
|
|
840ba5af33 | ||
|
|
3403b87641 | ||
|
|
bfbae5a7b3 | ||
|
|
58cce14a07 | ||
|
|
f9b02496c8 | ||
|
|
bfc2a74e4f | ||
|
|
780093d037 | ||
|
|
1873e948c3 | ||
|
|
735a243849 | ||
|
|
edada598c8 | ||
|
|
2527ce5de9 | ||
|
|
44a5ed1bc0 | ||
|
|
4c0e9b9f46 | ||
|
|
839d1ecfb1 | ||
|
|
593b328dbb | ||
|
|
4beca25ceb | ||
|
|
82ffce1474 | ||
|
|
4726ca0478 | ||
|
|
d979597361 | ||
|
|
070aed8ade | ||
|
|
8df100203c | ||
|
|
8fa9eed6a8 | ||
|
|
5692f7a68a | ||
|
|
57f0c859fc | ||
|
|
40282e3b74 | ||
|
|
dacc699293 | ||
|
|
c8638dee46 | ||
|
|
7dc45ff94e | ||
|
|
8248d075db | ||
|
|
d7152cdbdd | ||
|
|
89dc34df88 | ||
|
|
67ce6822ca | ||
|
|
194e9d5f5f | ||
|
|
5005bca353 | ||
|
|
e5dbb15c34 | ||
|
|
c90e433057 | ||
|
|
3ce77633c0 | ||
|
|
00a907c493 | ||
|
|
cf2f589615 | ||
|
|
463f536750 | ||
|
|
00a7de0171 | ||
|
|
efb792ff24 | ||
|
|
d8601478db | ||
|
|
66fc54421c | ||
|
|
6a27c26257 | ||
|
|
3ca82ea880 | ||
|
|
47dcc61e9b | ||
|
|
2bf111f456 | ||
|
|
aa6c1bf300 | ||
|
|
5281e67222 | ||
|
|
dbb53109f4 | ||
|
|
b015515f30 | ||
|
|
be88e14424 | ||
|
|
9e490ef473 | ||
|
|
bfb4246659 | ||
|
|
fbf96a3502 | ||
|
|
2a9e288dfe | ||
|
|
3460ca3cb9 | ||
|
|
9b45fc1172 | ||
|
|
8656549129 | ||
|
|
4c1f127549 | ||
|
|
13c951aa41 | ||
|
|
cd1fb61eb5 | ||
|
|
e79cb816e3 | ||
|
|
64901be67f | ||
|
|
9ce9d2235a | ||
|
|
06d5495b60 | ||
|
|
851a0ff50c | ||
|
|
3596ccf828 | ||
|
|
d1ae73a8a8 | ||
|
|
c661012754 | ||
|
|
0e19ea3062 | ||
|
|
ceae6d7b61 | ||
|
|
34f776b48b | ||
|
|
f7b027a295 | ||
|
|
6c880a6691 | ||
|
|
4636c78100 | ||
|
|
28c8c89a42 | ||
|
|
7899857201 | ||
|
|
9354b80a6e | ||
|
|
638af123ba | ||
|
|
42aa37cfc0 | ||
|
|
03302c751f | ||
|
|
246ea8ef61 | ||
|
|
f60b3c7920 | ||
|
|
e92899607a | ||
|
|
c930aa3713 | ||
|
|
4378944459 | ||
|
|
123384975e | ||
|
|
943191f0c0 | ||
|
|
c915e98c15 | ||
|
|
de4009efbd | ||
|
|
9c6eaf0bed | ||
|
|
c66a0217d2 | ||
|
|
319791cd10 | ||
|
|
a584ffe92d | ||
|
|
1e22932313 | ||
|
|
98dd883ce8 | ||
|
|
35bd1be109 | ||
|
|
596bf5398c | ||
|
|
523bb928bf | ||
|
|
f9afc9389b | ||
|
|
9d6c606cc9 | ||
|
|
44817b75c6 | ||
|
|
c55f7ec5bb | ||
|
|
d7f868b832 | ||
|
|
d481d5fb1a | ||
|
|
bc1df49201 | ||
|
|
7826f8f89c | ||
|
|
b14f82f408 | ||
|
|
6d989de336 | ||
|
|
ddad6c5a7c | ||
|
|
4b1decdb95 | ||
|
|
23dc253f89 | ||
|
|
0e9861558a | ||
|
|
853aecdb97 | ||
|
|
6b8e832ba5 | ||
|
|
6689e2d377 | ||
|
|
160cec2396 | ||
|
|
d2da6df14e | ||
|
|
701ae5563d | ||
|
|
e2e75c913f | ||
|
|
87f3e08ff7 | ||
|
|
951fd73c8e | ||
|
|
90703002c9 | ||
|
|
e624943bac | ||
|
|
f80a78d5a8 | ||
|
|
747104c9cc | ||
|
|
a9d1fdcee8 | ||
|
|
83c29292d3 | ||
|
|
7170761e47 | ||
|
|
fbaa85117b | ||
|
|
6efef2700a | ||
|
|
0db9fbe250 | ||
|
|
991422a328 | ||
|
|
a67285e6a2 | ||
|
|
dd2cb4ca91 | ||
|
|
2216405821 | ||
|
|
1cd5a0e029 | ||
|
|
b8abe5542c | ||
|
|
7b153c5aa9 | ||
|
|
e3c9aff41e | ||
|
|
351aabd512 | ||
|
|
9b52202f4a | ||
|
|
9713fe2a3b | ||
|
|
fca1f2ad02 | ||
|
|
e5294002ed | ||
|
|
eb14720381 | ||
|
|
d14b692368 | ||
|
|
8e9f09f829 | ||
|
|
da225fc24e | ||
|
|
71631f9ab0 | ||
|
|
2319b660e6 | ||
|
|
ed3a8f64d8 | ||
|
|
0e9b136315 | ||
|
|
fa5f7f5b88 | ||
|
|
1dbec3da50 | ||
|
|
84143d31b2 | ||
|
|
3d773d9054 | ||
|
|
9c7d1c9507 | ||
|
|
db68fc1c09 | ||
|
|
f9f5a19910 | ||
|
|
0097488eed | ||
|
|
fc7971b3b6 | ||
|
|
9db3dc5e32 | ||
|
|
dbf3c4b245 | ||
|
|
f4cc001410 | ||
|
|
b6a9a9728a | ||
|
|
a19725bc57 | ||
|
|
345ef80571 | ||
|
|
0bb1b0b3c2 | ||
|
|
fede2ecfc2 | ||
|
|
d30f3d466d | ||
|
|
5feb6f485f | ||
|
|
74daa81a1b | ||
|
|
a5768a4ebe | ||
|
|
85036bacb6 | ||
|
|
a8c65b50ca | ||
|
|
e9494c1dce | ||
|
|
98916a31e3 | ||
|
|
7275a81ec7 | ||
|
|
a81c5e704a | ||
|
|
be058c0922 | ||
|
|
99e158c062 | ||
|
|
e81968a32b | ||
|
|
0eae7ceb1a | ||
|
|
bc8f42c639 | ||
|
|
d7abf39169 | ||
|
|
33638417be | ||
|
|
6da56f3574 | ||
|
|
44f7bbae50 | ||
|
|
adb737b614 | ||
|
|
dd4d410ce2 | ||
|
|
215379ac63 | ||
|
|
bc59fe8719 | ||
|
|
58a14d18dd | ||
|
|
de5e216583 | ||
|
|
7917466f4e | ||
|
|
9ed3905a23 | ||
|
|
bbd7bbd7f5 | ||
|
|
f9511049c4 | ||
|
|
c2c47f7a03 | ||
|
|
d36117de7a | ||
|
|
2d86094fc7 | ||
|
|
8ca3f42a3b | ||
|
|
71d0593b06 | ||
|
|
04777bf6f1 | ||
|
|
08a5f4cbbb | ||
|
|
5dfdd4f892 | ||
|
|
a56cd9710b | ||
|
|
99846da6f3 | ||
|
|
cd51654bf1 | ||
|
|
3d386f7b7e | ||
|
|
593ab4a788 | ||
|
|
a1a627839c | ||
|
|
d7280da17c | ||
|
|
092b07c7aa | ||
|
|
83cb296cdc | ||
|
|
fa3e902ee8 | ||
|
|
4f6e5e9cb8 | ||
|
|
6fcf65a8e3 | ||
|
|
a630a78941 | ||
|
|
d5f5eb43e5 | ||
|
|
51bd3337ef | ||
|
|
3f41605ddc | ||
|
|
515e418eb4 | ||
|
|
42ce294665 | ||
|
|
51f2dae855 | ||
|
|
15f218e918 | ||
|
|
732992df4f | ||
|
|
fc635377bc | ||
|
|
09962895fb | ||
|
|
472c67722e | ||
|
|
b46aac0554 | ||
|
|
ef6da0a94e | ||
|
|
bdf21c932b | ||
|
|
4d7c2074e6 | ||
|
|
0023f6d998 | ||
|
|
72b8fc806f | ||
|
|
bfe5b64022 | ||
|
|
4219911423 | ||
|
|
8ecc2d69c4 | ||
|
|
0d4af68e63 |
+1
-1
@@ -16,7 +16,7 @@ Example valid usage:
|
||||
## Windows Compatibility
|
||||
|
||||
nanobot explicitly supports Windows. Key differences to keep in mind:
|
||||
- `ExecTool` uses `cmd /c` on Windows instead of `sh -c` (`shell.py`).
|
||||
- `ExecTool` defaults to PowerShell on Windows (`pwsh` when available, otherwise Windows PowerShell); pass `shell="cmd"` for cmd.exe syntax or cmd built-ins (`shell.py`).
|
||||
- `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input.
|
||||
- MCP stdio server commands are normalized for Windows path separators (`mcp.py`).
|
||||
- Always use `pathlib.Path` for path manipulation; do not assume `/` separators.
|
||||
|
||||
+6
-4
@@ -4,11 +4,13 @@ The agent operates with significant power (file system, shell, web). The followi
|
||||
|
||||
## Workspace Restriction
|
||||
|
||||
Filesystem tools (`read_file`, `write_file`, `edit_file`, `list_dir`) resolve paths through `_resolve_path` (`agent/tools/filesystem.py`), which enforces that the resolved path must lie under `allowed_dir` (typically the configured workspace), plus the media upload directory (`get_media_dir()`) and any `extra_allowed_dirs`.
|
||||
Filesystem tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `apply_patch`) resolve paths through the workspace path resolver (`agent/tools/filesystem.py` / `agent/tools/path_utils.py`), which enforces that the resolved path must lie under the active workspace when workspace restriction is enabled. The media upload directory is always an internal extra read root while restricted.
|
||||
|
||||
Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_workspace`: if enabled and `working_dir` is outside the workspace, the command is rejected before execution.
|
||||
Additional filesystem roots must be capability-specific. `extra_allowed_dirs` is a legacy read-only alias. Use `extra_read_allowed_dirs` for read-only roots, `extra_write_allowed_dirs` only when a write-capable tool is intentionally allowed to modify an extra directory, and exact file allowlists when a tool may modify only specific files.
|
||||
|
||||
**Rule**: Any new path-handling logic must go through `_resolve_path` or perform an equivalent `allowed_dir` check.
|
||||
Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_workspace` as an application-level guard: if enabled and `working_dir` is outside the workspace, the command is rejected before execution, and command text is checked for obvious workspace escapes. This is not process-level isolation; use an exec sandbox backend for that.
|
||||
|
||||
**Rule**: Any new path-handling logic must go through the workspace path resolver or perform an equivalent containment check with explicit read/write capability semantics.
|
||||
|
||||
## SSRF Protection
|
||||
|
||||
@@ -22,6 +24,6 @@ HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs
|
||||
|
||||
## Shell Sandbox
|
||||
|
||||
`tools/sandbox.py` provides optional command wrapping. The only backend currently shipped is `bwrap` (bubblewrap), intended for containerized deployments. On Windows and bare-metal Linux without `bwrap`, commands run in the native shell with workspace restriction as the only guard.
|
||||
`tools/sandbox.py` provides optional command wrapping. The only backend currently shipped is `bwrap` (bubblewrap), intended for containerized deployments. On Windows and bare-metal Linux without `bwrap`, commands run in the native shell with workspace restriction as an application-level guard only.
|
||||
|
||||
**Rule**: If adding a new sandbox backend, implement `_wrap_<name>(command, workspace, cwd) -> str` and register it in `_BACKENDS`.
|
||||
|
||||
@@ -3,8 +3,12 @@ name: Test Suite
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- docs/**
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- docs/**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -15,14 +19,25 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Python (${{ matrix.name }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
||||
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||
include:
|
||||
- name: minimum, 3.11
|
||||
os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
coverage: false
|
||||
- name: latest, 3.14 + coverage
|
||||
os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
coverage: true
|
||||
- name: Windows, 3.14
|
||||
os: windows-latest
|
||||
python-version: "3.14"
|
||||
coverage: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -40,10 +55,77 @@ jobs:
|
||||
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Install channel dependencies
|
||||
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
||||
|
||||
# Channel requirements live in manifests rather than uv.lock. Avoid a
|
||||
# later uv run sync pruning the packages installed by the previous step.
|
||||
- name: Lint with ruff
|
||||
run: uv run ruff check nanobot --select F
|
||||
if: matrix.coverage
|
||||
run: uv run --no-sync ruff check nanobot tests conftest.py
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest tests/
|
||||
- name: Run tests with coverage
|
||||
if: matrix.coverage
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
--cov=nanobot --cov-report=term-missing:skip-covered
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
- name: Run compatibility tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: >-
|
||||
uv run --no-sync python -m pytest
|
||||
--durations=25 --durations-min=1.0
|
||||
|
||||
webui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Verify npm lockfile
|
||||
working-directory: webui
|
||||
run: npm ci --ignore-scripts --dry-run
|
||||
|
||||
- name: Install WebUI dependencies
|
||||
working-directory: webui
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Lint WebUI
|
||||
working-directory: webui
|
||||
run: bun run lint
|
||||
|
||||
- name: Test WebUI
|
||||
working-directory: webui
|
||||
run: bun run test
|
||||
|
||||
- name: Build WebUI
|
||||
working-directory: webui
|
||||
run: bun run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build image with default channel dependencies
|
||||
run: docker build -t nanobot:test .
|
||||
|
||||
- name: Verify default WhatsApp dependencies
|
||||
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
|
||||
|
||||
- name: Verify runtime dependency permissions
|
||||
run: >-
|
||||
docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
|
||||
'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
|
||||
python -m scripts.install_channel_dependencies discord && python -c "import discord"'
|
||||
|
||||
@@ -36,18 +36,17 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
||||
|
||||
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
||||
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are self-contained packages auto-discovered via `pkgutil` scanning.
|
||||
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
|
||||
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
|
||||
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
|
||||
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
|
||||
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
|
||||
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
|
||||
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
|
||||
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
|
||||
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
|
||||
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context.
|
||||
- **Skills** (`nanobot/skills/`): Built-in skill definitions (cron, github, image-generation, etc.) loaded into agent context.
|
||||
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
|
||||
|
||||
### Entry Points
|
||||
|
||||
+52
-24
@@ -1,49 +1,77 @@
|
||||
FROM node:24-bookworm-slim AS webui-builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY webui/package.json webui/package-lock.json ./webui/
|
||||
WORKDIR /app/webui
|
||||
RUN npm ci
|
||||
COPY webui/ ./
|
||||
RUN mkdir -p /app/nanobot/web && npm run build
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
|
||||
# Install Node.js 20 for the WhatsApp bridge
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
apt-get purge -y gnupg && \
|
||||
apt-get autoremove -y && \
|
||||
apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client libmagic1 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Keep the runtime environment writable by the non-root nanobot user. Enabled
|
||||
# channels may install their manifest-declared dependencies at startup.
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN uv venv --seed "$VIRTUAL_ENV"
|
||||
|
||||
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||
# hook from hatch_build.py even for this metadata-only install.
|
||||
ARG NANOBOT_EXTRAS=
|
||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
|
||||
uv pip install --system --no-cache . && \
|
||||
rm -rf nanobot bridge
|
||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||
if [ -n "$NANOBOT_EXTRAS" ]; then \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
||||
--python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \
|
||||
else \
|
||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
||||
--python "$VIRTUAL_ENV/bin/python" --no-cache .; \
|
||||
fi && \
|
||||
rm -rf nanobot
|
||||
|
||||
# Copy the full source and install
|
||||
COPY nanobot/ nanobot/
|
||||
COPY bridge/ bridge/
|
||||
COPY webui/ webui/
|
||||
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache .
|
||||
COPY scripts/install_channel_dependencies.py scripts/
|
||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache .
|
||||
|
||||
# Build the WhatsApp bridge
|
||||
WORKDIR /app/bridge
|
||||
RUN git config --global --add url."https://github.com/".insteadOf ssh://git@github.com/ && \
|
||||
git config --global --add url."https://github.com/".insteadOf git@github.com: && \
|
||||
npm install && npm run build
|
||||
WORKDIR /app
|
||||
# Preinstall selected channel dependencies from their manifests. A comma-separated
|
||||
# list keeps the image configurable while preserving WhatsApp in the default image.
|
||||
ARG NANOBOT_CHANNELS=whatsapp
|
||||
RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
|
||||
python -m scripts.install_channel_dependencies "$channel"; \
|
||||
done
|
||||
|
||||
# Create non-root user and config directory
|
||||
# Render deploy template (see render.yaml): committed gateway config that wires
|
||||
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
|
||||
# at startup). Lives in the code dir (/app), not the data dir, so a mounted disk
|
||||
# won't shadow it. Only used when RENDER=true; ignored by local runs.
|
||||
COPY render-config.json ./
|
||||
|
||||
# Create the non-root user and hand ownership of the writable virtualenv to it.
|
||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||
mkdir -p /home/nanobot/.nanobot && \
|
||||
chown -R nanobot:nanobot /home/nanobot /app
|
||||
chown -R nanobot:nanobot /home/nanobot /app/.venv
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER nanobot
|
||||
# Start as root so the entrypoint can chown the data dir (on Render, the
|
||||
# freshly-mounted root-owned persistent disk) before dropping to the non-root
|
||||
# nanobot user via setpriv. The entrypoint drops privileges on every root start
|
||||
# and fails closed if it cannot, so the agent never runs as root (see
|
||||
# entrypoint.sh).
|
||||
USER root
|
||||
ENV HOME=/home/nanobot
|
||||
# Ensure crash output reaches Render logs (app output is otherwise swallowed on
|
||||
# non-graceful exit).
|
||||
ENV PYTHONUNBUFFERED=1 PYTHONFAULTHANDLER=1
|
||||
|
||||
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
||||
EXPOSE 18790 8765
|
||||
|
||||
@@ -42,10 +42,45 @@
|
||||
|---|---|
|
||||
| Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) |
|
||||
| Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) |
|
||||
| Open the bundled browser UI after the CLI works | [WebUI](#-webui) |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||
| Open the bundled browser UI | [WebUI](#-webui) |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||
| Deploy to the cloud in one click | [Deploy to Render](#deploy-to-render) |
|
||||
|
||||
## Deploy to Render
|
||||
|
||||
Deploy nanobot's gateway and bundled WebUI as a single web service with persistent memory. Render reads [`render.yaml`](./render.yaml) and prompts for two secrets on deploy: `ANTHROPIC_API_KEY` and `NANOBOT_WEB_TOKEN` (the password that gates the public WebUI — generate a strong random value, e.g. `openssl rand -hex 32`).
|
||||
|
||||
> **Note:** The blueprint attaches a persistent disk so sessions, memory, and WebUI history survive restarts. Persistent disks require a paid service (they are not available on Render's free tier).
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
|
||||
|
||||
## What can nanobot do?
|
||||
|
||||
nanobot is a self-hosted personal AI agent runtime. It can:
|
||||
|
||||
- run in a browser WebUI or terminal
|
||||
- connect to Telegram, Discord, Slack, WeChat, Email, Mattermost, and other chat apps
|
||||
- use tools such as files, shell, web search, web fetch, MCP, cron, image generation, and subagents
|
||||
- keep session history and long-term memory through Dream
|
||||
- run long-horizon goals and scheduled automations
|
||||
- expose a Python SDK and OpenAI-compatible API for integrations
|
||||
- deploy as a long-running local or server-side agent gateway
|
||||
|
||||
## Latest Release
|
||||
|
||||
**v0.2.2 - Durability Release**
|
||||
|
||||
Highlights:
|
||||
|
||||
- Segmented WebUI transcripts
|
||||
- Python SDK runtime controls
|
||||
- Automation management
|
||||
- Search/STT provider improvements
|
||||
- Gateway/session/provider reliability
|
||||
|
||||
[See full changelog](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2)
|
||||
|
||||
## Open Source Partners
|
||||
|
||||
@@ -54,138 +89,20 @@
|
||||
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
|
||||
</p>
|
||||
|
||||
## 📢 News
|
||||
## Recent Updates
|
||||
|
||||
- **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
|
||||
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
|
||||
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
|
||||
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
|
||||
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
|
||||
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
|
||||
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
|
||||
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
|
||||
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
|
||||
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
|
||||
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
|
||||
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
|
||||
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
|
||||
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
|
||||
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
|
||||
- **2026-05-15** 🚀 Released **v0.2.0** — **`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
|
||||
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
|
||||
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
|
||||
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
|
||||
- **2026-05-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
|
||||
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
|
||||
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
|
||||
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
|
||||
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
|
||||
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
||||
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
|
||||
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
|
||||
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
||||
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
|
||||
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
|
||||
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
||||
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
||||
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
|
||||
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
||||
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
||||
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
|
||||
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
||||
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
||||
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
||||
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
|
||||
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
|
||||
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
|
||||
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
|
||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
||||
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
||||
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
|
||||
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
|
||||
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
|
||||
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
|
||||
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
|
||||
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
|
||||
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
|
||||
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
|
||||
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
|
||||
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
|
||||
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
|
||||
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
|
||||
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
|
||||
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
|
||||
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
|
||||
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
|
||||
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
|
||||
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
|
||||
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
|
||||
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
|
||||
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
|
||||
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
|
||||
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
|
||||
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
|
||||
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
|
||||
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
|
||||
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
|
||||
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
|
||||
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
|
||||
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
||||
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
||||
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
||||
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
||||
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
||||
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
||||
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
|
||||
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
|
||||
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
||||
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
||||
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
||||
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
||||
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
||||
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
||||
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
|
||||
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
|
||||
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
|
||||
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
|
||||
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
|
||||
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
|
||||
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
|
||||
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
|
||||
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./docs/configuration.md#mcp-model-context-protocol) for details.
|
||||
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
|
||||
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./docs/configuration.md#providers).
|
||||
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
||||
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
||||
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
||||
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
||||
|
||||
</details>
|
||||
- **2026-07-12** Explicit `/goal` activation, safer runtime and workspace access.
|
||||
- **2026-07-11** Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||
- **2026-07-10** Stable model routing, multiline CLI input, new automation guide.
|
||||
- **2026-07-09** Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
||||
- **2026-07-08** Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
||||
|
||||
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
|
||||
|
||||
## 💡 Why nanobot
|
||||
|
||||
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
|
||||
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email.
|
||||
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, email, and Mattermost.
|
||||
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
|
||||
- **Small core**: readable internals with MCP, memory, deployment, and automation built in.
|
||||
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
|
||||
@@ -199,7 +116,7 @@
|
||||
|
||||
Pick **one** install method:
|
||||
|
||||
Prerequisites: Python 3.11 or newer. Git is only needed for a source install; Node.js/Bun are only needed if you are developing the WebUI itself.
|
||||
Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
|
||||
|
||||
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
|
||||
|
||||
@@ -217,7 +134,7 @@ Windows PowerShell:
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps below and go straight to **Test one message**.
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
|
||||
@@ -257,23 +174,29 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
|
||||
|
||||
**Install from source**
|
||||
|
||||
`bun` or `npm` must be available. From an activated virtual environment:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
python -m pip install .
|
||||
```
|
||||
|
||||
On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
|
||||
|
||||
Verify the install:
|
||||
|
||||
```bash
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If `nanobot` is not on `PATH`, invoke it through the method that installed it: reuse the recommended installer's command, use `uv tool run --from nanobot-ai nanobot ...` or `pipx run --spec nanobot-ai nanobot ...`, or use the Python executable from the environment where pip installed the package.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
Skip this step if the one-command setup already started the wizard and you saved the config there.
|
||||
Skip this step if the one-command setup already started the wizard and Quick Start finished there.
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
@@ -287,15 +210,16 @@ Skip this step if you already configured provider and model settings in the wiza
|
||||
|
||||
`nanobot onboard` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file.
|
||||
|
||||
The example below uses [OpenRouter](https://openrouter.ai/keys) only so the JSON has concrete names. Provider examples are recipes, not rankings or endorsements. If you use another provider, replace the provider config key, API key, preset provider name, and model ID together.
|
||||
The example below uses a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service. Provider examples are recipes, not rankings or endorsements. For copyable provider-specific setup, see [Provider Cookbook](./docs/provider-cookbook.md).
|
||||
|
||||
*Set your API key*:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,10 +232,10 @@ The example below uses [OpenRouter](https://openrouter.ai/keys) only so the JSON
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-opus-4.5",
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536,
|
||||
"contextWindowTokens": 200000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
@@ -335,7 +259,17 @@ For another provider, the same config shape still applies:
|
||||
| Model ID | `modelPresets.primary.model` |
|
||||
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
|
||||
|
||||
**3. Test one message**
|
||||
**3. Open the WebUI**
|
||||
|
||||
The stable-compatible path is:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the terminal open and visit `http://127.0.0.1:8765`. Current source versions also provide `nanobot webui`, which prepares the local WebSocket channel if needed, starts the gateway, and opens the browser automatically. The first-run WebUI binds to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
|
||||
For manual or terminal-only setup, test one CLI message:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
@@ -367,23 +301,13 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
|
||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||
</p>
|
||||
|
||||
**1. Enable the WebSocket channel in `~/.nanobot/config.json`**
|
||||
|
||||
Merge this block into your existing config:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
**2. Start the gateway**
|
||||
**Open it**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
**3. Open the WebUI**
|
||||
|
||||
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||
On current source versions, the command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). If your installed stable release does not include `nanobot webui`, run `nanobot gateway` and open that address manually. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||
|
||||
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
||||
|
||||
@@ -425,6 +349,7 @@ The WebUI is served by the WebSocket channel on port `8765` by default. The gate
|
||||
|
||||
Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
|
||||
|
||||
- Use task-oriented guides: [Guides](./docs/guides/README.md)
|
||||
- Start with no technical background: [Start Without Technical Background](./docs/start-without-technical-background.md)
|
||||
- Start from zero with developer basics: [Install and Quick Start](./docs/quick-start.md)
|
||||
- Understand the runtime model: [Concepts](./docs/concepts.md)
|
||||
@@ -432,7 +357,8 @@ Browse the [repo docs](./docs/README.md) for the latest features and GitHub deve
|
||||
- Choose a provider/model: [Providers and Models](./docs/providers.md)
|
||||
- Copy provider setup recipes: [Provider Cookbook](./docs/provider-cookbook.md)
|
||||
- Debug setup and runtime failures: [Troubleshooting](./docs/troubleshooting.md)
|
||||
- Talk to your nanobot with familiar chat apps: [Chat Apps](./docs/chat-apps.md)
|
||||
- Talk to your nanobot with familiar chat apps: [Chat App AI Agent](./docs/guides/chat-app-ai-agent.md) · [Chat Apps](./docs/chat-apps.md)
|
||||
- Schedule or trigger agent work: [Automations](./docs/automations.md)
|
||||
- Configure providers, web search, MCP, and runtime behavior: [Configuration](./docs/configuration.md)
|
||||
- Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md)
|
||||
- Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md)
|
||||
@@ -455,7 +381,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution gui
|
||||
|
||||
## Contact
|
||||
|
||||
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
|
||||
### Contributors
|
||||
|
||||
@@ -463,19 +389,6 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<div align="center">
|
||||
<a href="https://star-history.com/#HKUDS/nanobot&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" style="border-radius: 15px; box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
||||
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">
|
||||
|
||||
+8
-16
@@ -48,7 +48,7 @@ chmod 600 ~/.nanobot/config.json
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["+1234567890"]
|
||||
"allowFrom": ["1234567890"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ chmod 600 ~/.nanobot/config.json
|
||||
**Security Notes:**
|
||||
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
|
||||
- Get your Telegram user ID from `@userinfobot`
|
||||
- Use full phone numbers with country code for WhatsApp
|
||||
- Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
|
||||
- Review access logs regularly for unauthorized access attempts
|
||||
|
||||
### 3. Shell Command Execution
|
||||
@@ -107,12 +107,12 @@ File operations have path traversal protection, but:
|
||||
**API Calls:**
|
||||
- All external API calls use HTTPS by default
|
||||
- Timeouts are configured to prevent hanging requests
|
||||
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
|
||||
- Consider using a firewall to restrict outbound connections if needed
|
||||
|
||||
**WhatsApp Bridge:**
|
||||
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
|
||||
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
|
||||
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
|
||||
**WhatsApp:**
|
||||
- Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
|
||||
- Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
|
||||
|
||||
### 6. Dependency Security
|
||||
|
||||
@@ -127,17 +127,9 @@ pip-audit
|
||||
pip install --upgrade nanobot-ai
|
||||
```
|
||||
|
||||
For Node.js dependencies (WhatsApp bridge):
|
||||
```bash
|
||||
cd bridge
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Keep `litellm` updated to the latest version for security fixes
|
||||
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
|
||||
- Run `pip-audit` or `npm audit` regularly
|
||||
- Run `pip-audit` regularly after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
|
||||
- Subscribe to security advisories for nanobot and its dependencies
|
||||
|
||||
### 7. Production Deployment
|
||||
@@ -238,7 +230,7 @@ If you suspect a security breach:
|
||||
✅ **Secure Communication**
|
||||
- HTTPS for all external API calls
|
||||
- TLS for Telegram API
|
||||
- WhatsApp bridge: localhost-only binding + optional token auth
|
||||
- WhatsApp session secrets stay in the local session database
|
||||
|
||||
## Known Limitations
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "nanobot-whatsapp-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "WhatsApp bridge for nanobot using Baileys",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsc && node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ws": "^8.17.1",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* nanobot WhatsApp Bridge
|
||||
*
|
||||
* This bridge connects WhatsApp Web to nanobot's Python backend
|
||||
* via WebSocket. It handles authentication, message forwarding,
|
||||
* and reconnection logic.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build && npm start
|
||||
*
|
||||
* Or with custom settings:
|
||||
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
|
||||
*/
|
||||
|
||||
// Polyfill crypto for Baileys in ESM
|
||||
import { webcrypto } from 'crypto';
|
||||
if (!globalThis.crypto) {
|
||||
(globalThis as any).crypto = webcrypto;
|
||||
}
|
||||
|
||||
import { BridgeServer } from './server.js';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
||||
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
|
||||
const TOKEN = process.env.BRIDGE_TOKEN?.trim();
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🐈 nanobot WhatsApp Bridge');
|
||||
console.log('========================\n');
|
||||
|
||||
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n\nShutting down...');
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
server.start().catch((error) => {
|
||||
console.error('Failed to start bridge:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* WebSocket server for Python-Node.js bridge communication.
|
||||
* Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
|
||||
|
||||
interface SendCommand {
|
||||
type: 'send';
|
||||
to: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface SendMediaCommand {
|
||||
type: 'send_media';
|
||||
to: string;
|
||||
filePath: string;
|
||||
mimetype: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
type BridgeCommand = SendCommand | SendMediaCommand;
|
||||
|
||||
interface BridgeMessage {
|
||||
type: 'message' | 'status' | 'qr' | 'error';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class BridgeServer {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private wa: WhatsAppClient | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
|
||||
constructor(private port: number, private authDir: string, private token: string) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.token.trim()) {
|
||||
throw new Error('BRIDGE_TOKEN is required');
|
||||
}
|
||||
|
||||
// Bind to localhost only — never expose to external network
|
||||
this.wss = new WebSocketServer({
|
||||
host: '127.0.0.1',
|
||||
port: this.port,
|
||||
verifyClient: (info, done) => {
|
||||
const origin = info.origin || info.req.headers.origin;
|
||||
if (origin) {
|
||||
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
|
||||
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
|
||||
return;
|
||||
}
|
||||
done(true);
|
||||
},
|
||||
});
|
||||
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
|
||||
console.log('🔒 Token authentication enabled');
|
||||
|
||||
// Initialize WhatsApp client
|
||||
this.wa = new WhatsAppClient({
|
||||
authDir: this.authDir,
|
||||
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
|
||||
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
|
||||
onStatus: (status) => this.broadcast({ type: 'status', status }),
|
||||
});
|
||||
|
||||
// Handle WebSocket connections
|
||||
this.wss.on('connection', (ws) => {
|
||||
// Require auth handshake as first message
|
||||
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth' && msg.token === this.token) {
|
||||
console.log('🔗 Python client authenticated');
|
||||
this.setupClient(ws);
|
||||
} else {
|
||||
ws.close(4003, 'Invalid token');
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, 'Invalid auth message');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Connect to WhatsApp
|
||||
await this.wa.connect();
|
||||
}
|
||||
|
||||
private setupClient(ws: WebSocket): void {
|
||||
this.clients.add(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const cmd = JSON.parse(data.toString()) as BridgeCommand;
|
||||
await this.handleCommand(cmd);
|
||||
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
||||
} catch (error) {
|
||||
console.error('Error handling command:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Python client disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(cmd: BridgeCommand): Promise<void> {
|
||||
if (!this.wa) return;
|
||||
|
||||
if (cmd.type === 'send') {
|
||||
await this.wa.sendMessage(cmd.to, cmd.text);
|
||||
} else if (cmd.type === 'send_media') {
|
||||
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(msg: BridgeMessage): void {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const client of this.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Close all client connections
|
||||
for (const client of this.clients) {
|
||||
client.close();
|
||||
}
|
||||
this.clients.clear();
|
||||
|
||||
// Close WebSocket server
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
// Disconnect WhatsApp
|
||||
if (this.wa) {
|
||||
await this.wa.disconnect();
|
||||
this.wa = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
declare module 'qrcode-terminal' {
|
||||
export function generate(text: string, options?: { small?: boolean }): void;
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* WhatsApp client wrapper using Baileys.
|
||||
* Based on OpenClaw's working implementation.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage,
|
||||
extractMessageContent as baileysExtractMessageContent,
|
||||
} from '@whiskeysockets/baileys';
|
||||
|
||||
import { Boom } from '@hapi/boom';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import pino from 'pino';
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join, basename, resolve, sep } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
|
||||
export interface InboundMessage {
|
||||
id: string;
|
||||
sender: string;
|
||||
pn: string;
|
||||
participant?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isGroup: boolean;
|
||||
isForwarded?: boolean;
|
||||
wasMentioned?: boolean;
|
||||
isReplyToBot?: boolean;
|
||||
media?: string[];
|
||||
}
|
||||
|
||||
export interface WhatsAppClientOptions {
|
||||
authDir: string;
|
||||
onMessage: (msg: InboundMessage) => void;
|
||||
onQR: (qr: string) => void;
|
||||
onStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
export class WhatsAppClient {
|
||||
private sock: any = null;
|
||||
private options: WhatsAppClientOptions;
|
||||
private reconnecting = false;
|
||||
|
||||
constructor(options: WhatsAppClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private normalizeJid(jid: string | undefined | null): string {
|
||||
return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, '');
|
||||
}
|
||||
|
||||
private selfJids(): Set<string> {
|
||||
return new Set(
|
||||
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
|
||||
.map((jid) => this.normalizeJid(jid))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
private messageContextInfos(msg: any): any[] {
|
||||
const unwrapped = baileysExtractMessageContent(msg?.message);
|
||||
const containers = [msg?.message, unwrapped];
|
||||
const infos = containers.flatMap((message) => [
|
||||
message?.extendedTextMessage?.contextInfo,
|
||||
message?.imageMessage?.contextInfo,
|
||||
message?.videoMessage?.contextInfo,
|
||||
message?.documentMessage?.contextInfo,
|
||||
message?.audioMessage?.contextInfo,
|
||||
]);
|
||||
return infos.filter(Boolean);
|
||||
}
|
||||
|
||||
private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } {
|
||||
if (!msg?.key?.remoteJid?.endsWith('@g.us')) {
|
||||
return { wasMentioned: false, isReplyToBot: false };
|
||||
}
|
||||
|
||||
const selfIds = this.selfJids();
|
||||
const contextInfos = this.messageContextInfos(msg);
|
||||
|
||||
const mentioned = contextInfos.flatMap((info) => (
|
||||
Array.isArray(info?.mentionedJid) ? info.mentionedJid : []
|
||||
));
|
||||
const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
|
||||
|
||||
const isReplyToBot = contextInfos.some((info) => {
|
||||
const quotedParticipant = this.normalizeJid(info?.participant);
|
||||
return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant));
|
||||
});
|
||||
|
||||
return { wasMentioned, isReplyToBot };
|
||||
}
|
||||
|
||||
private isForwarded(msg: any): boolean {
|
||||
return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded));
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const logger = pino({ level: 'silent' });
|
||||
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
|
||||
console.log(`Using Baileys version: ${version.join('.')}`);
|
||||
|
||||
// Record startup time — messages older than this will be ignored
|
||||
// to avoid replaying history on reconnect
|
||||
const startupTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create socket following OpenClaw's pattern
|
||||
this.sock = makeWASocket({
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
version,
|
||||
logger,
|
||||
printQRInTerminal: false,
|
||||
browser: ['nanobot', 'cli', VERSION],
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
});
|
||||
|
||||
// Handle WebSocket errors
|
||||
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
|
||||
this.sock.ws.on('error', (err: Error) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle connection updates
|
||||
this.sock.ev.on('connection.update', async (update: any) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
if (qr) {
|
||||
// Display QR code in terminal
|
||||
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
|
||||
qrcode.generate(qr, { small: true });
|
||||
this.options.onQR(qr);
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
|
||||
|
||||
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
|
||||
this.options.onStatus('disconnected');
|
||||
|
||||
if (shouldReconnect && !this.reconnecting) {
|
||||
this.reconnecting = true;
|
||||
console.log('Reconnecting in 5 seconds...');
|
||||
setTimeout(() => {
|
||||
this.reconnecting = false;
|
||||
this.connect();
|
||||
}, 5000);
|
||||
}
|
||||
} else if (connection === 'open') {
|
||||
console.log('✅ Connected to WhatsApp');
|
||||
this.options.onStatus('connected');
|
||||
}
|
||||
});
|
||||
|
||||
// Save credentials on update
|
||||
this.sock.ev.on('creds.update', saveCreds);
|
||||
|
||||
// Handle incoming messages
|
||||
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.key.fromMe) continue;
|
||||
if (msg.key.remoteJid === 'status@broadcast') continue;
|
||||
|
||||
// Drop messages older than startup time (avoid replaying history on reconnect)
|
||||
const msgTimestamp = msg.messageTimestamp as number;
|
||||
if (msgTimestamp && msgTimestamp < startupTimestamp) continue;
|
||||
|
||||
const unwrapped = baileysExtractMessageContent(msg.message);
|
||||
if (!unwrapped) continue;
|
||||
|
||||
const content = this.getTextContent(unwrapped);
|
||||
let fallbackContent: string | null = null;
|
||||
const mediaPaths: string[] = [];
|
||||
|
||||
if (unwrapped.imageMessage) {
|
||||
fallbackContent = '[Image]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.documentMessage) {
|
||||
fallbackContent = '[Document]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
|
||||
unwrapped.documentMessage.fileName ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.videoMessage) {
|
||||
fallbackContent = '[Video]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.audioMessage) {
|
||||
fallbackContent = '[Voice Message]';
|
||||
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
|
||||
if (path) mediaPaths.push(path);
|
||||
} else if (unwrapped.contactMessage) {
|
||||
// Single shared contact
|
||||
const displayName = unwrapped.contactMessage.displayName || '';
|
||||
const vcard = unwrapped.contactMessage.vcard || '';
|
||||
fallbackContent = `[Contact: ${displayName}]\n${vcard}`;
|
||||
} else if (unwrapped.contactsArrayMessage) {
|
||||
// Multiple shared contacts
|
||||
const vcards = unwrapped.contactsArrayMessage.contacts || [];
|
||||
const parts = vcards.map((c: any) => {
|
||||
const name = c.displayName || '';
|
||||
const vc = c.vcard || '';
|
||||
return `[Contact: ${name}]\n${vc}`;
|
||||
});
|
||||
fallbackContent = parts.join('\n\n');
|
||||
}
|
||||
|
||||
const isForwarded = this.isForwarded(msg);
|
||||
|
||||
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
|
||||
if (!finalContent && mediaPaths.length === 0) continue;
|
||||
|
||||
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
|
||||
const { wasMentioned, isReplyToBot } = this.botAddressing(msg);
|
||||
|
||||
this.options.onMessage({
|
||||
id: msg.key.id || '',
|
||||
sender: msg.key.remoteJid || '',
|
||||
pn: msg.key.remoteJidAlt || '',
|
||||
...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}),
|
||||
content: finalContent,
|
||||
timestamp: msg.messageTimestamp as number,
|
||||
isGroup,
|
||||
...(isForwarded ? { isForwarded } : {}),
|
||||
...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}),
|
||||
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
|
||||
try {
|
||||
const mediaDir = join(this.options.authDir, '..', 'media');
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
||||
|
||||
let outFilename: string;
|
||||
if (fileName) {
|
||||
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
|
||||
} else {
|
||||
const mime = mimetype || 'application/octet-stream';
|
||||
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
|
||||
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
const filepath = resolve(mediaDir, outFilename);
|
||||
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
|
||||
throw new Error(`Path traversal blocked: ${outFilename}`);
|
||||
}
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
return filepath;
|
||||
} catch (err) {
|
||||
console.error('Failed to download media:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getTextContent(message: any): string | null {
|
||||
// Text message
|
||||
if (message.conversation) {
|
||||
return message.conversation;
|
||||
}
|
||||
|
||||
// Extended text (reply, link preview)
|
||||
if (message.extendedTextMessage?.text) {
|
||||
return message.extendedTextMessage.text;
|
||||
}
|
||||
|
||||
// Image with optional caption
|
||||
if (message.imageMessage) {
|
||||
return message.imageMessage.caption || '';
|
||||
}
|
||||
|
||||
// Video with optional caption
|
||||
if (message.videoMessage) {
|
||||
return message.videoMessage.caption || '';
|
||||
}
|
||||
|
||||
// Document with optional caption
|
||||
if (message.documentMessage) {
|
||||
return message.documentMessage.caption || '';
|
||||
}
|
||||
|
||||
// Voice/Audio message
|
||||
if (message.audioMessage) {
|
||||
return `[Voice Message]`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async sendMessage(to: string, text: string): Promise<void> {
|
||||
if (!this.sock) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
await this.sock.sendMessage(to, { text });
|
||||
}
|
||||
|
||||
async sendMedia(
|
||||
to: string,
|
||||
filePath: string,
|
||||
mimetype: string,
|
||||
caption?: string,
|
||||
fileName?: string,
|
||||
): Promise<void> {
|
||||
if (!this.sock) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
const category = mimetype.split('/')[0];
|
||||
|
||||
if (category === 'image') {
|
||||
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
|
||||
} else if (category === 'video') {
|
||||
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
|
||||
} else if (category === 'audio') {
|
||||
await this.sock.sendMessage(to, { audio: buffer, mimetype });
|
||||
} else {
|
||||
const name = fileName || basename(filePath);
|
||||
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.sock) {
|
||||
this.sock.end(undefined);
|
||||
this.sock = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""Cross-suite test infrastructure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
|
||||
import certifi
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
|
||||
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
|
||||
|
||||
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
|
||||
suite constructs hundreds of clients while mocking their I/O. System roots
|
||||
preserve certificate verification for accidental local requests; explicit
|
||||
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
yield
|
||||
return
|
||||
|
||||
original = ssl.create_default_context
|
||||
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
|
||||
|
||||
def create_default_context(
|
||||
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
|
||||
*,
|
||||
cafile: str | None = None,
|
||||
capath: str | None = None,
|
||||
cadata: str | bytes | None = None,
|
||||
) -> ssl.SSLContext:
|
||||
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
|
||||
if requested_path == certifi_path and capath is None and cadata is None:
|
||||
return original(purpose)
|
||||
return original(
|
||||
purpose,
|
||||
cafile=cafile,
|
||||
capath=capath,
|
||||
cadata=cadata,
|
||||
)
|
||||
|
||||
ssl.create_default_context = create_default_context
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
ssl.create_default_context = original
|
||||
@@ -0,0 +1,16 @@
|
||||
x-bwrap-security: &bwrap-security
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- seccomp=unconfined
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
<<: *bwrap-security
|
||||
|
||||
nanobot-api:
|
||||
<<: *bwrap-security
|
||||
|
||||
nanobot-cli:
|
||||
<<: *bwrap-security
|
||||
+3
-6
@@ -2,15 +2,12 @@ x-common-config: &common-config
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
|
||||
volumes:
|
||||
- ~/.nanobot:/home/nanobot/.nanobot
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
- seccomp=unconfined
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
@@ -19,7 +16,7 @@ services:
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 18790:18790
|
||||
- 127.0.0.1:18790:18790
|
||||
- 8765:8765
|
||||
deploy:
|
||||
resources:
|
||||
|
||||
+62
-84
@@ -1,106 +1,84 @@
|
||||
# nanobot Docs
|
||||
# nanobot Documentation
|
||||
|
||||
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
|
||||
Use these docs to get a working agent first, then open a task guide only when you need the next capability. Source-level design and extension details are kept in the contributor section.
|
||||
|
||||
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools.
|
||||
|
||||
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
|
||||
|
||||
Provider examples are concrete walkthroughs, not rankings or endorsements. Use the provider whose key, endpoint, and model ID you actually control.
|
||||
|
||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
||||
|
||||
## Pick a Track
|
||||
|
||||
| You are | Start with | Then use |
|
||||
|---|---|---|
|
||||
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
|
||||
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
|
||||
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
|
||||
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
Repository docs follow the current source tree and can be newer than the latest package release. For published release docs, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
|
||||
|
||||
## Start Here
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
| Your situation | Read this | You are done when... |
|
||||
|---|---|---|
|
||||
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply |
|
||||
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
|
||||
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions |
|
||||
| Choose or change a model provider | [`providers.md`](./providers.md) | Correct provider/model pairing without reading the full config reference |
|
||||
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
|
||||
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
|
||||
| Terminals, Python, or API keys are new to you | [Beginner walkthrough](./start-without-technical-background.md) | The browser can send `Hello!` and receive a reply |
|
||||
| You are comfortable running commands | [Install and Quick Start](./quick-start.md) | `nanobot status` is healthy and the WebUI or CLI can get one reply |
|
||||
| Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
|
||||
|
||||
## After the First Reply Works
|
||||
The recommended first-run path is:
|
||||
|
||||
Do not configure everything at once. Pick one next surface:
|
||||
1. Install nanobot.
|
||||
2. Choose **Quick Start** in `nanobot onboard --wizard`.
|
||||
3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
|
||||
4. Send `Hello!` before configuring anything else.
|
||||
|
||||
If a local `nanobot agent` session can already answer normally, you can also ask nanobot to help configure itself: have it read the relevant docs, inspect your current config, make one specific next change, and tell you when to run `/restart`.
|
||||
Most people do not need to edit JSON for the first run. The wizard handles the initial provider, model, and local WebUI settings. Current source versions also provide `nanobot webui` to start the gateway and open the browser in one step. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||
|
||||
| Next goal | Read | First check |
|
||||
|---|---|---|
|
||||
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
|
||||
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
||||
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
||||
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
|
||||
| Diagnose a new failure | [`troubleshooting.md`](./troubleshooting.md) | Start with `nanobot status`, then `nanobot agent -m "Hello!"` |
|
||||
## Add One Capability
|
||||
|
||||
## Use nanobot
|
||||
Pick the row that matches what you want to accomplish next:
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
|---|---|---|
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
|
||||
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
||||
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
|
||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
||||
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
||||
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
||||
| Join agent communities | [`agent-social-network.md`](./agent-social-network.md) | External agent-community setup |
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Learn the browser workbench | [WebUI](./webui.md) |
|
||||
| Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
|
||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Generate images | [Image Generation](./image-generation.md) |
|
||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||
| Understand and manage long-term memory | [Memory](./memory.md) |
|
||||
| Run nanobot continuously | [Deployment](./deployment.md) |
|
||||
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
|
||||
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
|
||||
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
|
||||
|
||||
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
|
||||
|
||||
## Operate nanobot
|
||||
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| Commands and flags | [CLI Reference](./cli-reference.md) |
|
||||
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
|
||||
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.md) |
|
||||
| Provider/model matching and selection | [Providers and Models](./providers.md) |
|
||||
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
|
||||
| Older development highlights | [Release Archive](./release-archive.md) |
|
||||
|
||||
## Reference
|
||||
|
||||
| Area | Read | Best for |
|
||||
|---|---|---|
|
||||
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
|
||||
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
|
||||
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
|
||||
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
|
||||
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
|
||||
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
|
||||
| WebSocket protocol | [`websocket.md`](./websocket.md) | Custom clients, token issuance, multiplexed chats, media, and protocol events |
|
||||
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | `/v1/chat/completions`, `/v1/models`, file uploads, and SDK-compatible usage |
|
||||
| Python SDK | [`python-sdk.md`](./python-sdk.md) | Running nanobot from Python and attaching hooks |
|
||||
| Runtime self-inspection | [`my-tool.md`](./my-tool.md) | Inspecting and tuning the current agent run |
|
||||
Use reference pages to look up an exact option after you know what you are trying to configure:
|
||||
|
||||
## Fast Lookup
|
||||
|
||||
| Need | Jump to |
|
||||
| Area | Reference |
|
||||
|---|---|
|
||||
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
|
||||
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
|
||||
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
|
||||
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
||||
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
||||
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
|
||||
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
|
||||
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
| Every configuration field and default | [Configuration](./configuration.md) |
|
||||
| Provider and model behavior | [Providers and Models](./providers.md) |
|
||||
| Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
|
||||
| WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
|
||||
| Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
|
||||
| OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
|
||||
| Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
|
||||
|
||||
## Extend nanobot
|
||||
Configuration examples are usually snippets to merge into `~/.nanobot/config.json`, not complete replacement files. The docs use camelCase because nanobot writes config that way. Keep real API keys, bot tokens, and passwords out of issues and public logs.
|
||||
|
||||
| Goal | Read | Outcome |
|
||||
|---|---|---|
|
||||
| Add a provider or transcription adapter | [`development.md`](./development.md) | A registry/schema-aligned implementation path |
|
||||
| Add a chat channel plugin | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | A packaged channel discovered through entry points |
|
||||
| Add custom MCP servers | [`configuration.md#mcp-model-context-protocol`](./configuration.md#mcp-model-context-protocol) | External tools exposed to the agent through MCP |
|
||||
| Tune tool safety | [`configuration.md#security`](./configuration.md#security) | Shell sandboxing, workspace restriction, and SSRF policy |
|
||||
## Extend or Contribute
|
||||
|
||||
## Reading Strategy
|
||||
These pages explain implementation and extension points. You do not need them to install or operate nanobot.
|
||||
|
||||
Use the docs in this order when you are unsure where to go:
|
||||
| Goal | Read |
|
||||
|---|---|
|
||||
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
|
||||
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
||||
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
|
||||
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
||||
|
||||
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time.
|
||||
2. [`quick-start.md`](./quick-start.md) proves installation, config loading, and provider access.
|
||||
3. [`concepts.md`](./concepts.md) explains the runtime model so later pages are easier to scan.
|
||||
4. [`provider-cookbook.md`](./provider-cookbook.md) gives pasteable provider, fallback, local model, and Langfuse recipes.
|
||||
5. A task guide, such as [`chat-apps.md`](./chat-apps.md), [`image-generation.md`](./image-generation.md), or [`deployment.md`](./deployment.md), gets one workflow working.
|
||||
6. [`configuration.md`](./configuration.md) is the source of truth when you need a specific field, default value, or advanced option.
|
||||
7. [`troubleshooting.md`](./troubleshooting.md) helps isolate whether a failure is install, config, provider, gateway, channel, or tool related.
|
||||
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
|
||||
|
||||
@@ -1,10 +1,99 @@
|
||||
# Agent Social Network
|
||||
|
||||
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
|
||||
An agent social network lets a nanobot instance join an external agent community
|
||||
or chat network as a bot identity. After joining, nanobot can receive messages
|
||||
through that network, answer with its normal agent runtime, and use the same
|
||||
workspace, tools, memory, and channel access controls that apply elsewhere.
|
||||
|
||||
| Platform | How to Join (send this message to your bot) |
|
||||
|----------|-------------|
|
||||
| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
|
||||
| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
|
||||
This page describes the current entry points and the safety model. Treat each
|
||||
network as an external integration: only join networks you trust, keep owner
|
||||
approval narrow, and review the skill instructions before asking nanobot to
|
||||
follow them.
|
||||
|
||||
Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
|
||||
## What is an agent social network?
|
||||
|
||||
In nanobot docs, an agent social network is an external community that publishes
|
||||
setup instructions for nanobot-compatible agents. The setup usually lives in a
|
||||
remote `skill.md` file. You send nanobot a message asking it to read that file
|
||||
and follow the network's registration flow.
|
||||
|
||||
The external network is not part of nanobot core. nanobot provides the runtime:
|
||||
model calls, tools, memory, sessions, and channel delivery.
|
||||
|
||||
> [!WARNING]
|
||||
> Remote `skill.md` files are external instructions. Review them before asking
|
||||
> nanobot to follow them, especially when file, shell, network, or chat-delivery
|
||||
> tools are enabled. Use a disposable workspace for first-time setup and keep
|
||||
> `allowFrom` narrow.
|
||||
|
||||
## What nanobot can do after joining
|
||||
|
||||
After setup, the exact behavior depends on the network, but the normal pattern
|
||||
is:
|
||||
|
||||
- receive direct messages or community messages addressed to the bot
|
||||
- reply through the configured network channel
|
||||
- use normal nanobot tools allowed by your configuration
|
||||
- keep session history for conversations that flow through the network
|
||||
- use Dream memory if memory is enabled for the workspace
|
||||
|
||||
## Supported networks
|
||||
|
||||
| Platform | Join message to send to your bot |
|
||||
|---|---|
|
||||
| [Moltbook](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
|
||||
| [ClawdChat](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
|
||||
|
||||
Send the message from the CLI, WebUI, or an already configured chat channel.
|
||||
nanobot will read the public setup instructions and perform the requested setup
|
||||
using its available tools.
|
||||
|
||||
## Security model
|
||||
|
||||
- The remote setup instructions are external content. Read them yourself before
|
||||
running the join prompt if the bot has file, shell, or network tools enabled.
|
||||
- Keep `allowFrom` narrow on the channel you use for setup so only trusted users
|
||||
can issue registration commands.
|
||||
- Keep `tools.restrictToWorkspace` enabled unless the network setup explicitly
|
||||
needs another path.
|
||||
- Avoid `allowFrom: ["*"]` during setup unless the bot is isolated in a test
|
||||
workspace.
|
||||
- Store network tokens through environment variables when the integration
|
||||
supports secrets.
|
||||
|
||||
## Example workflow
|
||||
|
||||
1. Confirm the local agent works:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
2. Open the WebUI or a trusted chat channel.
|
||||
|
||||
3. Send the join message for the network you want.
|
||||
|
||||
4. Restart the gateway if the setup changes channel configuration:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
5. Send a test message through the external network and confirm the session is
|
||||
routed to the expected workspace and model.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Network features, identity, and moderation rules are controlled by the
|
||||
external network.
|
||||
- Availability depends on the remote setup instructions remaining reachable.
|
||||
- nanobot does not automatically audit remote skills for you.
|
||||
- Some networks may require public callbacks, tokens, or channel-specific
|
||||
account setup.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Chat Apps](./chat-apps.md)
|
||||
- [Security configuration](./configuration.md#security)
|
||||
- [Pairing](./configuration.md#pairing)
|
||||
- [Runtime self-inspection](./my-tool.md)
|
||||
|
||||
@@ -81,11 +81,11 @@ Main files:
|
||||
| Area | Files |
|
||||
|---|---|
|
||||
| Base channel contract | `nanobot/channels/base.py` |
|
||||
| Built-in channels | `nanobot/channels/*.py` |
|
||||
| Channel packages | `nanobot/channels/<channel>/` |
|
||||
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
|
||||
| WebSocket/WebUI channel | `nanobot/channels/websocket/` |
|
||||
|
||||
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md).
|
||||
Channels are discovered by scanning self-contained packages under `nanobot/channels/`. Add a channel by contributing one package that follows [`channel-package-guide.md`](./channel-package-guide.md).
|
||||
|
||||
## WebUI and Gateway
|
||||
|
||||
@@ -181,7 +181,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
||||
| Extension | How |
|
||||
|---|---|
|
||||
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
|
||||
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
||||
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||
| MCP | Add `tools.mcpServers` config |
|
||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Automations
|
||||
|
||||
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
|
||||
|
||||
Automations are agent turns that run later in a linked chat/session. Use them
|
||||
when nanobot should do work without someone actively typing: reminders,
|
||||
recurring checks, nightly summaries, CI follow-ups, local script reports, or
|
||||
webhook-driven events.
|
||||
|
||||
Create automations from the chat, channel, or WebUI session where the result
|
||||
should appear. That lets nanobot keep the right session history, workspace, and
|
||||
reply target.
|
||||
|
||||
## Choose an Automation Type
|
||||
|
||||
| Type | Starts from | Best for | Created with |
|
||||
|---|---|---|---|
|
||||
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target session to schedule it with the `cron` tool |
|
||||
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target session |
|
||||
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
|
||||
|
||||
The two user-created automation types are scheduled automations and local
|
||||
triggers. Heartbeat uses the same background service but is system-managed and
|
||||
protected from normal automation edits.
|
||||
|
||||
## Before You Create One
|
||||
|
||||
Keep `nanobot gateway` running. The gateway owns background delivery for chat
|
||||
apps, WebUI sessions, scheduled automations, local triggers, heartbeat, and
|
||||
Dream jobs.
|
||||
|
||||
Use the same workspace and config for the gateway and any process that sends
|
||||
local trigger messages. If you run multiple nanobot instances, pass the matching
|
||||
`--config` or `--workspace` option to `nanobot trigger`.
|
||||
|
||||
Create each automation from the target session. An automation without a linked
|
||||
chat/session cannot be enabled or run from the WebUI because nanobot would not
|
||||
know where to deliver the turn.
|
||||
|
||||
## Scheduled Automations
|
||||
|
||||
Scheduled automations are created by the agent's `cron` tool. In practice, ask
|
||||
nanobot from the target chat or WebUI session:
|
||||
|
||||
```text
|
||||
Every weekday at 9am, check open pull requests and summarize blockers here.
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
Tomorrow at 4pm, remind me to send the release notes.
|
||||
```
|
||||
|
||||
The cron tool supports interval schedules, cron expressions, and one-time
|
||||
scheduled tasks. Cron expressions can include an IANA timezone such as
|
||||
`America/Vancouver`; otherwise nanobot uses the runtime default timezone.
|
||||
|
||||
Scheduled automations normally deliver the result back to the session where they
|
||||
were created. Use them for work that should run on a predictable schedule and
|
||||
report each run.
|
||||
|
||||
For background checks that should stay quiet unless there is something useful to
|
||||
report, use heartbeat instead of a user-created scheduled automation.
|
||||
|
||||
## Local Triggers
|
||||
|
||||
Local triggers let a local script or external service send a message into a
|
||||
specific nanobot session later.
|
||||
|
||||
Create the trigger from the chat or WebUI session where future messages should
|
||||
arrive:
|
||||
|
||||
```text
|
||||
/trigger PR review
|
||||
```
|
||||
|
||||
nanobot replies with a trigger ID and a command shaped like:
|
||||
|
||||
```bash
|
||||
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||
```
|
||||
|
||||
Replace the quoted text with the message nanobot should receive. For generated
|
||||
or longer content, pipe stdin:
|
||||
|
||||
```bash
|
||||
generate-report | nanobot trigger trg_8K4P2Q9X
|
||||
```
|
||||
|
||||
For multiple instances, use the same config or workspace selector as the
|
||||
gateway:
|
||||
|
||||
```bash
|
||||
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
|
||||
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
|
||||
```
|
||||
|
||||
nanobot does not provide a built-in public webhook receiver for local triggers.
|
||||
If GitHub, CI, or another external system should wake nanobot, run your own
|
||||
small webhook service and have it call `nanobot trigger` after it builds the
|
||||
final message.
|
||||
|
||||
## Heartbeat
|
||||
|
||||
Heartbeat is for recurring workspace checks that should usually stay quiet. It
|
||||
reads `<workspace>/HEARTBEAT.md`, executes active tasks, and sends only useful or
|
||||
actionable results to the most recently active chat target.
|
||||
|
||||
Use heartbeat for checks such as "watch this repo for important failures" or
|
||||
"periodically inspect this workspace and only tell me when action is needed." Use
|
||||
a scheduled automation instead when every run should produce a visible reminder
|
||||
or report.
|
||||
|
||||
Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
|
||||
[`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat).
|
||||
|
||||
## Manage Automations
|
||||
|
||||
Use the WebUI Automations view to:
|
||||
|
||||
- filter by all, active, paused, needs-attention, or system jobs;
|
||||
- search by task name, message, trigger command, linked chat, schedule, or
|
||||
status;
|
||||
- sort by next run, last run, updated time, or name;
|
||||
- run scheduled automations now;
|
||||
- pause or resume, rename, or delete user-created automations;
|
||||
- copy the CLI command for local triggers;
|
||||
- inspect protected system automations without changing them.
|
||||
|
||||
Local triggers do not have a WebUI "Run now" action because each run needs a
|
||||
message. Copy the `nanobot trigger ...` command from the WebUI and replace
|
||||
`"message"` with the content that should be delivered.
|
||||
|
||||
## Delivery and Reliability
|
||||
|
||||
Automation delivery is workspace-local. Scheduled jobs and local trigger
|
||||
deliveries use the same workspace as the gateway.
|
||||
|
||||
Local trigger messages are written to a durable queue. If the gateway is not
|
||||
running yet, the message waits in that workspace. If the linked session is
|
||||
already running a turn, the trigger waits until the session becomes idle instead
|
||||
of being injected into the active turn.
|
||||
|
||||
The local trigger queue is at-least-once, not exactly-once. If the gateway exits
|
||||
after claiming a delivery but before the linked turn completes, the next gateway
|
||||
start requeues that delivery. External scripts should make repeated trigger
|
||||
messages safe. If the delivery reaches the agent and the turn fails, the
|
||||
delivery is marked failed instead of retrying forever.
|
||||
|
||||
Each local trigger delivery writes an audit record under
|
||||
`<workspace>/triggers/runs`. Run one gateway consumer per workspace; the local
|
||||
queue is not a distributed multi-consumer queue.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
For a nightly report, ask from the target session:
|
||||
|
||||
```text
|
||||
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
|
||||
```
|
||||
|
||||
For a CI follow-up, create a trigger once:
|
||||
|
||||
```text
|
||||
/trigger CI follow-up
|
||||
```
|
||||
|
||||
Then have your CI or webhook adapter call:
|
||||
|
||||
```bash
|
||||
nanobot trigger <trigger-id> "Build failed on main. Inspect the logs and suggest the next fix."
|
||||
```
|
||||
|
||||
For a local report script:
|
||||
|
||||
```bash
|
||||
generate-report | nanobot trigger <trigger-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If an automation does not run, check that `nanobot gateway` is running, the
|
||||
automation is enabled, and it was created from a linked chat/session.
|
||||
|
||||
If a local trigger waits forever, confirm the command uses the same workspace or
|
||||
config as the gateway.
|
||||
|
||||
If a trigger message appears twice after a restart, treat it as expected
|
||||
at-least-once delivery and make the external message idempotent.
|
||||
|
||||
If you need to edit, pause, resume, rename, delete, or inspect automations, use
|
||||
the WebUI Automations view.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [`webui.md#automations`](./webui.md#automations) for the browser management view
|
||||
- [`chat-commands.md#local-triggers`](./chat-commands.md#local-triggers) for `/trigger`
|
||||
- [`cli-reference.md#local-triggers`](./cli-reference.md#local-triggers) for `nanobot trigger`
|
||||
- [`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat) for heartbeat settings
|
||||
- [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) for long-running agent work
|
||||
@@ -0,0 +1,793 @@
|
||||
# Channel Package Guide
|
||||
|
||||
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
|
||||
|
||||
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
|
||||
|
||||
## How It Works
|
||||
|
||||
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
|
||||
|
||||
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||
|
||||
## Ownership and Sources of Truth
|
||||
|
||||
| Concern | Owner and source of truth |
|
||||
|---------|---------------------------|
|
||||
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
|
||||
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
|
||||
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
|
||||
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
|
||||
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
|
||||
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
|
||||
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
|
||||
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
|
||||
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
|
||||
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
|
||||
|
||||
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
|
||||
|
||||
## Quick Start
|
||||
|
||||
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nanobot/channels/webhook/
|
||||
├── __init__.py # lightweight package marker; do not import the runtime
|
||||
├── manifest.py # dependency-free ChannelPlugin descriptor
|
||||
├── runtime.py # channel implementation and optional SDK imports
|
||||
├── tests/ # package-local tests
|
||||
└── webui/ # optional settings UI and translations
|
||||
```
|
||||
|
||||
### 1. Create Your Channel
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/__init__.py
|
||||
"""Webhook channel package."""
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/manifest.py
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.runtime:WebhookChannel",
|
||||
dependencies=("aiohttp>=3.9.0,<4.0.0",),
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot/channels/webhook/runtime.py
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start an HTTP server that listens for incoming messages.
|
||||
|
||||
IMPORTANT: start() must block forever (or until stop() is called).
|
||||
If it returns, the channel is considered dead.
|
||||
"""
|
||||
self._running = True
|
||||
port = self.config.port
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/message", self._on_request)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
logger.info("Webhook listening on :{}", port)
|
||||
|
||||
# Block until stopped
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await runner.cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message.
|
||||
|
||||
msg.content — markdown text (convert to platform format as needed)
|
||||
msg.media — list of local file paths to attach
|
||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||
msg.metadata — channel routing context such as message/thread ids
|
||||
msg.event — typed runtime event for progress/status messages
|
||||
"""
|
||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||
|
||||
async def _on_request(self, request: web.Request) -> web.Response:
|
||||
"""Handle an incoming HTTP POST."""
|
||||
body = await request.json()
|
||||
sender = body.get("sender", "unknown")
|
||||
chat_id = body.get("chat_id", sender)
|
||||
text = body.get("text", "")
|
||||
media = body.get("media", []) # list of URLs
|
||||
|
||||
# This is the key call: validates allowFrom, then puts the
|
||||
# message onto the bus for the agent to process.
|
||||
await self._handle_message(
|
||||
sender_id=sender,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
media=media,
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
```
|
||||
|
||||
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
|
||||
|
||||
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
nanobot plugins list # verify the channel package appears as "webhook"
|
||||
nanobot onboard # add default config for detected channels
|
||||
```
|
||||
|
||||
Edit `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
|
||||
|
||||
### 3. Run & Test
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9000/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||
```
|
||||
|
||||
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||
|
||||
## Channel Package Requirements
|
||||
|
||||
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
|
||||
|
||||
### Package Layout
|
||||
|
||||
```text
|
||||
nanobot/channels/<channel>/
|
||||
├── __init__.py # package marker only; no runtime or SDK imports
|
||||
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
|
||||
├── config.py # optional dependency-free config model and defaults
|
||||
├── connect.py # optional interactive setup connector
|
||||
├── instances.py # optional dependency-free multi-instance management adapter
|
||||
├── state.py # optional persisted login-state detection
|
||||
├── validation.py # optional package-owned setup checks
|
||||
├── runtime.py # BaseChannel implementation and platform SDK imports
|
||||
├── tests/ # channel-specific Python tests
|
||||
└── webui/ # optional, compiled into the shared WebUI
|
||||
├── index.ts or index.tsx # structure and optional React components
|
||||
└── locales/
|
||||
├── en.json # canonical locale shape
|
||||
└── <locale>.json # one file for every supported WebUI locale
|
||||
```
|
||||
|
||||
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
|
||||
|
||||
### Manifest and Runtime Boundary
|
||||
|
||||
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
|
||||
|
||||
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, dependency requirements, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
|
||||
|
||||
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
|
||||
|
||||
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
|
||||
|
||||
### Package-owned WebUI
|
||||
|
||||
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
|
||||
|
||||
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
|
||||
|
||||
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
|
||||
|
||||
### Channel-owned i18n
|
||||
|
||||
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Use nanobot from Example chats.",
|
||||
"requirements": "Example app credentials and gateway",
|
||||
"setup": {
|
||||
"docsLabel": "Open Example setup",
|
||||
"officialLabel": "Open Example console",
|
||||
"summary": "Example needs app credentials.",
|
||||
"tryIt": "Send a test message.",
|
||||
"steps": [
|
||||
"Create an Example app.",
|
||||
"Add the credentials.",
|
||||
"Save, enable, and test the channel."
|
||||
],
|
||||
"fields": {
|
||||
"clientId": {
|
||||
"label": "Client ID",
|
||||
"placeholder": "Example client ID",
|
||||
"help": "Copy it from the Example console."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"copyManifest": "Copy manifest"
|
||||
},
|
||||
"presets": {
|
||||
"default": "Default"
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"connected": "{{name}} is connected."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
|
||||
|
||||
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
|
||||
|
||||
The dependency direction is intentional:
|
||||
|
||||
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
|
||||
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
|
||||
- Settings components may consume both the UI registry and locale registry.
|
||||
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
|
||||
|
||||
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
|
||||
|
||||
### Tests and Definition of Done
|
||||
|
||||
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
|
||||
|
||||
For a focused channel change, run the smallest relevant set:
|
||||
|
||||
```bash
|
||||
uv run pytest nanobot/channels/<channel>/tests -q
|
||||
|
||||
cd webui
|
||||
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
Before considering the change complete, verify all of the following:
|
||||
|
||||
- The manifest can be discovered without importing the runtime or optional platform SDK.
|
||||
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
|
||||
- The TypeScript field, action, and preset IDs have matching English locale messages.
|
||||
- Every supported locale matches the English key shape and interpolation variables.
|
||||
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
|
||||
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
|
||||
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
|
||||
|
||||
## BaseChannel API
|
||||
|
||||
### Required (abstract)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. Raise when the transport does not accept it. |
|
||||
|
||||
#### Outbound delivery contract
|
||||
|
||||
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
|
||||
|
||||
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### Provided by Base
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
|
||||
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
|
||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional management contract
|
||||
|
||||
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
|
||||
|
||||
```python
|
||||
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
|
||||
from nanobot.channels.plugin import ChannelPlugin
|
||||
|
||||
from .instances import MANAGEMENT
|
||||
|
||||
PLUGIN = ChannelPlugin(
|
||||
name="webhook",
|
||||
display_name="Webhook",
|
||||
runtime=f"{__package__}.channel:WebhookChannel",
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"token": ChannelFieldSpec(kind="secret"),
|
||||
"region": ChannelFieldSpec(
|
||||
kind="enum",
|
||||
choices=frozenset({"us", "eu"}),
|
||||
default="us",
|
||||
),
|
||||
},
|
||||
required=(SetupRequirement.field("token"),),
|
||||
),
|
||||
management=MANAGEMENT,
|
||||
)
|
||||
```
|
||||
|
||||
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
||||
|
||||
from .config import default_config
|
||||
|
||||
|
||||
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
|
||||
... # Expand the persisted channel-owned envelope.
|
||||
|
||||
|
||||
def update_instance_config(
|
||||
section: Any,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
instance_id: str = "default",
|
||||
) -> dict[str, Any]:
|
||||
... # Update one instance without discarding sibling data.
|
||||
|
||||
|
||||
MANAGEMENT = ChannelManagementSpec(
|
||||
multi_instance=True,
|
||||
default_config=default_config,
|
||||
instance_specs=instance_specs,
|
||||
update_instance_config=update_instance_config,
|
||||
)
|
||||
```
|
||||
|
||||
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
|
||||
|
||||
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
|
||||
|
||||
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
|
||||
|
||||
- every `instance_id` is non-empty and unique;
|
||||
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
|
||||
- runtime names cannot overwrite a runtime already owned by another channel;
|
||||
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
|
||||
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
|
||||
|
||||
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
|
||||
|
||||
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
|
||||
|
||||
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
|
||||
|
||||
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### Message Types
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
channel: str # your channel name
|
||||
chat_id: str # recipient (same value you passed to _handle_message)
|
||||
content: str # markdown text — convert to platform format as needed
|
||||
media: list[str] # local file paths to attach (images, audio, docs)
|
||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||
```
|
||||
|
||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||
|
||||
## Streaming Support
|
||||
|
||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
stream_end: bool = False,
|
||||
resuming: bool = False,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
if stream_end:
|
||||
text = self._buffers.pop(buffer_key, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(buffer_key, "")
|
||||
self._buffers[buffer_key] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||
|
||||
```python
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = msg.event
|
||||
|
||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if isinstance(event, ProgressEvent):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
stream_id: str | None = None,
|
||||
) -> None:
|
||||
buffer_key = stream_id or chat_id
|
||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning arguments:**
|
||||
|
||||
| Argument | Meaning |
|
||||
|------|---------|
|
||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
||||
|
||||
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
||||
|
||||
2. Convert `dict` → model in `__init__`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
```
|
||||
|
||||
3. Access config as attributes (not `.get()`):
|
||||
|
||||
```python
|
||||
async def start(self) -> None:
|
||||
port = self.config.port
|
||||
token = self.config.token
|
||||
```
|
||||
|
||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||
|
||||
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
|
||||
|
||||
```python
|
||||
setup=ChannelSetupSpec(
|
||||
fields={
|
||||
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
|
||||
| Manifest name | `{name}` | `webhook` |
|
||||
| Config section | `channels.{name}` | `channels.webhook` |
|
||||
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show the package as "webhook"
|
||||
nanobot plugins enable webhook
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Type Enabled
|
||||
discord channel no
|
||||
telegram channel yes
|
||||
webhook channel yes
|
||||
```
|
||||
@@ -1,550 +0,0 @@
|
||||
# Channel Plugin Guide
|
||||
|
||||
Build a custom nanobot channel in three steps: subclass, package, install.
|
||||
|
||||
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
|
||||
|
||||
## How It Works
|
||||
|
||||
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
|
||||
|
||||
1. Built-in channels in `nanobot/channels/`
|
||||
2. External packages registered under the `nanobot.channels` entry point group
|
||||
|
||||
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||
|
||||
## Quick Start
|
||||
|
||||
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```text
|
||||
nanobot-channel-webhook/
|
||||
├── nanobot_channel_webhook/
|
||||
│ ├── __init__.py # re-export WebhookChannel
|
||||
│ └── channel.py # channel implementation
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
### 1. Create Your Channel
|
||||
|
||||
```python
|
||||
# nanobot_channel_webhook/__init__.py
|
||||
from nanobot_channel_webhook.channel import WebhookChannel
|
||||
|
||||
__all__ = ["WebhookChannel"]
|
||||
```
|
||||
|
||||
```python
|
||||
# nanobot_channel_webhook/channel.py
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start an HTTP server that listens for incoming messages.
|
||||
|
||||
IMPORTANT: start() must block forever (or until stop() is called).
|
||||
If it returns, the channel is considered dead.
|
||||
"""
|
||||
self._running = True
|
||||
port = self.config.port
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/message", self._on_request)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
logger.info("Webhook listening on :{}", port)
|
||||
|
||||
# Block until stopped
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await runner.cleanup()
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Deliver an outbound message.
|
||||
|
||||
msg.content — markdown text (convert to platform format as needed)
|
||||
msg.media — list of local file paths to attach
|
||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||
msg.metadata — may contain "_progress": True for streaming chunks
|
||||
"""
|
||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||
|
||||
async def _on_request(self, request: web.Request) -> web.Response:
|
||||
"""Handle an incoming HTTP POST."""
|
||||
body = await request.json()
|
||||
sender = body.get("sender", "unknown")
|
||||
chat_id = body.get("chat_id", sender)
|
||||
text = body.get("text", "")
|
||||
media = body.get("media", []) # list of URLs
|
||||
|
||||
# This is the key call: validates allowFrom, then puts the
|
||||
# message onto the bus for the agent to process.
|
||||
await self._handle_message(
|
||||
sender_id=sender,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
media=media,
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True})
|
||||
```
|
||||
|
||||
### 2. Register the Entry Point
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[project]
|
||||
name = "nanobot-channel-webhook"
|
||||
version = "0.1.0"
|
||||
dependencies = ["nanobot-ai", "aiohttp"]
|
||||
|
||||
[project.entry-points."nanobot.channels"]
|
||||
webhook = "nanobot_channel_webhook:WebhookChannel"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["nanobot_channel_webhook"]
|
||||
```
|
||||
|
||||
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
|
||||
|
||||
### 3. Install & Configure
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # verify "Webhook" shows as "plugin"
|
||||
nanobot onboard # auto-adds default config for detected plugins
|
||||
```
|
||||
|
||||
Edit `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"port": 9000,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Run & Test
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9000/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||
```
|
||||
|
||||
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||
|
||||
## BaseChannel API
|
||||
|
||||
### Required (abstract)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
|
||||
|
||||
### Interactive Login
|
||||
|
||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||
|
||||
```python
|
||||
async def login(self, force: bool = False) -> bool:
|
||||
"""
|
||||
Perform channel-specific interactive login.
|
||||
|
||||
Args:
|
||||
force: If True, ignore existing credentials and re-authenticate.
|
||||
|
||||
Returns True if already authenticated or login succeeds.
|
||||
"""
|
||||
# For QR-code-based login:
|
||||
# 1. If force, clear saved credentials
|
||||
# 2. Check if already authenticated (load from disk/state)
|
||||
# 3. If not, show QR code and poll for confirmation
|
||||
# 4. Save token on success
|
||||
```
|
||||
|
||||
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
||||
|
||||
Users trigger interactive login via:
|
||||
```bash
|
||||
nanobot channels login <channel_name>
|
||||
nanobot channels login <channel_name> --force # re-authenticate
|
||||
```
|
||||
|
||||
### Provided by Base
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||
| `is_running` | Returns `self._running`. |
|
||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||
|
||||
### Optional (streaming)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||
|
||||
### Message Types
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class OutboundMessage:
|
||||
channel: str # your channel name
|
||||
chat_id: str # recipient (same value you passed to _handle_message)
|
||||
content: str # markdown text — convert to platform format as needed
|
||||
media: list[str] # local file paths to attach (images, audio, docs)
|
||||
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
|
||||
# "message_id" for reply threading
|
||||
```
|
||||
|
||||
## Streaming Support
|
||||
|
||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||
|
||||
### How It Works
|
||||
|
||||
When **both** conditions are met, the agent streams content through your channel:
|
||||
|
||||
1. Config has `"streaming": true`
|
||||
2. Your subclass overrides `send_delta()`
|
||||
|
||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||
|
||||
### Implementing `send_delta`
|
||||
|
||||
Override `send_delta` to handle two types of calls:
|
||||
|
||||
```python
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
meta = metadata or {}
|
||||
|
||||
if meta.get("_stream_end"):
|
||||
# Streaming finished — do final formatting, cleanup, etc.
|
||||
return
|
||||
|
||||
# Regular delta — append text, update the message on screen
|
||||
# delta contains a small chunk of text (a few tokens)
|
||||
```
|
||||
|
||||
**Metadata flags:**
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `_stream_delta: True` | A content chunk (delta contains the new text) |
|
||||
| `_stream_end: True` | Streaming finished (delta is empty) |
|
||||
|
||||
### Example: Webhook with Streaming
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._buffers: dict[str, str] = {}
|
||||
|
||||
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
meta = metadata or {}
|
||||
if meta.get("_stream_end"):
|
||||
text = self._buffers.pop(chat_id, "")
|
||||
# Final delivery — format and send the complete message
|
||||
await self._deliver(chat_id, text, final=True)
|
||||
return
|
||||
|
||||
self._buffers.setdefault(chat_id, "")
|
||||
self._buffers[chat_id] += delta
|
||||
# Incremental update — push partial text to the client
|
||||
await self._deliver(chat_id, self._buffers[chat_id], final=False)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
# Non-streaming path — unchanged
|
||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Enable streaming per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"streaming": true,
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||
|
||||
### BaseChannel Streaming API
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
|
||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||
|
||||
## Progress, Tool Hints, and Reasoning
|
||||
|
||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||
|
||||
### Progress and Tool Hints
|
||||
|
||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
|
||||
|
||||
```python
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
meta = msg.metadata or {}
|
||||
|
||||
if meta.get("_tool_hint"):
|
||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||
return
|
||||
|
||||
if meta.get("_progress"):
|
||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||
return
|
||||
|
||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||
```
|
||||
|
||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"sendToolHints": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"sendToolHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reasoning Blocks
|
||||
|
||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||
|
||||
```python
|
||||
class WebhookChannel(BaseChannel):
|
||||
name = "webhook"
|
||||
display_name = "Webhook"
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
self._reasoning_buffers: dict[str, str] = {}
|
||||
|
||||
async def send_reasoning_delta(
|
||||
self,
|
||||
chat_id: str,
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
meta = metadata or {}
|
||||
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
|
||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
|
||||
|
||||
async def send_reasoning_end(
|
||||
self,
|
||||
chat_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
meta = metadata or {}
|
||||
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||
text = self._reasoning_buffers.pop(stream_id, "")
|
||||
if text:
|
||||
await self._update_reasoning_block(chat_id, text, final=True)
|
||||
```
|
||||
|
||||
**Reasoning metadata flags:**
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
|
||||
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
|
||||
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
|
||||
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||
|
||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"showReasoning": true,
|
||||
"webhook": {
|
||||
"enabled": true,
|
||||
"showReasoning": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Recommended rendering:
|
||||
|
||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||
|
||||
## Config
|
||||
|
||||
### Why Pydantic model is required
|
||||
|
||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
||||
|
||||
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
class WebhookConfig(Base):
|
||||
"""Webhook channel configuration."""
|
||||
enabled: bool = False
|
||||
port: int = 9000
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
||||
|
||||
2. Convert `dict` → model in `__init__`:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
class WebhookChannel(BaseChannel):
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = WebhookConfig(**config)
|
||||
super().__init__(config, bus)
|
||||
```
|
||||
|
||||
3. Access config as attributes (not `.get()`):
|
||||
|
||||
```python
|
||||
async def start(self) -> None:
|
||||
port = self.config.port
|
||||
token = self.config.token
|
||||
```
|
||||
|
||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||
|
||||
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebhookConfig().model_dump(by_alias=True)
|
||||
```
|
||||
|
||||
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
|
||||
|
||||
If not overridden, the base class returns `{"enabled": false}`.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
|
||||
| Entry point key | `{name}` | `webhook` |
|
||||
| Config section | `channels.{name}` | `channels.webhook` |
|
||||
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/you/nanobot-channel-webhook
|
||||
cd nanobot-channel-webhook
|
||||
python -m pip install -e .
|
||||
nanobot plugins list # should show "Webhook" as "plugin"
|
||||
nanobot gateway # test end-to-end
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
$ nanobot plugins list
|
||||
|
||||
Name Source Enabled
|
||||
telegram builtin yes
|
||||
discord builtin no
|
||||
webhook plugin yes
|
||||
```
|
||||
+171
-29
@@ -1,6 +1,22 @@
|
||||
# Chat Apps
|
||||
# Chat Apps for Self-Hosted AI Agents
|
||||
|
||||
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md).
|
||||
Connect nanobot to Telegram, Discord, Slack, WeChat, Email, Mattermost, and
|
||||
other chat platforms. This page is the full chat-channel reference. If you want
|
||||
a focused setup path for one platform, start with a guide:
|
||||
|
||||
| Platform | Guide |
|
||||
|---|---|
|
||||
| Telegram | [Build a Telegram AI Agent with nanobot](./guides/telegram-ai-agent.md) |
|
||||
| Discord | [Build a Discord AI Agent with nanobot](./guides/discord-ai-agent.md) |
|
||||
| Slack | [Build a Slack AI Agent with nanobot](./guides/slack-ai-agent.md) |
|
||||
| Feishu | [Build a Feishu AI Agent with nanobot](./guides/feishu-ai-agent.md) |
|
||||
| WhatsApp | [Build a WhatsApp AI Agent with nanobot](./guides/whatsapp-ai-agent.md) |
|
||||
| WeChat | [Build a WeChat AI Agent with nanobot](./guides/wechat-ai-agent.md) |
|
||||
| QQ | [Build a QQ AI Agent with nanobot](./guides/qq-ai-agent.md) |
|
||||
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
|
||||
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
|
||||
|
||||
Want to build your own channel? See the [Channel Package Guide](./channel-package-guide.md).
|
||||
|
||||
Before configuring a chat app, make sure the local CLI path works:
|
||||
|
||||
@@ -10,33 +26,67 @@ nanobot agent -m "Hello!"
|
||||
|
||||
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
||||
|
||||
Most examples below are snippets to merge into `~/.nanobot/config.json`.
|
||||
## Recommended Setup in the WebUI
|
||||
|
||||
## Common Setup Pattern
|
||||
For normal local setup, let the WebUI write and validate the channel config:
|
||||
|
||||
1. Run `nanobot webui`.
|
||||
2. Open **Settings → Channels**.
|
||||
3. Search for the platform and open its setup panel.
|
||||
4. Follow the credential fields or QR flow. The screen tells you which platform-side token, permission, account, or URL it needs.
|
||||
5. Let nanobot install the optional channel support when prompted.
|
||||
6. Restart from the WebUI if it reports that a restart is required.
|
||||
7. Send a private test message. If the channel returns a pairing code, approve the pending request in the WebUI and send the message again.
|
||||
|
||||
If your installed stable release does not show **Settings → Channels**, continue with the [manual setup pattern](#manual-setup-pattern) below or install current source.
|
||||
|
||||
Optional package installation is available to a same-machine WebUI by default. Remote browser clients cannot change the Python environment unless an administrator explicitly enables that capability. Run `nanobot plugins enable <channel>` locally when the guided install is unavailable.
|
||||
|
||||
The sections below explain what each chat platform requires and provide manual config for deployments that manage `config.json` directly.
|
||||
|
||||
> [!NOTE]
|
||||
> If you are upgrading from a version where chat app SDKs were installed by default,
|
||||
> enable the channel in the same Python environment so nanobot installs its
|
||||
> manifest-declared dependencies:
|
||||
>
|
||||
> ```bash
|
||||
> nanobot plugins enable <channel>
|
||||
> ```
|
||||
>
|
||||
> Replace `<channel>` with names such as `telegram`, `slack`, `feishu`,
|
||||
> `dingtalk`, `matrix`, `qq`, `napcat`, `weixin`, `wecom`, or `msteams`.
|
||||
> To turn a channel off later, run `nanobot plugins disable <channel>`.
|
||||
> nanobot keeps the saved settings, but stops loading that channel after the
|
||||
> next restart.
|
||||
|
||||
## Manual Setup Pattern
|
||||
|
||||
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a snippet includes `allowFrom`, it is showing a static allowlist. For pairing-based access on supported channels, omit `allowFrom`; Slack and Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing codes.
|
||||
|
||||
Every chat app uses the same shape:
|
||||
|
||||
1. Create or prepare the bot/account in the chat platform.
|
||||
2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you.
|
||||
3. Merge that platform's JSON snippet into `~/.nanobot/config.json`.
|
||||
4. Keep access control narrow at first with `allowFrom` or the platform-specific allow list.
|
||||
5. Check that nanobot can see the configured channel:
|
||||
4. Prefer pairing for DM-capable channels: omit `allowFrom`, let the first DM receive a pairing code, then approve it with `/pairing approve <code>`.
|
||||
5. For channels without pairing, such as Email, keep access narrow with `allowFrom` or the platform-specific allow list.
|
||||
6. Check that nanobot can see the configured channel:
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
```
|
||||
|
||||
6. Start the gateway and leave that terminal running:
|
||||
7. Start the gateway and leave that terminal running:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
7. Send a message from the allowed account. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
|
||||
8. Send a test DM. If the bot returns a pairing code, approve it and send the message again. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
|
||||
|
||||
If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists.
|
||||
|
||||
> `["*"]` allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
|
||||
> `allowFrom: ["*"]` bypasses pairing and allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
|
||||
|
||||
| Channel | What you need |
|
||||
|---------|---------------|
|
||||
@@ -44,7 +94,7 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
||||
| **Discord** | Bot token + Message Content intent |
|
||||
| **WhatsApp** | QR code scan (`nanobot channels login whatsapp`) |
|
||||
| **WeChat (Weixin)** | QR code scan (`nanobot channels login weixin`) |
|
||||
| **Feishu** | App ID + App Secret |
|
||||
| **Feishu** | QR code scan (`nanobot channels login feishu`) or App ID + App Secret |
|
||||
| **DingTalk** | App Key + App Secret |
|
||||
| **Slack** | Bot token + App-Level token |
|
||||
| **Matrix** | Homeserver URL + Access token |
|
||||
@@ -59,6 +109,12 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
||||
<details>
|
||||
<summary><b>Telegram</b></summary>
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable telegram
|
||||
```
|
||||
|
||||
**1. Create a bot**
|
||||
- Open Telegram, search `@BotFather`
|
||||
- Send `/newbot`, follow prompts
|
||||
@@ -79,6 +135,8 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
|
||||
```
|
||||
|
||||
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
|
||||
>
|
||||
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
|
||||
|
||||
|
||||
**3. Run**
|
||||
@@ -121,6 +179,14 @@ Telegram uses long polling by default. To receive updates through a webhook, exp
|
||||
|
||||
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
||||
|
||||
**Install the optional realtime dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable mochat
|
||||
```
|
||||
|
||||
Without these dependencies, Mochat still works through HTTP polling.
|
||||
|
||||
**1. Ask nanobot to set up Mochat for you**
|
||||
|
||||
Simply send this message to nanobot (replace `xxx@xxx` with your real email):
|
||||
@@ -231,14 +297,14 @@ nanobot gateway
|
||||
<details>
|
||||
<summary><b>Matrix (Element)</b></summary>
|
||||
|
||||
Install Matrix dependencies first:
|
||||
Enable Matrix support first:
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[matrix]"
|
||||
nanobot plugins enable matrix
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel and is skipped by the `matrix` extra on `sys_platform == 'win32'`. The command above will still succeed on Windows but without `matrix-nio` installed, so enabling the Matrix channel will fail at startup. Use macOS, Linux, or WSL2.
|
||||
> Matrix encryption is disabled by default on Windows because `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel. Use macOS, Linux, or WSL2 if you need Matrix E2EE.
|
||||
|
||||
**1. Create/choose a Matrix account**
|
||||
|
||||
@@ -301,9 +367,13 @@ nanobot gateway
|
||||
<details>
|
||||
<summary><b>WhatsApp</b></summary>
|
||||
|
||||
Requires **Node.js ≥18**.
|
||||
Requires the WhatsApp optional dependencies:
|
||||
|
||||
**1. Link device**
|
||||
```bash
|
||||
nanobot plugins enable whatsapp
|
||||
```
|
||||
|
||||
**1. Link device with QR**
|
||||
|
||||
```bash
|
||||
nanobot channels login whatsapp
|
||||
@@ -317,24 +387,58 @@ nanobot channels login whatsapp
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["+1234567890"]
|
||||
"allowFrom": ["1234567890"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run** (two terminals)
|
||||
For groups, `allowFrom` can contain either a participant sender ID/LID or a
|
||||
group JID/bare group ID. A participant entry allows that sender wherever the bot
|
||||
can see them; a group entry allows replies in that group.
|
||||
|
||||
Optional session database path:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Migrating from the old bridge**
|
||||
|
||||
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
|
||||
- Re-run `nanobot channels login whatsapp`; old Baileys bridge auth data is not reused by neonize.
|
||||
- Update `allowFrom` entries to the WhatsApp sender ID without a leading `+`.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
nanobot channels login whatsapp
|
||||
|
||||
# Terminal 2
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
> WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with:
|
||||
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
|
||||
**Optional: static LID mappings**
|
||||
|
||||
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
|
||||
learns LID to phone mappings at runtime when both identifiers are present, but you
|
||||
can also seed mappings up front so the phone number resolves from the
|
||||
very first message:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["1234567890"],
|
||||
"lidMappings": { "123456789012345": "1234567890" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -343,6 +447,20 @@ nanobot gateway
|
||||
|
||||
Uses **WebSocket** long connection — no public IP required.
|
||||
|
||||
**Quick setup: QR login**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable feishu
|
||||
nanobot channels login feishu
|
||||
# Use --force to create/sign in with a new bot
|
||||
```
|
||||
|
||||
Open the printed URL or scan the QR code with Feishu/Lark on your phone. If the optional `qrcode` package is installed, nanobot shows a terminal QR code; otherwise it prints the login URL. nanobot writes `appId`, `appSecret`, `domain`, and `enabled` under `channels.feishu` in the active config file. Use `--config <path>` to update a non-default config.
|
||||
|
||||
If QR login is unavailable for your account, use manual setup below.
|
||||
|
||||
**Manual setup**
|
||||
|
||||
**1. Create a Feishu bot**
|
||||
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
|
||||
- Create a new app → Enable **Bot** capability
|
||||
@@ -403,6 +521,12 @@ nanobot gateway
|
||||
|
||||
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable qq
|
||||
```
|
||||
|
||||
**1. Register & create bot**
|
||||
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
|
||||
- Create a new bot application
|
||||
@@ -455,6 +579,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
|
||||
- Copy the forward websocket server's token
|
||||
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable napcat
|
||||
```
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
@@ -492,6 +622,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
|
||||
|
||||
Uses **Stream Mode** — no public IP required.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable dingtalk
|
||||
```
|
||||
|
||||
**1. Create a DingTalk bot**
|
||||
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
|
||||
- Create a new app -> Add **Robot** capability
|
||||
@@ -534,6 +670,12 @@ nanobot gateway
|
||||
|
||||
Uses **Socket Mode** — no public URL required.
|
||||
|
||||
**Install the optional channel dependency**
|
||||
|
||||
```bash
|
||||
nanobot plugins enable slack
|
||||
```
|
||||
|
||||
**1. Create a Slack app**
|
||||
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
|
||||
- Pick a name and select your workspace
|
||||
@@ -644,10 +786,10 @@ nanobot gateway
|
||||
|
||||
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
|
||||
|
||||
**1. Install with WeChat support**
|
||||
**1. Enable WeChat support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[weixin]"
|
||||
nanobot plugins enable weixin
|
||||
```
|
||||
|
||||
**2. Configure**
|
||||
@@ -696,10 +838,10 @@ nanobot gateway
|
||||
>
|
||||
> Uses **WebSocket** long connection — no public IP required.
|
||||
|
||||
**1. Install the optional dependency**
|
||||
**1. Enable WeCom support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[wecom]"
|
||||
nanobot plugins enable wecom
|
||||
```
|
||||
|
||||
**2. Create a WeCom AI Bot**
|
||||
@@ -735,10 +877,10 @@ nanobot gateway
|
||||
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
|
||||
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
|
||||
|
||||
**1. Install the optional dependency**
|
||||
**1. Enable Microsoft Teams support**
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[msteams]"
|
||||
nanobot plugins enable msteams
|
||||
```
|
||||
|
||||
**2. Create a Teams / Azure bot app registration**
|
||||
|
||||
+72
-4
@@ -15,7 +15,11 @@ These commands work inside chat channels and interactive agent sessions:
|
||||
| `/dream-log <sha>` | Show a specific Dream memory change |
|
||||
| `/dream-restore` | List recent Dream memory versions |
|
||||
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
||||
| `/dream-prompt` | Show how Dream is being guided for memory |
|
||||
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
|
||||
| `/skill` | List enabled skills and their descriptions |
|
||||
| `/trigger` | Show local trigger usage |
|
||||
| `/trigger <name>` | Create a named local trigger for the current chat/session |
|
||||
| `/pairing` | List pending pairing requests |
|
||||
| `/pairing approve <code>` | Approve a pairing code |
|
||||
| `/pairing deny <code>` | Deny a pending pairing request |
|
||||
@@ -55,20 +59,84 @@ To switch presets for future turns:
|
||||
|
||||
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
||||
|
||||
## Local triggers
|
||||
|
||||
Use `/trigger <name>` when a local script or another service should be able to
|
||||
send a message into the current chat/session later. A name is required; plain
|
||||
`/trigger` only shows the usage hint.
|
||||
|
||||
Create the trigger from the chat where future messages should arrive:
|
||||
|
||||
```text
|
||||
/trigger PR review
|
||||
```
|
||||
|
||||
nanobot replies with a trigger ID and a command shaped like:
|
||||
|
||||
```bash
|
||||
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||
```
|
||||
|
||||
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
|
||||
trigger is bound to the session where it was created, so the message goes back
|
||||
to that same chat. Keep `nanobot gateway` running so trigger messages can be
|
||||
delivered. The trigger message starts an automation turn recorded in that
|
||||
session with the message you passed to the CLI; it is not treated as a normal
|
||||
user message. If that session is already running a turn, the trigger waits
|
||||
until the session is idle instead of being injected into the active turn.
|
||||
|
||||
Trigger deliveries are stored in the workspace until their linked agent turn
|
||||
finishes successfully. If the gateway exits after claiming a delivery but before
|
||||
the turn completes, the next gateway start requeues that delivery. This is an
|
||||
at-least-once local queue: a delivery may run more than once if the process
|
||||
exits at the wrong time, so external scripts should make repeated trigger
|
||||
messages safe. If the delivery reaches the agent and the agent turn fails, the
|
||||
delivery is marked failed in Automations instead of retrying forever.
|
||||
|
||||
For longer or generated content, omit the message argument and pipe stdin:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "Review the latest failed CI job" | nanobot trigger trg_8K4P2Q9X
|
||||
```
|
||||
|
||||
If an external webhook should wake nanobot up, run your own small webhook
|
||||
service and have it call the trigger command after it builds the final message:
|
||||
|
||||
```bash
|
||||
nanobot trigger <trigger-id> "<message>"
|
||||
```
|
||||
|
||||
If you run multiple nanobot instances, pass the same config or workspace
|
||||
selector used by the gateway:
|
||||
|
||||
```bash
|
||||
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
|
||||
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
|
||||
```
|
||||
|
||||
Manage triggers from the WebUI Automations view. You can search, pause/resume,
|
||||
rename, delete, and copy the trigger command there. A session may have multiple
|
||||
triggers, just like it may have multiple scheduled automations.
|
||||
|
||||
See [Automations](./automations.md) for how local triggers fit with scheduled
|
||||
automations, heartbeat, and gateway delivery.
|
||||
|
||||
## Periodic Tasks
|
||||
|
||||
Periodic tasks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently.
|
||||
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||
|
||||
Use heartbeat for recurring checks that should usually stay quiet. User-created cron jobs are different: they run as scheduled turns in the chat/session where they were created and normally deliver the result back to that channel.
|
||||
|
||||
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
||||
|
||||
```markdown
|
||||
## Active Tasks
|
||||
|
||||
- Check weather forecast and send a summary
|
||||
- Scan inbox for urgent emails
|
||||
- Check weather forecast and notify me only if storms are expected
|
||||
- Scan inbox for urgent emails and notify me if any are found
|
||||
```
|
||||
|
||||
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
|
||||
The agent can also manage this file itself - ask it to "add a periodic background check" or "check this periodically but only notify me if something changes" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
|
||||
|
||||
You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`:
|
||||
|
||||
|
||||
+147
-10
@@ -8,13 +8,17 @@ Use this page when you know what you want to run and need the command shape. For
|
||||
|---|---|---|
|
||||
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
|
||||
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
|
||||
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
|
||||
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
|
||||
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
|
||||
| Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
|
||||
| Check config without calling a model | `nanobot status` | Summarizes the selected config, workspace, active model, and providers |
|
||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running while those surfaces are in use |
|
||||
| Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
|
||||
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot |
|
||||
|
||||
@@ -46,13 +50,16 @@ nanobot gateway --verbose
|
||||
nanobot serve --verbose
|
||||
```
|
||||
|
||||
Long-running commands keep working until you stop them. Press `Ctrl+C` in that terminal to stop `nanobot gateway` or `nanobot serve`.
|
||||
Long-running commands keep working until you stop them. Press `Ctrl+C` in that terminal
|
||||
to stop foreground `nanobot gateway` or `nanobot serve`. If you started the gateway
|
||||
with `--background`, use `nanobot gateway stop`.
|
||||
|
||||
## Setup
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot onboard` | Initialize or refresh the default config and workspace |
|
||||
| `nanobot onboard --refresh` | Refresh an existing config without prompting, preserving existing values |
|
||||
| `nanobot onboard --wizard` | Use the interactive setup wizard |
|
||||
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
|
||||
|
||||
@@ -75,19 +82,57 @@ Default paths:
|
||||
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
|
||||
| `nanobot agent --logs` | Show runtime logs while chatting |
|
||||
|
||||
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
||||
|
||||
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## Gateway
|
||||
|
||||
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint.
|
||||
## WebUI
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot gateway` | Start the gateway with config defaults |
|
||||
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
|
||||
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
|
||||
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
|
||||
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
|
||||
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
|
||||
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; provider credentials still require interactive setup |
|
||||
|
||||
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
|
||||
|
||||
## Gateway
|
||||
|
||||
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot gateway` | Start the gateway in the foreground with config defaults |
|
||||
| `nanobot gateway --verbose` | Show verbose runtime output |
|
||||
| `nanobot gateway --port <port>` | Override `gateway.port` for the health endpoint |
|
||||
| `nanobot gateway --workspace <path>` | Override workspace |
|
||||
| `nanobot gateway --config <path>` | Use a specific config file |
|
||||
| `nanobot gateway --background` | Start the gateway as a background process |
|
||||
| `nanobot gateway status` | Show the recorded background gateway PID, state file, and log file |
|
||||
| `nanobot gateway logs --no-follow` | Print recent background gateway logs and exit |
|
||||
| `nanobot gateway logs` | Follow background gateway logs |
|
||||
| `nanobot gateway restart` | Restart the recorded background gateway with the current config |
|
||||
| `nanobot gateway stop` | Stop the recorded background gateway |
|
||||
| `nanobot gateway install-service` | Install a systemd user service or macOS LaunchAgent |
|
||||
| `nanobot gateway install-service --dry-run` | Preview the generated service file and system commands |
|
||||
| `nanobot gateway uninstall-service` | Remove the installed system service |
|
||||
|
||||
For custom instances, pass the same selector flags to management commands:
|
||||
|
||||
```bash
|
||||
nanobot gateway --background --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
nanobot gateway status --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
nanobot gateway stop --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
nanobot gateway install-service --config ./bot-a/config.json --workspace ./bot-a/workspace --name bot-a
|
||||
```
|
||||
|
||||
`--background` is a lightweight detached process. `install-service` is for
|
||||
login/startup integration: Linux uses a systemd user service; macOS uses a
|
||||
LaunchAgent plist. System services run the foreground gateway under the OS
|
||||
supervisor rather than nesting another background process.
|
||||
|
||||
Default health endpoint:
|
||||
|
||||
@@ -97,6 +142,55 @@ http://127.0.0.1:18790/health
|
||||
|
||||
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
|
||||
|
||||
## Local Triggers
|
||||
|
||||
`nanobot trigger` delivers one local message to a trigger that was created from
|
||||
a chat/session with `/trigger <name>`.
|
||||
|
||||
```bash
|
||||
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
||||
```
|
||||
|
||||
Keep `nanobot gateway` running so the message can be delivered to the linked
|
||||
chat/session. The message is recorded as an automation turn in that session,
|
||||
not as a normal chat message typed by the user.
|
||||
|
||||
The command writes to a workspace-local durable queue. If `nanobot gateway` is
|
||||
not running yet, the message waits in that workspace. If the target session is
|
||||
already running a turn, the trigger waits for that session to become idle. If the
|
||||
gateway exits after claiming a delivery but before the linked turn completes,
|
||||
the next gateway start requeues that delivery. The queue is at-least-once, not
|
||||
exactly-once, so the same message can be delivered again after an interrupted
|
||||
process. If the agent receives the delivery and the turn fails, the delivery is
|
||||
marked failed instead of retried indefinitely. Each delivery also writes an
|
||||
audit record under `<workspace>/triggers/runs`. Run one gateway consumer per
|
||||
workspace; this local queue is not a distributed multi-consumer queue.
|
||||
|
||||
Use stdin when another local process generates the message:
|
||||
|
||||
```bash
|
||||
generate-report | nanobot trigger trg_8K4P2Q9X
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot trigger <id> "message"` | Deliver one message through a trigger |
|
||||
| `nanobot trigger <id>` | Read the message from stdin |
|
||||
| `nanobot trigger --config <path> <id> "message"` | Use the workspace from a specific config |
|
||||
| `nanobot trigger --workspace <path> <id> "message"` | Use a specific workspace |
|
||||
|
||||
Triggers are managed in the WebUI Automations view instead of through separate
|
||||
`list`, `revoke`, or `delete` CLI subcommands. From there you can pause/resume,
|
||||
rename, delete, search, and copy the command for each trigger.
|
||||
|
||||
For webhooks or other external systems, run your own small service and have it
|
||||
call this CLI after it decides what message nanobot should receive.
|
||||
|
||||
See [Automations](./automations.md) for the broader automation model, WebUI
|
||||
management, and delivery behavior.
|
||||
|
||||
## OpenAI-Compatible API
|
||||
|
||||
| Command | Description |
|
||||
@@ -115,6 +209,8 @@ Default API endpoint:
|
||||
http://127.0.0.1:8900
|
||||
```
|
||||
|
||||
Public binds (`0.0.0.0` or `::`) require `api.apiKey`; send it as a Bearer token on API routes.
|
||||
|
||||
See [`openai-api.md`](./openai-api.md) for request examples.
|
||||
|
||||
## Status
|
||||
@@ -123,7 +219,13 @@ See [`openai-api.md`](./openai-api.md) for request examples.
|
||||
nanobot status
|
||||
```
|
||||
|
||||
Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance.
|
||||
Shows the config path, workspace path, active model, and provider summary without calling a model.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot status` | Inspect the default instance |
|
||||
| `nanobot status --config <path>` | Inspect a specific config |
|
||||
| `nanobot status --config <path> --workspace <path>` | Inspect a specific config with a workspace override |
|
||||
|
||||
## Channels
|
||||
|
||||
@@ -134,6 +236,7 @@ Shows the default config path, workspace path, active model, and provider summar
|
||||
| `nanobot channels login <channel>` | Run interactive login for supported channels |
|
||||
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
|
||||
| `nanobot channels login <channel> --config <path>` | Use a specific config file |
|
||||
| `nanobot plugins list --config <path>` | Show plugin/channel enabled state for a specific config |
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -145,12 +248,46 @@ nanobot channels status
|
||||
|
||||
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
||||
|
||||
## Optional Features
|
||||
|
||||
Use these commands when you want nanobot to add or remove a built-in capability
|
||||
without hand-editing JSON. Enabling may install the support package first.
|
||||
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
|
||||
saved settings and turns the channel off.
|
||||
|
||||
The `plugins` command name is retained for compatibility, but these entries are
|
||||
nanobot runtime support packages, not the user-invokable tools shown in WebUI
|
||||
Apps. They cannot be attached to a chat turn with `@`.
|
||||
|
||||
| Feature name | What it enables |
|
||||
|---|---|
|
||||
| `api` | Dependencies required by the OpenAI-compatible `nanobot serve` process |
|
||||
| `azure` | Azure identity support for Azure-hosted models |
|
||||
| `bedrock` | AWS Bedrock model provider support |
|
||||
| `langfuse` | Langfuse tracing support for OpenAI-compatible providers |
|
||||
| `olostep` | Olostep web search provider support |
|
||||
| A channel name such as `telegram` or `slack` | The connector package and saved channel enablement |
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot plugins list` | Show available channels and optional capabilities |
|
||||
| `nanobot plugins enable <name>` | Install missing support and enable the feature or channel |
|
||||
| `nanobot plugins enable <name> --logs` | Show package install logs while enabling |
|
||||
| `nanobot plugins disable <channel>` | Turn off a channel without deleting its saved settings |
|
||||
| `nanobot plugins list --config <path>` | Read a specific config file |
|
||||
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
|
||||
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
|
||||
|
||||
Document and PDF reading are included in the standard installation. The old
|
||||
`nanobot plugins enable documents` and `nanobot plugins enable pdf` commands
|
||||
remain accepted as no-op compatibility aliases.
|
||||
|
||||
## Provider OAuth
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot provider login openai-codex` | Authenticate OpenAI Codex provider |
|
||||
| `nanobot provider login github-copilot` | Authenticate GitHub Copilot provider |
|
||||
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
|
||||
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
|
||||
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
|
||||
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
|
||||
|
||||
|
||||
+22
-7
@@ -12,7 +12,7 @@ nanobot has one small core loop and several ways to enter it:
|
||||
|---|---|
|
||||
| Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies |
|
||||
| Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs |
|
||||
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, and others |
|
||||
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, Mattermost, and others |
|
||||
| Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents |
|
||||
| Memory | Workspace files and session history that keep useful context across turns |
|
||||
| Gateway | Long-running process that connects enabled channels and serves the health endpoint |
|
||||
@@ -64,9 +64,9 @@ That flow is the same whether the message starts in the CLI, WebUI, Telegram, Di
|
||||
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
|
||||
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
|
||||
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
|
||||
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
|
||||
| WebUI | `nanobot webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
|
||||
|
||||
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint.
|
||||
The WebUI launcher is the normal browser entry point. Underneath, the gateway keeps the WebSocket channel and other long-running services alive. The gateway health endpoint is on `gateway.port` (`18790` by default); the browser WebUI is served on `8765` by default, not by the health endpoint.
|
||||
|
||||
## Provider and Model Selection
|
||||
|
||||
@@ -123,7 +123,7 @@ Tools are discovered automatically from built-in modules and plugin entry points
|
||||
- shell execution with configurable sandboxing;
|
||||
- web search and web fetch with SSRF checks;
|
||||
- MCP servers;
|
||||
- cron reminders and heartbeat tasks;
|
||||
- cron reminders, local triggers, and heartbeat tasks;
|
||||
- image generation;
|
||||
- subagents and runtime self-inspection.
|
||||
|
||||
@@ -131,14 +131,29 @@ Security-sensitive controls live in [`configuration.md#security`](./configuratio
|
||||
|
||||
## Background Jobs
|
||||
|
||||
When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<workspace>/cron/jobs.json` and registers system jobs:
|
||||
When `nanobot gateway` starts, it runs workspace-scoped automations and
|
||||
registers system jobs:
|
||||
|
||||
- `dream`, when `agents.defaults.dream.enabled` is true;
|
||||
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
|
||||
|
||||
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends useful results to the most recently active chat target.
|
||||
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
|
||||
|
||||
User-created reminders use the same cron service but are not the same as the protected heartbeat system job.
|
||||
User-created reminders use the same cron service but are not the same as the
|
||||
protected heartbeat system job. They run as scheduled turns in their origin
|
||||
chat/session and normally deliver the result back to that channel.
|
||||
|
||||
Local triggers are also session-bound, but they do not have their own
|
||||
schedule. Create one from the target chat with `/trigger <name>`, then call
|
||||
`nanobot trigger <id> "<message>"` when a local script or external service wants
|
||||
nanobot to respond in that session. Webhook servers, third-party auth, and
|
||||
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
|
||||
in the workspace until the linked agent turn finishes successfully. If the
|
||||
target session is busy, the trigger waits until that session is idle instead of
|
||||
being injected into the active turn. The message is recorded as an automation
|
||||
turn in that session. Delivery is at-least-once, so external systems should
|
||||
tolerate repeated trigger messages; a delivery that reaches the agent but fails
|
||||
is marked failed rather than retried forever.
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
|
||||
+276
-55
@@ -4,6 +4,8 @@ Config file: `~/.nanobot/config.json`
|
||||
|
||||
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
|
||||
|
||||
For normal local use, prefer the WebUI before editing JSON: **Settings → Models** manages model choices and provider credentials, **Settings → Channels** guides chat-platform setup, other Settings pages cover built-in capabilities, and **Apps** manages CLI App and MCP integrations. Edit `config.json` directly when you need an advanced field, automate deployment, or intentionally manage configuration as code.
|
||||
|
||||
The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md).
|
||||
|
||||
The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
|
||||
@@ -11,13 +13,29 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
|
||||
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
|
||||
|
||||
> [!NOTE]
|
||||
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
||||
> If your config file is older than the current schema, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
|
||||
|
||||
## Configuration Guides
|
||||
|
||||
This page is the complete configuration reference. For task-oriented setup, use
|
||||
the focused guides first and come back here for exact fields and defaults.
|
||||
|
||||
| Task | Guide |
|
||||
|---|---|
|
||||
| Add MCP tools | [`guides/configure-mcp-tools.md`](./guides/configure-mcp-tools.md) |
|
||||
| Enable web search and web fetch | [`guides/configure-web-search.md`](./guides/configure-web-search.md) |
|
||||
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
|
||||
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
|
||||
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
|
||||
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
|
||||
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||
|
||||
## Quick Jump
|
||||
|
||||
| Need | Section |
|
||||
|---|---|
|
||||
| Keep secrets out of `config.json` | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
||||
| Tune process-level behavior with env vars | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||
| Trace model calls | [Langfuse Observability](#langfuse-observability) |
|
||||
| Configure credentials and endpoints | [Providers](#providers) |
|
||||
| Name and switch model choices | [Model Presets](#model-presets) |
|
||||
@@ -31,23 +49,24 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
|
||||
| Control access and pairing | [Pairing](#pairing) |
|
||||
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
||||
|
||||
## Where to Edit First
|
||||
## Where a Setting Lives
|
||||
|
||||
If you are not sure where a setting belongs, start from the task you are trying to complete. Most changes touch one config section and one verification command.
|
||||
If the WebUI does not expose the option you need, start from the task below. Most advanced changes touch one config section and one verification command.
|
||||
|
||||
| Task | First keys to check | Verify with | Deep dive |
|
||||
|---|---|---|---|
|
||||
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
|
||||
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
|
||||
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
|
||||
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot gateway`, then open `http://127.0.0.1:8765` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
|
||||
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
|
||||
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot webui` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
|
||||
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, optional pairing or `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
|
||||
| Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) |
|
||||
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
|
||||
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
||||
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
||||
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
||||
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
||||
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
||||
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
|
||||
|
||||
## Environment Variables for Secrets
|
||||
@@ -159,6 +178,38 @@ ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
|
||||
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
|
||||
```
|
||||
|
||||
## Runtime Environment Variables
|
||||
|
||||
These variables are process-level switches. Set them in the same terminal, service unit, container, or supervisor that starts nanobot.
|
||||
|
||||
### Runtime controls
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
|
||||
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
||||
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
|
||||
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
|
||||
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
|
||||
| `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` | `unknown` | Display label for the external workspace sandbox when `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` is truthy, for example `macos_app_sandbox` or `bwrap`. |
|
||||
| `NANOBOT_SANDBOX_ENFORCED` | unset | Legacy compatibility alias for `NANOBOT_WORKSPACE_SANDBOX_ENFORCED`. |
|
||||
| `NANOBOT_TMUX_SOCKET_DIR` | `${TMPDIR:-/tmp}/nanobot-tmux-sockets` | Socket directory used by the bundled `tmux` skill scripts. |
|
||||
|
||||
### Installer, build, and WebUI development
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
|
||||
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
|
||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
|
||||
| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. |
|
||||
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
|
||||
|
||||
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
||||
|
||||
## Langfuse Observability
|
||||
|
||||
nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK wrapper. This is configured with environment variables, not `config.json`.
|
||||
@@ -166,7 +217,7 @@ nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK
|
||||
Install the optional package in the same Python environment that runs nanobot:
|
||||
|
||||
```bash
|
||||
python -m pip install langfuse
|
||||
nanobot plugins enable langfuse
|
||||
```
|
||||
|
||||
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
|
||||
@@ -198,19 +249,25 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
|
||||
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
|
||||
> - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
|
||||
> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value.
|
||||
> - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers.
|
||||
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
|
||||
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
|
||||
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
|
||||
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.com/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
|
||||
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
|
||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint | — |
|
||||
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||
| `opencode_zen` | LLM gateway (legacy alias for OpenCode Zen) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
|
||||
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
|
||||
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
|
||||
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
|
||||
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
|
||||
@@ -232,6 +289,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||
@@ -244,7 +302,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
|
||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
|
||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
|
||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
|
||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
|
||||
|
||||
@@ -342,7 +400,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA
|
||||
Install the optional dependency:
|
||||
|
||||
```bash
|
||||
python -m pip install 'nanobot-ai[azure]'
|
||||
nanobot plugins enable azure
|
||||
```
|
||||
|
||||
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
|
||||
@@ -357,7 +415,7 @@ python -m pip install 'nanobot-ai[azure]'
|
||||
|
||||
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
|
||||
|
||||
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `python -m pip install 'nanobot-ai[azure]'`.
|
||||
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `nanobot plugins enable azure`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -401,6 +459,17 @@ Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock m
|
||||
|
||||
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
|
||||
|
||||
Install Bedrock support first:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable bedrock
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you configured Bedrock before `boto3` became an optional dependency, run
|
||||
> `nanobot plugins enable bedrock` after upgrading. Otherwise the provider will
|
||||
> fail when it first tries to create a Bedrock client.
|
||||
|
||||
**1. Configure credentials**
|
||||
|
||||
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
|
||||
@@ -595,42 +664,19 @@ nanobot agent -m "Reply with one short sentence."
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
Codex uses OAuth instead of API keys and requires a ChatGPT Plus or Pro account. Authenticate it and make the current flagship model the active agent model with one command:
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
nanobot provider login openai-codex --set-main
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Then run:
|
||||
|
||||
**3. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
|
||||
# Target a specific workspace/config locally
|
||||
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
|
||||
|
||||
# One-off workspace override on top of that config
|
||||
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
|
||||
```
|
||||
|
||||
> Docker users: use `docker run -it` for interactive OAuth login.
|
||||
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -638,7 +684,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
<details>
|
||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
|
||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||
```bash
|
||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
|
||||
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
|
||||
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
|
||||
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
|
||||
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
|
||||
```
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
@@ -677,6 +733,75 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OpenCode Zen / Go</b></summary>
|
||||
|
||||
OpenCode Zen and OpenCode Go are available through nanobot's built-in
|
||||
OpenAI-compatible provider flow. They share the `OPENCODE_API_KEY` environment
|
||||
variable, but use separate provider keys and default base URLs:
|
||||
|
||||
| Provider | Default API base | Model prefix accepted by nanobot |
|
||||
|----------|------------------|-----------------------------------|
|
||||
| `opencode` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
||||
| `opencode_zen` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
|
||||
| `opencode_go` | `https://opencode.ai/zen/go/v1` | `opencode-go/<model-id>` |
|
||||
|
||||
OpenCode Zen:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"opencodeZen": {
|
||||
"provider": "opencode",
|
||||
"model": "opencode/deepseek-v4-pro"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "opencodeZen"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.opencodeZen` / `provider: "opencode_zen"` still work as compatibility aliases for existing configs.
|
||||
|
||||
OpenCode Go:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"opencodeGo": {
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "opencodeGo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode's own docs list models across `responses`, `messages`,
|
||||
provider-specific model endpoints, and `chat/completions`. nanobot's OpenCode
|
||||
providers use the OpenAI-compatible `chat/completions` path, so pick model IDs
|
||||
from that endpoint family. The `opencode/...` and `opencode-go/...` prefixes are
|
||||
accepted for config readability and stripped before sending the request.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LongCat (OpenAI-compatible)</b></summary>
|
||||
|
||||
@@ -752,7 +877,7 @@ Step Plan is StepFun's subscription-based service for high-frequency AI develope
|
||||
"providers": {
|
||||
"stepfun": {
|
||||
"apiKey": "${STEPFUN_API_KEY}",
|
||||
"apiBase": "https://api.stepfun.com/step_plan/v1"
|
||||
"apiBase": "https://api.stepfun.ai/step_plan/v1"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
@@ -882,6 +1007,29 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
|
||||
}
|
||||
```
|
||||
|
||||
If a custom OpenAI-compatible endpoint exposes a provider-specific thinking toggle, set `thinkingStyle` so nanobot can translate `reasoningEffort` into the right request body. Supported styles are `thinking_type` (`{"thinking":{"type":"enabled"}}`), `enable_thinking` (`{"enable_thinking": true}`), and `reasoning_split` (`{"reasoning_split": true}`):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"companyProxy": {
|
||||
"apiKey": "${COMPANY_PROXY_API_KEY}",
|
||||
"apiBase": "https://api.your-provider.com/v1",
|
||||
"thinkingStyle": "enable_thinking"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"company": {
|
||||
"provider": "companyProxy",
|
||||
"model": "served-model-name",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `thinkingStyle` unset unless the endpoint explicitly documents one of those wire formats. `extraBody` is still applied last, so advanced users can override the generated value.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="local-providers"></a>
|
||||
@@ -1354,8 +1502,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
|---------|---------|-------------|
|
||||
| `sendProgress` | `true` | Stream agent's text progress to the channel |
|
||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
||||
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
|
||||
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
|
||||
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix / Mattermost keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
|
||||
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. PDF, DOCX, XLSX, and PPTX readers are included in the standard installation. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
|
||||
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
||||
|
||||
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
|
||||
@@ -1379,6 +1527,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
}
|
||||
```
|
||||
|
||||
Telegram `richMessages` defaults to `false`. Enable it only to opt in to Bot API 10.1 `sendRichMessage` rendering; leave it disabled for Telegram Web clients that show unsupported-message errors for rich messages.
|
||||
|
||||
### Retry Behavior
|
||||
|
||||
Retry is intentionally simple.
|
||||
@@ -1427,18 +1577,21 @@ nanobot uses a shared SSRF guard for built-in web fetches and HTTP/SSE MCP conne
|
||||
|
||||
Keep whitelist entries as narrow as possible, such as a single host CIDR (`192.168.1.50/32`). The whitelist is global for the shared SSRF guard; it is not limited to one tool or one MCP server.
|
||||
|
||||
HTTP/SSE MCP connections use the same process-wide proxy environment behavior as `web_fetch`: proxied targets use the configured proxy, and URLs excluded by `NO_PROXY` remain DNS-pinned direct connections.
|
||||
|
||||
> [!TIP]
|
||||
> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy:
|
||||
> Use `proxy` in `tools.web` to route web requests through a proxy:
|
||||
> ```json
|
||||
> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
|
||||
> ```
|
||||
> `web_fetch` applies DNS pinning for direct connections. When an explicit `tools.web.proxy` or a process-wide proxy environment variable applies to the target URL, nanobot still validates the requested URL locally, but DNS resolution for the outbound fetch happens at the proxy; configure only trusted proxies. URLs excluded by `NO_PROXY` keep the DNS-pinned direct path unless `tools.web.proxy` is configured.
|
||||
|
||||
### `tools.web`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
|
||||
| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` |
|
||||
| `proxy` | string or null | `null` | Proxy for web requests, for example `http://127.0.0.1:7890`. `web_fetch` DNS pinning applies only to direct connections; proxied fetches rely on the configured proxy as the trusted network exit. |
|
||||
| `userAgent` | string or null | `null` | User-Agent header for all web requests. If null, a browser one will be used |
|
||||
|
||||
### Web Search
|
||||
@@ -1456,6 +1609,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an
|
||||
| `olostep` | `apiKey` | `OLOSTEP_API_KEY` | No |
|
||||
| `bocha` | `apiKey` | `BOCHA_API_KEY` | Free tier (1M calls for startups) |
|
||||
| `volcengine` | `apiKey` | `VOLCENGINE_SEARCH_API_KEY` or `WEB_SEARCH_API_KEY` | Monthly quota, then paid |
|
||||
| `keenable` | `apiKey` (optional) | `KEENABLE_API_KEY` | Yes (no key needed; key raises limits) |
|
||||
| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
|
||||
| `duckduckgo` (default) | — | — | Yes |
|
||||
|
||||
@@ -1565,6 +1719,37 @@ You can set `BOCHA_API_KEY` in the environment instead of storing it in config.
|
||||
|
||||
You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-search skill. Create the key in the [Volcengine web search console](https://console.volcengine.com/search-infinity/web-search), then copy it from [API keys](https://console.volcengine.com/search-infinity/api-key). Volcengine Ark keys are separate and do not work for this search provider.
|
||||
|
||||
**Keenable** (works without an API key on the free tier):
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"provider": "keenable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit.
|
||||
|
||||
**Serper** (Google Search API):
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"provider": "serper",
|
||||
"apiKey": "${SERPER_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_KEY` in the environment instead of storing it in config.
|
||||
|
||||
**SearXNG** (self-hosted, no API key needed):
|
||||
```json
|
||||
{
|
||||
@@ -1596,7 +1781,7 @@ You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `searxng`, `duckduckgo` |
|
||||
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `keenable`, `serper`, `searxng`, `duckduckgo` |
|
||||
| `apiKey` | string | `""` | API key for API-backed search providers |
|
||||
| `baseUrl` | string | `""` | Base URL for SearXNG |
|
||||
| `maxResults` | integer | `5` | Results per search (1–10) |
|
||||
@@ -1708,9 +1893,9 @@ Use `enabledTools` to register only a subset of tools from an MCP server:
|
||||
|
||||
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
|
||||
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
|
||||
- Set `enabledTools` to `[]` to register no tools from that server.
|
||||
- Set `enabledTools` to a non-empty list of names to register only that subset.
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all capabilities (tools, resources, and prompts).
|
||||
- Set `enabledTools` to `[]` to register no tools from that server. Resources and prompts are also skipped, since they have no per-name filter.
|
||||
- Set `enabledTools` to a non-empty list of names to register only those tools — resources and prompts are not registered.
|
||||
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
@@ -1720,22 +1905,23 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
## Security
|
||||
|
||||
> [!TIP]
|
||||
> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
|
||||
> For production deployments, set both `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config. `restrictToWorkspace` enables nanobot's application-level workspace guards; `tools.exec.sandbox` provides process-level isolation for shell commands.
|
||||
|
||||
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
|
||||
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
||||
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
|
||||
| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox — the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables workspace restriction for file tools. **Linux only** — requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
|
||||
| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
|
||||
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
|
||||
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
|
||||
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
|
||||
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
||||
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
||||
|
||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces.
|
||||
|
||||
|
||||
## Pairing
|
||||
@@ -1744,7 +1930,7 @@ Pairing lets users get access to the bot through a simple code exchange — no c
|
||||
|
||||
### How it works
|
||||
|
||||
1. A user sends a DM to the bot on any channel (Telegram, Discord, Slack, etc.) where they aren't yet approved.
|
||||
1. A user sends a DM to the bot on a pairing-capable channel where they aren't yet approved. This includes Telegram, Discord, WeChat, and channels such as Slack or Mattermost when their DM policy is set to `allowlist`.
|
||||
2. The bot replies with a pairing code (like `ABCD-EFGH`) and tells them to forward it to you.
|
||||
3. You approve the code:
|
||||
|
||||
@@ -1758,7 +1944,7 @@ Pairing only works in **DMs** — unapproved users in group chats are silently i
|
||||
|
||||
### Pairing-only mode
|
||||
|
||||
By default, if you don't set `allowFrom`, anyone who isn't approved yet will get a pairing code when they DM the bot. This means you can skip `allowFrom` entirely and manage all access through pairing:
|
||||
By default, if you don't set `allowFrom`, pairing-capable channels can issue a pairing code when an unapproved user DMs the bot. This means you can skip `allowFrom` entirely and manage access through pairing:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1770,6 +1956,21 @@ By default, if you don't set `allowFrom`, anyone who isn't approved yet will get
|
||||
}
|
||||
```
|
||||
|
||||
Slack and Mattermost DMs are open by default. To use pairing there, set the
|
||||
channel's `dm.policy` to `"allowlist"` and leave `dm.allowFrom` empty until you
|
||||
approve users:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"dm": { "policy": "allowlist" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you prefer to allow everyone without approval:
|
||||
|
||||
```json
|
||||
@@ -1819,7 +2020,9 @@ The gateway can run a protected heartbeat cron job that periodically checks `HEA
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and delivers useful results to the most recently active chat target. If the file has no active tasks, the heartbeat is skipped silently.
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and sends only useful/actionable results to the most recently active chat target. If the file has no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||
|
||||
This is intentionally different from user-created cron jobs. A cron job created with the `cron` tool runs as a scheduled turn in its origin chat/session and normally delivers the result back to that channel. Use `HEARTBEAT.md` for recurring background checks that should not notify the user on every run.
|
||||
|
||||
The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks.
|
||||
|
||||
@@ -1828,6 +2031,11 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
||||
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
|
||||
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
|
||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
||||
|
||||
### Custom heartbeat evaluator prompt
|
||||
|
||||
The notification gate runs on a built-in system prompt. Advanced users can override it, but you rarely need to — it's strongly advised to first read the evaluator code and the default `evaluator.md`. To override, drop your prompt at `<workspace>/prompts/evaluator.md`. It must still instruct the model to call the `evaluate_notification` tool; otherwise the gate fails closed and stays silent.
|
||||
|
||||
|
||||
## Subagent Concurrency
|
||||
@@ -1844,9 +2052,22 @@ By default, nanobot only allows one spawned subagent at a time. When the limit i
|
||||
}
|
||||
```
|
||||
|
||||
Subagents also stop immediately when one of their tools returns an execution error. That default keeps failures visible to the parent agent. If your subagent workflows use tools that can fail transiently and should be retried or worked around by the model, disable hard-stop behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"failOnToolError": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
|
||||
| `agents.defaults.failOnToolError` | `true` | Stop a spawned subagent when a tool execution fails. Set to `false` to return tool errors to the subagent model so it can recover within the same run. |
|
||||
|
||||
|
||||
## Auto Compact
|
||||
|
||||
+95
-89
@@ -13,7 +13,7 @@ Check these once before Docker, systemd, or LaunchAgent:
|
||||
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
|
||||
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
|
||||
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
|
||||
| Ports are planned | Gateway health defaults to `18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
||||
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
|
||||
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
|
||||
|
||||
Restart the deployed process after editing `config.json`. Long-running processes read config at startup.
|
||||
@@ -38,14 +38,13 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret:
|
||||
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with a secret:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "gateway": { "host": "0.0.0.0" },
|
||||
> "channels": {
|
||||
> "websocket": {
|
||||
> "enabled": true,
|
||||
> "host": "0.0.0.0",
|
||||
> "port": 8765,
|
||||
> "tokenIssueSecret": "your-secret-here"
|
||||
@@ -55,9 +54,30 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
||||
> ```
|
||||
>
|
||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||
> The gateway health route itself is intentionally minimal and unauthenticated. When the
|
||||
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
|
||||
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
|
||||
> must probe it directly, replace `127.0.0.1` in the port mapping with a trusted host
|
||||
> interface and restrict inbound traffic to the monitoring system.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
The default image preinstalls WhatsApp dependencies. To bake other enabled
|
||||
channels into an image (recommended for deployments without PyPI access), pass
|
||||
a comma-separated `NANOBOT_CHANNELS` build argument:
|
||||
|
||||
```bash
|
||||
NANOBOT_CHANNELS=telegram,slack docker compose build
|
||||
```
|
||||
|
||||
The image keeps nanobot in a virtual environment owned by its built-in non-root
|
||||
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
|
||||
startup can therefore install its manifest-declared dependencies. Rebuilding
|
||||
with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying
|
||||
on the container's writable layer. If you override the container with a
|
||||
different `--user`, bake every enabled channel into the image because that UID
|
||||
is not guaranteed write access to the virtual environment.
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||
vim ~/.nanobot/config.json # add API keys
|
||||
@@ -70,12 +90,32 @@ docker compose logs -f nanobot-gateway # view logs
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
The default Compose file drops all Linux capabilities and keeps Docker's default
|
||||
AppArmor/seccomp profiles enabled. If you explicitly set
|
||||
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
|
||||
override file when starting containers:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-gateway
|
||||
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
|
||||
```
|
||||
|
||||
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
|
||||
the container so bubblewrap can create its nested namespaces. Use it only when the
|
||||
bwrap sandbox is enabled.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t nanobot .
|
||||
|
||||
# Or preinstall a regular Python extra such as Bedrock support
|
||||
docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot .
|
||||
|
||||
# Or preinstall dependencies for a specific set of channels
|
||||
docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot .
|
||||
|
||||
# Initialize config (first time only)
|
||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
|
||||
@@ -83,18 +123,23 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||
vim ~/.nanobot/config.json
|
||||
|
||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
||||
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
||||
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
||||
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
||||
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
||||
# endpoint on 18790.
|
||||
# `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
|
||||
# health endpoint on 18790.
|
||||
docker run \
|
||||
--cap-drop ALL \
|
||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||
-p 18790:18790 -p 8765:8765 \
|
||||
nanobot gateway
|
||||
|
||||
# If `tools.exec.sandbox: "bwrap"` is enabled, run with the extra permissions
|
||||
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
|
||||
# `clone3: Operation not permitted`.
|
||||
docker run \
|
||||
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||
--security-opt apparmor=unconfined \
|
||||
--security-opt seccomp=unconfined \
|
||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||
-p 18790:18790 -p 8765:8765 \
|
||||
-p 127.0.0.1:18790:18790 -p 8765:8765 \
|
||||
nanobot gateway
|
||||
|
||||
# Or run a single command
|
||||
@@ -106,48 +151,41 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status
|
||||
|
||||
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
|
||||
|
||||
**1. Find the nanobot binary path:**
|
||||
Preview the generated unit first:
|
||||
|
||||
```bash
|
||||
which nanobot # e.g. /home/user/.local/bin/nanobot
|
||||
nanobot gateway install-service --manager systemd --dry-run
|
||||
```
|
||||
|
||||
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Nanobot Gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/nanobot gateway
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%h
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
**3. Enable and start:**
|
||||
Install, enable, and start it:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now nanobot-gateway
|
||||
nanobot gateway install-service --manager systemd
|
||||
```
|
||||
|
||||
**Common operations:**
|
||||
For a custom instance, pass the same config/workspace selector you use to run the gateway:
|
||||
|
||||
```bash
|
||||
nanobot gateway install-service \
|
||||
--manager systemd \
|
||||
--name nanobot-telegram \
|
||||
--config ~/.nanobot-telegram/config.json \
|
||||
--workspace ~/.nanobot-telegram/workspace
|
||||
```
|
||||
|
||||
Common operations:
|
||||
|
||||
```bash
|
||||
systemctl --user status nanobot-gateway # check status
|
||||
systemctl --user restart nanobot-gateway # restart after config changes
|
||||
journalctl --user -u nanobot-gateway -f # follow logs
|
||||
nanobot gateway uninstall-service --manager systemd
|
||||
```
|
||||
|
||||
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
|
||||
The installer writes `~/.config/systemd/user/nanobot-gateway.service`, runs
|
||||
`systemctl --user daemon-reload`, enables the unit, and restarts it. It uses the
|
||||
current Python executable with `python -m nanobot gateway --foreground`, so the
|
||||
service runs in the same environment you used to install nanobot.
|
||||
|
||||
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
|
||||
>
|
||||
@@ -159,70 +197,38 @@ If you edit the `.service` file itself, run `systemctl --user daemon-reload` bef
|
||||
|
||||
Use a LaunchAgent when you want `nanobot gateway` to stay online after you log in, without keeping a terminal open.
|
||||
|
||||
**1. Get the absolute `nanobot` path:**
|
||||
Preview the generated plist first:
|
||||
|
||||
```bash
|
||||
which nanobot # e.g. /Users/youruser/.local/bin/nanobot
|
||||
nanobot gateway install-service --manager launchd --dry-run
|
||||
```
|
||||
|
||||
Use that exact path in the plist. It keeps the Python environment from your install method.
|
||||
|
||||
**2. Create `~/Library/LaunchAgents/ai.nanobot.gateway.plist`:**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.nanobot.gateway</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/youruser/.local/bin/nanobot</string>
|
||||
<string>gateway</string>
|
||||
<string>--workspace</string>
|
||||
<string>/Users/youruser/.nanobot/workspace</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/youruser/.nanobot/workspace</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/youruser/.nanobot/logs/gateway.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/youruser/.nanobot/logs/gateway.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
**3. Load and start it:**
|
||||
Install, load, enable, and start it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents ~/.nanobot/logs
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
||||
launchctl enable gui/$(id -u)/ai.nanobot.gateway
|
||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
|
||||
nanobot gateway install-service --manager launchd
|
||||
```
|
||||
|
||||
**Common operations:**
|
||||
For a custom instance:
|
||||
|
||||
```bash
|
||||
nanobot gateway install-service \
|
||||
--manager launchd \
|
||||
--name nanobot-telegram \
|
||||
--config ~/.nanobot-telegram/config.json \
|
||||
--workspace ~/.nanobot-telegram/workspace
|
||||
```
|
||||
|
||||
Common operations:
|
||||
|
||||
```bash
|
||||
launchctl list | grep ai.nanobot.gateway
|
||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway # restart
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
|
||||
nanobot gateway uninstall-service --manager launchd
|
||||
```
|
||||
|
||||
After editing the plist, run `launchctl bootout ...` and `launchctl bootstrap ...` again.
|
||||
The installer writes `~/Library/LaunchAgents/ai.nanobot.gateway.plist`, uses the
|
||||
current Python executable with `python -m nanobot gateway --foreground`, and
|
||||
writes LaunchAgent logs under `~/.nanobot/logs/`.
|
||||
|
||||
> **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# nanobot Task Guides
|
||||
|
||||
Start with [Install and Quick Start](../quick-start.md) and get one reply before using a guide below. Each guide targets one outcome; linked reference pages hold the complete option tables and edge cases.
|
||||
|
||||
## Start and Use
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
|
||||
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
||||
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||
| Run a sustained goal | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
|
||||
|
||||
## Connect a Chat App
|
||||
|
||||
Use **Settings → Channels** in the WebUI for guided setup. These guides explain the account, bot, token, permission, and test-message steps on each platform.
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Connect chat apps | [Chat app AI agent](./chat-app-ai-agent.md) |
|
||||
| Connect Telegram | [Telegram AI agent](./telegram-ai-agent.md) |
|
||||
| Connect Discord | [Discord AI agent](./discord-ai-agent.md) |
|
||||
| Connect Slack | [Slack AI agent](./slack-ai-agent.md) |
|
||||
| Connect Feishu | [Feishu AI agent](./feishu-ai-agent.md) |
|
||||
| Connect WhatsApp | [WhatsApp AI agent](./whatsapp-ai-agent.md) |
|
||||
| Connect WeChat | [WeChat AI agent](./wechat-ai-agent.md) |
|
||||
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
|
||||
| Connect Email | [Email AI agent](./email-ai-agent.md) |
|
||||
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
|
||||
|
||||
## Integrate from Code
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
|
||||
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
|
||||
|
||||
## Configure and Operate
|
||||
|
||||
| Goal | Guide |
|
||||
|---|---|
|
||||
| Add MCP tools | [Configure MCP tools](./configure-mcp-tools.md) |
|
||||
| Enable web search | [Configure web search](./configure-web-search.md) |
|
||||
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
|
||||
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
|
||||
| Improve Ollama tool prompt-cache reuse | [Configure Ollama prompt caching](./configure-ollama-prompt-cache.md) |
|
||||
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
|
||||
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
||||
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
||||
@@ -0,0 +1,72 @@
|
||||
# How AI Agent Memory Works in nanobot
|
||||
|
||||
This guide explains how to use nanobot's long-term AI agent memory: session
|
||||
history, compressed archives, durable memory files, Dream consolidation, and
|
||||
Git-backed memory changes.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a workspace with persistent session history
|
||||
- compressed history archives for older turns
|
||||
- durable memory files such as `USER.md` and `MEMORY.md`
|
||||
- a Dream workflow for curating long-term memory
|
||||
|
||||
## When to use this
|
||||
|
||||
Use memory when an agent should remember stable preferences, project facts,
|
||||
decisions, and recurring context across sessions. Do not use memory as a dumping
|
||||
ground for every raw transcript; nanobot separates short-term messages from
|
||||
curated durable knowledge.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Ask the agent to remember a stable fact in a normal session, then run Dream:
|
||||
|
||||
```text
|
||||
/dream
|
||||
```
|
||||
|
||||
Inspect recent memory changes:
|
||||
|
||||
```text
|
||||
/dream-log
|
||||
```
|
||||
|
||||
The exact files live in the active workspace, usually under
|
||||
`~/.nanobot/workspace/`.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use one workspace per project or personal context.
|
||||
- Keep durable facts concise; old session details belong in `history.jsonl`.
|
||||
- Use `/dream-prompt init` when a workspace needs custom memory guidance.
|
||||
- Review Git-backed memory changes when memory affects important workflows.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Memory files may contain sensitive user or project facts.
|
||||
- Avoid sharing workspaces without reviewing `SOUL.md`, `USER.md`, and
|
||||
`memory/MEMORY.md`.
|
||||
- Use separate workspaces for personal and team contexts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If memory feels stale, run `/dream` and inspect `/dream-log`.
|
||||
- If memory changed incorrectly, use `/dream-restore` to inspect and restore
|
||||
previous versions.
|
||||
- If a new session lacks context, confirm it uses the same workspace.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [AI Agent Memory in nanobot](../memory.md)
|
||||
- [Concepts](../concepts.md)
|
||||
- [Configuration](../configuration.md#auto-compact)
|
||||
- [Chat Commands](../chat-commands.md)
|
||||
@@ -0,0 +1,73 @@
|
||||
# How to Use an AI Agent WebUI with nanobot
|
||||
|
||||
nanobot includes a browser WebUI for persistent chat sessions, visible agent
|
||||
activity, workspace controls, Apps, MCP presets, Skills, settings, and
|
||||
Automations.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a local browser workbench
|
||||
- one persistent chat session
|
||||
- a visible timeline of agent messages, tool calls, and file edit diffs
|
||||
- a gateway-backed WebSocket connection
|
||||
|
||||
## When to use this
|
||||
|
||||
Use the WebUI when you want a local AI agent interface that is easier to operate
|
||||
than a terminal, especially for project work, file attachments, model switching,
|
||||
workspace selection, Apps, Skills, and scheduled automations.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The published wheel already includes the WebUI bundle. You only need the
|
||||
`webui/` source directory when changing the frontend.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
The launcher checks setup, enables the local WebSocket channel after
|
||||
confirmation, starts the gateway, and opens the browser.
|
||||
|
||||
When nanobot edits a file, the WebUI activity timeline can show the changed
|
||||
line counts, a unified diff, and an **Open file** action for a read-only
|
||||
preview. File previews use the chat's current workspace access mode: restricted
|
||||
access stays inside the selected workspace, while Full Access can preview files
|
||||
outside the workspace when the gateway allows it.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use `nanobot webui --background` when you do not want to keep a terminal open.
|
||||
- Use `nanobot gateway status`, `logs`, `restart`, and `stop` to manage a
|
||||
background gateway.
|
||||
- If you expose the WebUI beyond localhost, set a token issue secret and review
|
||||
workspace/tool access.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The first-run WebUI path binds to `127.0.0.1` by default.
|
||||
- Do not expose the WebUI on a LAN or public host without an intentional access
|
||||
model.
|
||||
- Keep file and shell tools scoped to the workspace before inviting other users.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- The WebUI is served by the WebSocket channel on port `8765` by default.
|
||||
- The gateway health endpoint is separate from the browser UI.
|
||||
- If the page opens but messages fail, check provider setup with
|
||||
`nanobot agent -m "Hello!"`.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Nanobot WebUI](../webui.md)
|
||||
- [Quick Start](../quick-start.md)
|
||||
- [WebSocket protocol](../websocket.md)
|
||||
- [Configuration](../configuration.md)
|
||||
@@ -0,0 +1,83 @@
|
||||
# How to Build a Personal AI Agent with nanobot
|
||||
|
||||
This guide builds a personal AI agent you can run locally, talk to from the
|
||||
terminal or browser, and later connect to chat apps, memory, tools, and
|
||||
automations.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a configured nanobot install
|
||||
- one working model provider
|
||||
- one local agent reply
|
||||
- a browser WebUI session for ongoing work
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this when you want a personal AI agent that you control rather than a hosted
|
||||
chat-only interface. nanobot is useful when the agent needs local workspace
|
||||
access, tool calls, session history, memory, scheduled work, or chat app
|
||||
delivery.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
The wizard creates `~/.nanobot/config.json` and helps you choose a provider and
|
||||
model. If terminals and config files are new to you, use
|
||||
[Start Without Technical Background](../start-without-technical-background.md)
|
||||
instead.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
First prove the runtime can answer:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then open the browser workbench:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
The WebUI starts the local gateway, opens a browser, and keeps persistent chat
|
||||
sessions for longer work.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Keep one workspace per project or personal context.
|
||||
- Use `modelPresets` when you want stable names for fast, deep, local, or
|
||||
fallback models.
|
||||
- Keep `nanobot gateway` running for WebUI, chat apps, automations, and the
|
||||
WebSocket channel.
|
||||
- Use the Python SDK or OpenAI-compatible API when another program should call
|
||||
the agent.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Do not store API keys directly in shared files; use environment variables.
|
||||
- Prefer chat app pairing for first setup. Use `allowFrom` only for static
|
||||
allowlists, and keep those lists narrow.
|
||||
- Enable workspace restriction before exposing file or shell tools to other
|
||||
users.
|
||||
- Use a separate workspace for experiments that can modify files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `nanobot status` shows the config path, workspace path, and active model.
|
||||
- If `nanobot agent -m "Hello!"` fails, fix provider setup before opening the
|
||||
WebUI or chat apps.
|
||||
- If the WebUI opens but does not answer, check gateway logs and provider
|
||||
credentials.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Quick Start](../quick-start.md)
|
||||
- [Concepts](../concepts.md)
|
||||
- [WebUI](../webui.md)
|
||||
- [Configuration](../configuration.md)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
@@ -0,0 +1,96 @@
|
||||
# How to Connect an AI Agent to Chat Apps with nanobot
|
||||
|
||||
nanobot can run as a self-hosted chatbot or AI agent in Telegram, Discord,
|
||||
Slack, WeChat, Email, Mattermost, and other chat apps. The gateway receives chat
|
||||
messages, runs the agent, and sends replies back to the same channel.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a working local agent
|
||||
- one enabled chat channel
|
||||
- a running gateway
|
||||
- a pairing-based approval flow or a narrow static allowlist
|
||||
|
||||
## When to use this
|
||||
|
||||
Use chat apps when the agent should live where users already communicate:
|
||||
private DMs, team channels, group chats, email threads, or bot workspaces.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
|
||||
|
||||
- [Telegram AI agent](./telegram-ai-agent.md)
|
||||
- [Discord AI agent](./discord-ai-agent.md)
|
||||
- [Slack AI agent](./slack-ai-agent.md)
|
||||
- [Feishu AI agent](./feishu-ai-agent.md)
|
||||
- [WhatsApp AI agent](./whatsapp-ai-agent.md)
|
||||
- [WeChat AI agent](./wechat-ai-agent.md)
|
||||
- [QQ AI agent](./qq-ai-agent.md)
|
||||
- [Email AI agent](./email-ai-agent.md)
|
||||
- [Mattermost AI agent](./mattermost-ai-agent.md)
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Use the guided channel setup:
|
||||
|
||||
1. Get the platform token, login state, webhook, or mailbox credentials.
|
||||
2. Open **Settings → Channels** in the WebUI.
|
||||
3. Choose the platform and open its setup panel.
|
||||
4. Complete the credential or QR flow and install optional support if prompted.
|
||||
5. Restart when the WebUI requests it.
|
||||
6. Send a private test message.
|
||||
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
|
||||
|
||||
If your installed release does not show **Settings → Channels**, use the full [Chat Apps reference](../chat-apps.md#manual-setup-pattern) to configure the channel manually.
|
||||
|
||||
Check status from the terminal when you need a lower-level confirmation:
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
```
|
||||
|
||||
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Keep the gateway running as a service for always-on chat apps.
|
||||
- Use mention-only group policies before opening a bot to busy channels.
|
||||
- Use one channel at a time while debugging.
|
||||
- Prefer DMs for first tests; pairing only works in DMs, and group chats add
|
||||
permissions and routing behavior.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Prefer pairing or explicit allowlists; do not use `allowFrom: ["*"]` outside
|
||||
an intentional sandbox.
|
||||
- Rotate bot tokens if they are pasted into logs or shared files.
|
||||
- Review file, shell, and web tool access before inviting other users.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `nanobot channels status` does not show the channel, the config key or
|
||||
optional dependency is likely missing.
|
||||
- If the first DM returns a pairing code, approve the pending request in the WebUI or use `/pairing approve <code>` from an authorized chat.
|
||||
- If messages do not arrive, run `nanobot gateway --verbose` and compare
|
||||
platform credentials, event permissions, and allow lists.
|
||||
- If group replies are unexpected, review that channel's group policy.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Chat Apps](../chat-apps.md)
|
||||
- [Configuration](../configuration.md#channel-settings)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,79 @@
|
||||
# How to Configure Langfuse Observability for nanobot
|
||||
|
||||
nanobot can trace supported OpenAI-compatible provider calls through Langfuse's
|
||||
OpenAI SDK wrapper.
|
||||
|
||||
## What you will build
|
||||
|
||||
- Langfuse installed in the same Python environment as nanobot
|
||||
- Langfuse environment variables set before startup
|
||||
- one traced nanobot model call
|
||||
|
||||
## When to use this
|
||||
|
||||
Use Langfuse when you need observability for model requests, latency, errors,
|
||||
cost, or prompt behavior during development or production operation.
|
||||
|
||||
## Install
|
||||
|
||||
Install nanobot and prove the agent works:
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Install Langfuse:
|
||||
|
||||
```bash
|
||||
python -m pip install langfuse
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Set credentials before starting nanobot:
|
||||
|
||||
```bash
|
||||
export LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:LANGFUSE_SECRET_KEY = "sk-lf-..."
|
||||
$env:LANGFUSE_PUBLIC_KEY = "pk-lf-..."
|
||||
$env:LANGFUSE_BASE_URL = "https://cloud.langfuse.com"
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Production notes
|
||||
|
||||
- Langfuse is configured with environment variables, not `config.json`.
|
||||
- Start services from an environment that exports the same variables.
|
||||
- Add tracing after the provider works; it should not be the first setup step.
|
||||
- Native providers that do not use the OpenAI-compatible client path may not
|
||||
produce Langfuse OpenAI-wrapper traces.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat Langfuse projects as observability stores for sensitive prompts and
|
||||
outputs.
|
||||
- Use separate projects for personal, staging, and production traffic.
|
||||
- Keep Langfuse keys out of committed service files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If no traces appear, confirm the service process sees the environment
|
||||
variables.
|
||||
- Confirm the provider path is OpenAI-compatible.
|
||||
- Run one local `nanobot agent -m "Hello!"` call before debugging service logs.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Configuration: Langfuse Observability](../configuration.md#langfuse-observability)
|
||||
- [Provider Cookbook: Langfuse Tracing](../provider-cookbook.md#recipe-langfuse-tracing)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,82 @@
|
||||
# How to Configure MCP Tools in nanobot
|
||||
|
||||
This guide adds an MCP server to nanobot so the agent can use external tools
|
||||
through the Model Context Protocol.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a working nanobot agent
|
||||
- one MCP integration configured through Apps or `~/.nanobot/config.json`
|
||||
- a restricted set of MCP tools exposed to the model
|
||||
|
||||
## When to use this
|
||||
|
||||
Use MCP when the capability you need already exists as an MCP server, or when
|
||||
you want external tools to be managed outside nanobot core.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Install the MCP server runtime separately. Many examples use `npx`, `uvx`, or a
|
||||
remote HTTP endpoint.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui` and open **Apps**.
|
||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||
4. Save and restart when prompted.
|
||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||
|
||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
||||
"enabledTools": ["read_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart nanobot and ask a question that requires the MCP tool.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Prefer `enabledTools` over exposing every tool by default.
|
||||
- Use `toolTimeout` for slow MCP operations.
|
||||
- Use HTTP MCP only for endpoints you trust.
|
||||
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Stdio MCP starts a local process; review the command before enabling it.
|
||||
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
||||
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||
- Do not place secrets in command arguments when environment variables or
|
||||
headers can be used.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Run the MCP command outside nanobot first.
|
||||
- Start `nanobot gateway --verbose` and inspect tool registration logs.
|
||||
- If an HTTP MCP URL is blocked, check whether it points to loopback or a
|
||||
private address that needs explicit allowlisting.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [MCP tools for AI agents](./mcp-tools-for-ai-agents.md)
|
||||
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
|
||||
- [Security](../configuration.md#security)
|
||||
@@ -0,0 +1,93 @@
|
||||
# How to Configure Model Fallback in nanobot
|
||||
|
||||
Model fallback lets nanobot try a primary model first, then fall back to one or
|
||||
more named presets when the primary provider fails or rate-limits.
|
||||
|
||||
## What you will build
|
||||
|
||||
- two or more `modelPresets`
|
||||
- a primary `agents.defaults.modelPreset`
|
||||
- an ordered `agents.defaults.fallbackModels` chain
|
||||
|
||||
## When to use this
|
||||
|
||||
Use fallback when you want better reliability across rate limits, provider
|
||||
outages, local model downtime, or cost-sensitive routing.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Verify each provider works before adding it as a fallback.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Merge this shape into `~/.nanobot/config.json` and replace provider/model names
|
||||
with ones you control:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"fast": {
|
||||
"label": "Fast",
|
||||
"provider": "primary-provider",
|
||||
"model": "primary-model-id",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
},
|
||||
"deep": {
|
||||
"label": "Deep",
|
||||
"provider": "fallback-provider",
|
||||
"model": "fallback-model-id",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 200000,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "fast",
|
||||
"fallbackModels": ["deep"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
String entries in `fallbackModels` are preset names, not raw model IDs.
|
||||
Replace the placeholder model IDs with currently supported model IDs from your
|
||||
provider. The [Provider Cookbook](../provider-cookbook.md) has concrete recipes
|
||||
for common providers.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Keep fallback context windows realistic; smaller fallback windows constrain
|
||||
how much context can fit.
|
||||
- Put cheaper or faster fallbacks before expensive ones when acceptable.
|
||||
- Use `/model <preset>` for runtime switching without editing config.
|
||||
- Keep labels human-readable for WebUI model lists.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Different providers may have different data handling policies.
|
||||
- Do not put provider keys directly in shared config files.
|
||||
- Confirm fallback models can safely receive the same prompts and files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If a fallback never triggers, confirm the primary error is treated as
|
||||
retryable/fallbackable.
|
||||
- If startup fails, check that each fallback string matches a key under
|
||||
`modelPresets`.
|
||||
- If output is truncated after fallback, review `maxTokens` and
|
||||
`contextWindowTokens`.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Providers and Models](../providers.md)
|
||||
- [Provider Cookbook: Fallback Presets](../provider-cookbook.md#recipe-fallback-presets)
|
||||
- [Configuration: Model Fallbacks](../configuration.md#model-fallbacks)
|
||||
@@ -0,0 +1,239 @@
|
||||
# How to Improve Ollama Tool-Calling Prompt Cache Reuse in nanobot
|
||||
|
||||
Some Ollama model templates move or remove tool definitions as a conversation
|
||||
switches between user, assistant, and tool messages. nanobot can send a correct
|
||||
append-only chat request while the model template still renders a different token
|
||||
prefix. On slower local hardware, re-evaluating that prefix can add tens of seconds
|
||||
to an otherwise simple tool-using turn.
|
||||
|
||||
This guide shows how to diagnose that specific pattern and create a derived
|
||||
`llama3.1:8b` tag with a prefix-stable tool template. It does not modify nanobot or
|
||||
overwrite the original Ollama model.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a repeatable two-turn cache check
|
||||
- an optional derived `llama3.1:8b-prefix-stable-v1` Ollama tag
|
||||
- a nanobot model preset that uses the derived tag
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this guide when all of the following are true:
|
||||
|
||||
- direct Ollama responses are reasonably fast;
|
||||
- nanobot becomes slow after the model calls a tool;
|
||||
- Ollama logs show a long main prompt, a much shorter tool follow-up, and low
|
||||
initial cache reuse on the next main prompt;
|
||||
- the model is `llama3.1:8b` with a template that renders concrete tools only for
|
||||
the final user message.
|
||||
|
||||
Do not apply this template to another model family without checking that model's
|
||||
tool-call format first.
|
||||
|
||||
## Diagnose the rendered prompt
|
||||
|
||||
Stop any existing Ollama process, then start a single-slot debug server. A single
|
||||
slot makes the cache sequence easier to read.
|
||||
|
||||
**macOS or Linux**
|
||||
|
||||
```bash
|
||||
OLLAMA_CONTEXT_LENGTH=16384 \
|
||||
OLLAMA_NUM_PARALLEL=1 \
|
||||
OLLAMA_DEBUG=1 \
|
||||
ollama serve
|
||||
```
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
$env:OLLAMA_CONTEXT_LENGTH = "16384"
|
||||
$env:OLLAMA_NUM_PARALLEL = "1"
|
||||
$env:OLLAMA_DEBUG = "1"
|
||||
ollama serve
|
||||
```
|
||||
|
||||
In another terminal, use a fresh session and explicitly request a tool so both
|
||||
turns exercise the agent loop:
|
||||
|
||||
```bash
|
||||
nanobot agent --session cli:ollama-cache-check \
|
||||
--message "Use the exec tool to calculate 2+2, then answer"
|
||||
nanobot agent --session cli:ollama-cache-check \
|
||||
--message "Use the exec tool to calculate 4+7, then answer"
|
||||
```
|
||||
|
||||
In the Ollama output, find each `new prompt` line and the first
|
||||
`cached n_tokens` line that follows it. Later increasing `cached n_tokens` lines
|
||||
are prompt-evaluation progress, not additional initial cache hits.
|
||||
|
||||
A cache-unfriendly tool template may produce a pattern like this:
|
||||
|
||||
```text
|
||||
turn 1 main: 2 / 8460 initially cached
|
||||
turn 1 tool follow-up: 3713 / 3758 initially cached
|
||||
turn 2 main: 3767 / 8519 initially cached
|
||||
```
|
||||
|
||||
The cache is working, but the next main request can reuse only the shorter prompt.
|
||||
Hardware throughput determines how expensive the remaining evaluation is.
|
||||
|
||||
To inspect the API request bodies as well, add
|
||||
`OLLAMA_DEBUG_LOG_REQUESTS=1` before starting Ollama. These logs can contain system
|
||||
prompts, workspace context, and user messages. Keep them local and disable request
|
||||
logging after diagnosis.
|
||||
|
||||
## Why this happens with the stock template
|
||||
|
||||
The tested `llama3.1:8b` template conditionally expands the tool definitions inside
|
||||
a user message:
|
||||
|
||||
```gotemplate
|
||||
{{- if and $.Tools $last }}
|
||||
... render tool definitions ...
|
||||
{{- end }}
|
||||
```
|
||||
|
||||
The first request ends with a user message, so the tools are rendered there. After
|
||||
nanobot appends an assistant tool call and its result, that user message is no
|
||||
longer last, so the same API request history renders without the concrete tool
|
||||
block. On the next user turn, the tools reappear at a new position.
|
||||
|
||||
This is a model-template behavior. At the API boundary, nanobot continues to append
|
||||
the assistant tool call and tool result and sends the same tool definitions.
|
||||
|
||||
## Create a prefix-stable derived model
|
||||
|
||||
Create `PrefixStable.Modelfile` with the content below. The template keeps concrete
|
||||
tool definitions in the system block, where they remain in the same position across
|
||||
user and tool messages.
|
||||
|
||||
```dockerfile
|
||||
FROM llama3.1:8b
|
||||
|
||||
TEMPLATE """{{- if or .System .Tools }}<|start_header_id|>system<|end_header_id|>
|
||||
{{- if .System }}
|
||||
|
||||
{{ .System }}
|
||||
{{- end }}
|
||||
{{- if .Tools }}
|
||||
|
||||
Cutting Knowledge Date: December 2023
|
||||
|
||||
When you receive a tool call response, use the output to format an answer to the original user question.
|
||||
|
||||
You are a helpful assistant with tool calling capabilities.
|
||||
|
||||
Given the following functions, respond with a JSON function call with the proper arguments when a tool is needed.
|
||||
|
||||
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
|
||||
|
||||
{{ range .Tools }}
|
||||
{{- . }}
|
||||
{{ end }}
|
||||
{{- end }}<|eot_id|>
|
||||
{{- end }}
|
||||
{{- range $i, $_ := .Messages }}
|
||||
{{- $last := eq (len (slice $.Messages $i)) 1 }}
|
||||
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
|
||||
|
||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
||||
|
||||
{{ end }}
|
||||
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
|
||||
{{- if .ToolCalls }}
|
||||
{{ range .ToolCalls }}
|
||||
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
|
||||
{{- else }}
|
||||
|
||||
{{ .Content }}
|
||||
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
|
||||
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
|
||||
|
||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
||||
|
||||
{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}"""
|
||||
```
|
||||
|
||||
Create the new tag:
|
||||
|
||||
```bash
|
||||
ollama create llama3.1:8b-prefix-stable-v1 -f PrefixStable.Modelfile
|
||||
ollama list
|
||||
```
|
||||
|
||||
Ollama reuses the existing model layers. The new tag adds a small template and
|
||||
manifest instead of copying the base weights.
|
||||
|
||||
## Select the derived model in nanobot
|
||||
|
||||
Merge this preset into `~/.nanobot/config.json` and select it:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"ollamaPrefixStable": {
|
||||
"label": "Ollama Llama 3.1 prefix-stable",
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1:8b-prefix-stable-v1",
|
||||
"maxTokens": 2048,
|
||||
"contextWindowTokens": 16384,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "ollamaPrefixStable"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify the selected model and repeat the two-turn check:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent --session cli:ollama-stable-check \
|
||||
--message "Use the exec tool to calculate 2+2, then answer"
|
||||
nanobot agent --session cli:ollama-stable-check \
|
||||
--message "Use the exec tool to calculate 4+7, then answer"
|
||||
```
|
||||
|
||||
In one controlled test with Ollama 0.32.1, `llama3.1:8b`, and one slot, the second
|
||||
main request improved from `3767 / 8519` initially cached (44.22%) to
|
||||
`8505 / 8520` (99.82%). The number of re-evaluated tokens fell from 4752 to 15.
|
||||
Treat these numbers as a diagnostic example, not a performance guarantee.
|
||||
|
||||
## Roll back
|
||||
|
||||
Switch `agents.defaults.modelPreset` back to the original preset. When no config
|
||||
uses the derived tag, remove it with:
|
||||
|
||||
```bash
|
||||
ollama rm llama3.1:8b-prefix-stable-v1
|
||||
```
|
||||
|
||||
Removing the derived tag does not remove `llama3.1:8b`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The template above is specific to the tested `llama3.1:8b` tool-call format.
|
||||
- Ollama or the model publisher may update the stock template in a later release.
|
||||
- Validate multiple tool calls, tool errors, parallel calls, and long conversations
|
||||
before using a custom template for unattended workloads.
|
||||
- A higher cache ratio reduces prompt evaluation, but model generation, tool
|
||||
execution, process startup, and storage can still dominate end-to-end latency.
|
||||
- Multiple Ollama slots change cache scheduling and may produce different results.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Provider Cookbook: Ollama Local Model](../provider-cookbook.md#recipe-ollama-local-model)
|
||||
- [Providers and Models: Ollama](../providers.md#ollama)
|
||||
- [Troubleshooting](../troubleshooting.md)
|
||||
@@ -0,0 +1,93 @@
|
||||
# How to Configure an OpenAI-Compatible Provider in nanobot
|
||||
|
||||
nanobot can call OpenAI-compatible model providers by configuring an `apiBase`,
|
||||
optional `apiKey`, and a model preset that references that provider name.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a custom provider entry
|
||||
- a model preset pointing at that provider
|
||||
- one successful `nanobot agent` run
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this for local or hosted services that expose OpenAI-compatible endpoints,
|
||||
including internal gateways, local model servers, and provider proxies that are
|
||||
not already named in nanobot.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Verify the endpoint responds before debugging nanobot:
|
||||
|
||||
```bash
|
||||
curl -sS https://api.example.com/v1/models
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Merge this into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${CUSTOM_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Custom",
|
||||
"provider": "custom",
|
||||
"model": "provider-model-name",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Production notes
|
||||
|
||||
- Include the version path in `apiBase` when the service expects `/v1`.
|
||||
- Use separate provider names for separate endpoints.
|
||||
- Use a placeholder key such as `EMPTY` only when the endpoint requires a
|
||||
non-empty key but does not validate it.
|
||||
- Leave `apiType` unset for OpenAI-compatible custom endpoints.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Keep provider keys in environment variables.
|
||||
- Treat internal model gateways as sensitive network services.
|
||||
- Do not point nanobot at untrusted proxy endpoints for private workspaces.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `curl /models` fails, fix the provider endpoint before changing nanobot.
|
||||
- If nanobot says the model is unknown, check the model ID expected by the
|
||||
provider.
|
||||
- If auth fails, confirm whether the provider wants Bearer auth and whether the
|
||||
key is present in the environment that starts nanobot.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Provider Cookbook: Custom OpenAI-Compatible Provider](../provider-cookbook.md#recipe-custom-openai-compatible-provider)
|
||||
- [Providers: Custom OpenAI-Compatible Endpoint](../providers.md#custom-openai-compatible-endpoint)
|
||||
- [OpenAI-Compatible Agent API](./openai-compatible-agent-api.md)
|
||||
@@ -0,0 +1,98 @@
|
||||
# How to Configure Web Search for a nanobot AI Agent
|
||||
|
||||
nanobot includes built-in web search and web fetch tools. Search uses
|
||||
DuckDuckGo by default and can be configured for API-backed or self-hosted
|
||||
providers.
|
||||
|
||||
## What you will build
|
||||
|
||||
- web tools enabled in nanobot
|
||||
- one search provider selected in the WebUI or `config.json`
|
||||
- optional web fetch settings for page reading
|
||||
|
||||
## When to use this
|
||||
|
||||
Configure web search when the agent needs current information, public web
|
||||
research, source discovery, or page fetching during a task.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Web tools are enabled by default. Configure them only when you want a specific
|
||||
provider, API key, proxy, fetch behavior, or SSRF allowlist.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
For local interactive setup:
|
||||
|
||||
1. Run `nanobot webui`.
|
||||
2. Open **Settings → Web**.
|
||||
3. Enable web search, choose a provider, and enter its API key if required.
|
||||
4. Save and restart when prompted.
|
||||
5. Ask a question that requires current information and inspect the cited sources.
|
||||
|
||||
For manual or deployment-managed config, use the default search provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"enable": true,
|
||||
"search": {
|
||||
"provider": "duckduckgo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use an API-backed provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"provider": "brave",
|
||||
"apiKey": "${BRAVE_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ask a question that requires current information and inspect the tool activity
|
||||
in the WebUI or logs.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Keep API keys in environment variables.
|
||||
- Set `maxResults` when you need fewer or more search results per query.
|
||||
- Set `tools.web.proxy` only to a proxy you trust.
|
||||
- Use `fetch.useJinaReader: false` if you need local page conversion.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Web fetch and HTTP MCP share an SSRF guard.
|
||||
- Private, loopback, link-local, and cloud metadata addresses are blocked by
|
||||
default.
|
||||
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
|
||||
- Do not give public chat users unrestricted web and shell access without
|
||||
review.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If search returns no results, switch provider or check the provider API key.
|
||||
- If fetch is blocked, inspect the target URL and SSRF whitelist.
|
||||
- If a proxy changes network behavior, verify `NO_PROXY` and proxy settings.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Configuration: Web Tools](../configuration.md#web-tools)
|
||||
- [Security](../configuration.md#security)
|
||||
- [WebUI](../webui.md)
|
||||
@@ -0,0 +1,76 @@
|
||||
# How to Deploy a Long-Running nanobot AI Agent Gateway
|
||||
|
||||
The nanobot gateway is the long-running self-hosted AI agent process that keeps
|
||||
WebUI sessions, chat apps, automations, local triggers, heartbeat jobs, Dream,
|
||||
and WebSocket delivery online.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a verified nanobot config
|
||||
- a gateway process
|
||||
- a service or container deployment path with Docker, systemd, or macOS
|
||||
LaunchAgent
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this when nanobot should keep running after a single CLI turn. Chat apps,
|
||||
browser sessions, background automations, local triggers, and server-side
|
||||
integrations all depend on a live gateway.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Run the gateway in the foreground:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
For WebUI background usage:
|
||||
|
||||
```bash
|
||||
nanobot webui --background
|
||||
nanobot gateway status
|
||||
nanobot gateway logs
|
||||
```
|
||||
|
||||
## Production notes
|
||||
|
||||
- Docker Compose is the most repeatable Linux container path.
|
||||
- systemd user services are useful for Linux user-level gateway deployments.
|
||||
- macOS LaunchAgent keeps the gateway alive after login.
|
||||
- Persist config, workspace, sessions, memory files, channel login state, and
|
||||
generated artifacts.
|
||||
- Restart the gateway after editing `config.json`.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Plan ports before exposing services. Gateway health defaults to `18790`,
|
||||
WebUI/WebSocket defaults to `8765`, and `nanobot serve` defaults to `8900`.
|
||||
- Bind externally only when you have configured tokens or API keys.
|
||||
- Keep chat access control intentional before deploying.
|
||||
- Use Docker or Linux sandboxing when shell tools are enabled for unattended
|
||||
work.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Use the same `--config` and `--workspace` flags for status checks and service
|
||||
startup.
|
||||
- Check logs with `docker compose logs`, `journalctl`, LaunchAgent logs, or
|
||||
`nanobot gateway --verbose`.
|
||||
- If Docker port publishing does not work, confirm the service is not bound only
|
||||
to container loopback.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Deployment](../deployment.md)
|
||||
- [Multiple Instances](../multiple-instances.md)
|
||||
- [Configuration](../configuration.md)
|
||||
@@ -0,0 +1,107 @@
|
||||
# Build a Discord AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to Discord so a Discord user or server channel can
|
||||
talk to your self-hosted AI agent through the nanobot gateway.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a Discord bot application
|
||||
- Message Content intent enabled
|
||||
- the `discord` channel enabled in nanobot
|
||||
- one direct message or mention test
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- Access to the Discord Developer Portal.
|
||||
- A Discord server where you can invite a bot.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Discord channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable discord
|
||||
```
|
||||
|
||||
Create a Discord application, add a bot, copy the token, and enable
|
||||
`MESSAGE CONTENT INTENT` in the bot settings.
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowChannels": [],
|
||||
"groupPolicy": "mention",
|
||||
"streaming": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot
|
||||
first, get a pairing code, and be approved before using the bot in servers.
|
||||
|
||||
Invite the bot with permissions to read history and send messages.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
Send the bot a DM first. It should return a pairing code. Approve it from a
|
||||
trusted local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
After approval, mention it in an allowed server channel:
|
||||
|
||||
```text
|
||||
@your-bot Hello from Discord
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Keep `groupPolicy` as `mention` for first deployment.
|
||||
- Use `allowChannels` for server channels where the bot should operate.
|
||||
- Prefer pairing-only mode for user access; add `allowFrom` only when you want a
|
||||
static allowlist.
|
||||
- Avoid open group behavior in busy channels until session routing is clear.
|
||||
- Review tool access before inviting the bot into shared servers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If no messages arrive, confirm Message Content intent is enabled.
|
||||
- If a DM returns a pairing code, approve it before testing normal replies.
|
||||
- If server messages are ignored, check pairing approval, `allowChannels`, and
|
||||
whether the bot was mentioned.
|
||||
- If the bot cannot reply, confirm the invite permissions and channel overrides.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Build an Email AI Agent with nanobot
|
||||
|
||||
This guide turns nanobot into an email AI agent that polls IMAP for accepted
|
||||
messages and replies through SMTP.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a dedicated mailbox for nanobot
|
||||
- IMAP and SMTP credentials in `config.json`
|
||||
- an allowed sender list
|
||||
- a gateway process that polls and replies
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A mailbox for the bot.
|
||||
- IMAP and SMTP access. For Gmail, use an app password rather than your account
|
||||
password.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Email channel
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json` and replace the addresses and
|
||||
passwords:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"consentGranted": true,
|
||||
"imapHost": "imap.gmail.com",
|
||||
"imapPort": 993,
|
||||
"imapUsername": "my-nanobot@gmail.com",
|
||||
"imapPassword": "your-app-password",
|
||||
"smtpHost": "smtp.gmail.com",
|
||||
"smtpPort": 587,
|
||||
"smtpUsername": "my-nanobot@gmail.com",
|
||||
"smtpPassword": "your-app-password",
|
||||
"fromAddress": "my-nanobot@gmail.com",
|
||||
"allowFrom": ["your-real-email@gmail.com"],
|
||||
"autoReplyEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
Send an email from an address in `allowFrom` to the bot mailbox. Keep the
|
||||
gateway running long enough for the polling interval to receive it.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Use a dedicated mailbox, not your primary personal inbox.
|
||||
- Set `consentGranted` to `false` to fully disable mailbox access.
|
||||
- Email does not use DM pairing. Keep `allowFrom` narrow; `["*"]` accepts mail
|
||||
from anyone.
|
||||
- Use environment variables for mailbox passwords.
|
||||
- Enable attachment types only when the agent needs them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If login fails, confirm IMAP/SMTP access and app-password setup.
|
||||
- If the bot reads but does not reply, check `autoReplyEnabled`, SMTP settings,
|
||||
and allowed sender addresses.
|
||||
- If attachments are missing, review `allowedAttachmentTypes`, size limits, and
|
||||
gateway logs.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [OpenAI-compatible agent API](./openai-compatible-agent-api.md)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Build a Feishu AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to Feishu or Lark through the `feishu` channel. The
|
||||
channel uses a WebSocket long connection, so the first setup does not require a
|
||||
public webhook URL.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a Feishu/Lark bot app connected to nanobot
|
||||
- the `feishu` channel enabled in `config.json`
|
||||
- one pairing-approved Feishu or Lark user
|
||||
- mention-only group behavior for first deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A Feishu or Lark account that can create or approve bot apps.
|
||||
- Permission to run `nanobot gateway` continuously.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Feishu channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable feishu
|
||||
```
|
||||
|
||||
The easiest path is QR login:
|
||||
|
||||
```bash
|
||||
nanobot channels login feishu
|
||||
```
|
||||
|
||||
Open the printed URL or scan the QR code. nanobot writes the generated `appId`,
|
||||
`appSecret`, `domain`, and `enabled` fields into the active config.
|
||||
|
||||
If QR login is unavailable, create a Feishu/Lark app manually and merge this
|
||||
shape into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"groupPolicy": "mention",
|
||||
"streaming": true,
|
||||
"domain": "feishu"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot,
|
||||
get a pairing code, and be approved before using the bot normally.
|
||||
|
||||
For manual apps, enable the Bot capability, receive-message events, and Long
|
||||
Connection mode. If your app cannot get the `cardkit:card:write` permission,
|
||||
set `"streaming": false`.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
DM the bot first. It should return a pairing code. Approve it from a trusted
|
||||
local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
After approval, DM the bot again or mention it in a group chat:
|
||||
|
||||
```text
|
||||
@nanobot Hello from Feishu
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||
static allowlist.
|
||||
- Keep `groupPolicy` as `"mention"` before inviting the bot into busy groups.
|
||||
- Store app secrets through environment variables for deployed services.
|
||||
- Review file, shell, and web tool access before adding more users.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If QR login is unavailable, use manual app setup from the full chat-apps
|
||||
reference.
|
||||
- If streaming cards fail, confirm `cardkit:card:write` or set
|
||||
`"streaming": false`.
|
||||
- If no messages arrive, check Feishu/Lark event permissions, Long Connection
|
||||
mode, and `nanobot gateway --verbose`.
|
||||
- If a first DM returns a pairing code, approve it before testing normal
|
||||
replies.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||
@@ -0,0 +1,73 @@
|
||||
# How to Run a Long-Running AI Agent with nanobot
|
||||
|
||||
nanobot can keep agent work alive across turns through sustained goals,
|
||||
persistent sessions, scheduled automations, local triggers, and a gateway
|
||||
process that stays running.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a working local agent
|
||||
- a persistent chat session
|
||||
- a long-running goal or automation
|
||||
- a gateway process for background delivery
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this when the task is not a one-shot answer: project work, recurring checks,
|
||||
scheduled summaries, file maintenance, multi-step research, or local triggers
|
||||
from scripts and build jobs.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Start a gateway:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
From the WebUI or a chat session, start a sustained goal:
|
||||
|
||||
```text
|
||||
/goal Review this workspace, identify missing tests, and propose the smallest next fix.
|
||||
```
|
||||
|
||||
For scheduled or trigger-based runs, create the automation from the target chat
|
||||
so nanobot can link it to the correct session and workspace.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Keep the gateway running for chat apps, WebUI sessions, automations, and local
|
||||
triggers.
|
||||
- Use stable session keys or chat sessions for work that should preserve context.
|
||||
- Keep goals bounded and explicit about done-ness.
|
||||
- Review Automations in the WebUI before relying on a schedule.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat long-running goals as delegated work with real tool access.
|
||||
- Restrict workspaces and shell execution before scheduling unattended tasks.
|
||||
- Keep chat access narrow so unknown users cannot create goals or automations.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If a goal appears stuck, inspect the active session and gateway logs.
|
||||
- If an automation does not run, check that it is linked to a chat/session and
|
||||
that the gateway is still running.
|
||||
- If a local trigger fails, check the command copied from the WebUI Automations
|
||||
view.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Automations](../automations.md)
|
||||
- [WebUI Automations](../webui.md#automations)
|
||||
- [Chat Commands](../chat-commands.md)
|
||||
- [Memory](../memory.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Build a Mattermost AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to Mattermost through the built-in Mattermost
|
||||
channel, using WebSocket events and the Mattermost REST API.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a Mattermost bot account or token
|
||||
- the `mattermost` channel enabled in nanobot
|
||||
- mention-only group behavior for first deployment
|
||||
- one pairing-approved DM or mention test
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A Mattermost server URL.
|
||||
- A bot token or personal access token for the bot account.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Mattermost channel
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"mattermost": {
|
||||
"enabled": true,
|
||||
"serverUrl": "https://mattermost.example.com",
|
||||
"token": "YOUR_MATTERMOST_TOKEN",
|
||||
"teamId": "YOUR_TEAM_ID",
|
||||
"groupPolicy": "mention",
|
||||
"replyInThread": true,
|
||||
"dm": {
|
||||
"policy": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`teamId` scopes the channel to a Mattermost team. Keep `groupPolicy` as
|
||||
`mention` for the first test.
|
||||
|
||||
Mattermost DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
|
||||
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
|
||||
code before using the bot normally.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
DM the bot account. It should return a pairing code. Approve it from a trusted
|
||||
local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Then DM the bot again, or mention it in a channel where the bot has access:
|
||||
|
||||
```text
|
||||
@nanobot Hello from Mattermost
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Store the Mattermost token in an environment variable for deployed services.
|
||||
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
|
||||
- Use mention-only group behavior before opening the bot to busy channels.
|
||||
- Review file and shell tools before inviting broad channel access.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If startup logs say `serverUrl and token must be configured`, check the
|
||||
camelCase config keys.
|
||||
- If DMs are ignored, review the `dm` policy and pairing approval state.
|
||||
- If channel messages are ignored, confirm the bot is mentioned and belongs to
|
||||
the team/channel.
|
||||
- If thread replies are surprising, review `replyInThread` and
|
||||
`includeThreadContext`.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,75 @@
|
||||
# How to Add MCP Tools to an AI Agent with nanobot
|
||||
|
||||
nanobot can connect MCP servers and expose their tools to the agent alongside
|
||||
built-in file, shell, web, cron, image generation, and subagent tools.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a working nanobot agent
|
||||
- one MCP server configured in `config.json`
|
||||
- a restricted set of tools available to the model
|
||||
|
||||
## When to use this
|
||||
|
||||
Use MCP when a tool already exists as an MCP server, when another application
|
||||
publishes an MCP adapter, or when you want a clean boundary between nanobot and
|
||||
external tool logic.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Install the MCP server's own runtime separately. For example, many local MCP
|
||||
servers use `npx` or `uvx`.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Add a stdio MCP server to `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
||||
"enabledTools": ["read_file"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart nanobot, then ask a question that needs the MCP tool.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use `enabledTools` to expose only the tools the agent actually needs.
|
||||
- Set `toolTimeout` for slow MCP servers.
|
||||
- Prefer stdio MCP for local tools and HTTP MCP for trusted remote services.
|
||||
- Keep MCP server install/update steps outside nanobot config when possible.
|
||||
|
||||
## Security notes
|
||||
|
||||
- HTTP/SSE MCP URLs use the same SSRF guard as web fetch.
|
||||
- Local/private HTTP endpoints require an explicit `tools.ssrfWhitelist` entry.
|
||||
- Stdio MCP servers run local processes; review their command and arguments.
|
||||
- Do not pass secrets in command-line args when environment variables or headers
|
||||
are available.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Start `nanobot gateway --verbose` and check MCP startup logs.
|
||||
- Confirm the MCP command works by itself before debugging nanobot.
|
||||
- If an HTTP MCP server is blocked, review the SSRF whitelist and use a narrow
|
||||
host CIDR.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
|
||||
- [Security](../configuration.md#security)
|
||||
@@ -0,0 +1,74 @@
|
||||
# How to Run an OpenAI-Compatible Agent API with nanobot
|
||||
|
||||
nanobot can expose a local OpenAI-compatible endpoint behind
|
||||
`/v1/chat/completions`. This lets existing OpenAI-style clients talk to a
|
||||
tool-using nanobot agent instead of a raw model.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a working nanobot agent
|
||||
- a local API server on `127.0.0.1:8900`
|
||||
- a `/v1/chat/completions` request
|
||||
- optional session isolation with `session_id`
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this when an existing client, another language, or a separate process
|
||||
already knows how to call an OpenAI-compatible API. Use the Python SDK when you
|
||||
want in-process access to sessions, memory, runtime helpers, and hooks.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot plugins enable api
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Start the API server:
|
||||
|
||||
```bash
|
||||
nanobot serve
|
||||
```
|
||||
|
||||
Call the chat endpoint:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8900/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"session_id": "demo"
|
||||
}'
|
||||
```
|
||||
|
||||
## Production notes
|
||||
|
||||
- Pass `session_id` to isolate users, jobs, or workflows.
|
||||
- Streaming uses Server-Sent Events when `stream` is `true`.
|
||||
- `/v1/models` reports the fixed model surface expected by compatible clients.
|
||||
- File uploads are supported through JSON base64 or multipart form data.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Local `127.0.0.1` usage does not require an API key.
|
||||
- If `api.host` is `0.0.0.0` or `::`, configure `api.apiKey` before startup.
|
||||
- Treat the API as agent access, not just model access: tools and workspace
|
||||
permissions still matter.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `/v1/chat/completions` fails, test `nanobot agent -m "Hello!"` first.
|
||||
- If remote clients cannot connect, check `api.host`, `api.port`, firewall, and
|
||||
API key configuration.
|
||||
- If sessions mix together, pass unique `session_id` values.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Nanobot OpenAI-Compatible API](../openai-api.md)
|
||||
- [Python SDK](../python-sdk.md)
|
||||
- [Configuration](../configuration.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Nanobot Python SDK: Run an AI Agent from Python
|
||||
|
||||
This guide shows when to use the Nanobot Python SDK instead of calling a model
|
||||
directly. The SDK runs the same agent runtime used by the CLI: model routing,
|
||||
tools, workspace access, session history, memory, streaming events, and runtime
|
||||
helpers.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a Python script that creates a `Nanobot`
|
||||
- one agent run from code
|
||||
- an optional streamed run with tool visibility
|
||||
|
||||
## When to use this
|
||||
|
||||
Use the Python SDK for notebooks, evals, product backends, local scripts,
|
||||
workflow runners, and integrations that need direct access to agent sessions,
|
||||
memory, hooks, runtime state, or structured run results.
|
||||
|
||||
Use the OpenAI-compatible API instead when another language or process should
|
||||
call nanobot over HTTP.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from nanobot import Nanobot
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Nanobot.from_config() as bot:
|
||||
result = await bot.run("List the top-level files in this workspace.")
|
||||
print(result.content)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Production notes
|
||||
|
||||
- Reuse one `Nanobot` instance for related work.
|
||||
- Pass `session_key` when a user, job, or eval case needs persistent history.
|
||||
- Use `bot.stream(...)` when the caller needs live text, tool, or failure
|
||||
events.
|
||||
- Use hooks for audit logs or custom observability.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The SDK uses the same config, workspace, tools, and secrets as the CLI.
|
||||
- Do not run untrusted prompts with broad file or shell access.
|
||||
- Keep separate config/workspace paths for separate products or tenants.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If SDK code fails, first run `nanobot agent -m "Hello!"` in the same
|
||||
environment.
|
||||
- Print `bot.runtime.workspace` and `bot.runtime.model` to confirm the expected
|
||||
config loaded.
|
||||
- Use explicit `config_path` and `workspace` when scripts run from services.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Nanobot Python SDK](../python-sdk.md)
|
||||
- [OpenAI-Compatible API](../openai-api.md)
|
||||
- [Configuration](../configuration.md)
|
||||
- [Concepts](../concepts.md)
|
||||
@@ -0,0 +1,102 @@
|
||||
# Build a QQ AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to QQ through the official `qq` channel. The
|
||||
official channel uses the botpy SDK and currently focuses on private messages.
|
||||
For QQ group chat and OneBot v11 workflows, use the Napcat section in the full
|
||||
chat-apps reference.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a QQ bot application
|
||||
- the `qq` channel enabled in nanobot
|
||||
- one pairing-approved QQ private sender
|
||||
- a running nanobot gateway
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- Access to the QQ Open Platform.
|
||||
- A QQ account added to the bot sandbox for testing.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the QQ channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable qq
|
||||
```
|
||||
|
||||
In the QQ Open Platform, create a bot application and copy the AppID and
|
||||
AppSecret. Add your QQ account to the sandbox test members, then merge this
|
||||
snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"appId": "YOUR_APP_ID",
|
||||
"secret": "YOUR_APP_SECRET",
|
||||
"msgFormat": "plain"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode. A new private sender should get
|
||||
a pairing code before normal agent access.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
Send the QQ bot a private message from a sandbox account. It should return a
|
||||
pairing code. Approve it from a trusted local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Send the message again after approval.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||
static allowlist.
|
||||
- Keep sandbox testing separate from production publishing.
|
||||
- Store QQ AppSecret through environment variables for deployed services.
|
||||
- Use Napcat only when you intentionally need a QQ account bridge and group chat
|
||||
features.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If private messages do not arrive, confirm the sender is in the QQ bot sandbox
|
||||
and the gateway is running.
|
||||
- If output formatting is unreliable, keep `msgFormat` as `"plain"`.
|
||||
- If a first private message returns a pairing code, approve it before testing
|
||||
normal replies.
|
||||
- If you need QQ groups, see the Napcat section in the full chat-apps reference.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||
@@ -0,0 +1,78 @@
|
||||
# How to Secure a Local AI Agent with nanobot
|
||||
|
||||
This guide covers the practical controls to review before letting a nanobot
|
||||
agent access files, shell commands, web fetch, chat apps, or remote users.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a workspace-scoped agent setup
|
||||
- narrow channel access
|
||||
- safer secrets handling
|
||||
- optional shell sandboxing on Linux
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this before exposing nanobot to teammates, chat apps, public networks, broad
|
||||
web access, or unattended automations.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## Minimal working example
|
||||
|
||||
Start with workspace restriction:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"restrictToWorkspace": true,
|
||||
"exec": {
|
||||
"enable": true,
|
||||
"sandbox": "bwrap"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`bwrap` is Linux-only and requires bubblewrap. On macOS or Windows, keep
|
||||
`restrictToWorkspace` enabled and review shell access carefully.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use environment variables for provider keys, bot tokens, and mailbox
|
||||
passwords.
|
||||
- Keep one workspace per trust boundary.
|
||||
- Prefer pairing for DM-capable chat apps, use narrow `allowFrom` lists only
|
||||
when static allowlists are intentional, and keep group policy mention-only at
|
||||
first.
|
||||
- Bind WebUI, WebSocket, and API services to localhost unless remote access is
|
||||
intentional.
|
||||
|
||||
## Security notes
|
||||
|
||||
- `restrictToWorkspace` is an application-level guard, not an OS sandbox.
|
||||
- `tools.exec.enable: false` removes shell execution entirely.
|
||||
- HTTP web fetch and HTTP MCP use SSRF protections by default.
|
||||
- Adding broad `tools.ssrfWhitelist` ranges increases exposure.
|
||||
- `allowFrom: ["*"]` bypasses pairing and means anyone who can reach that
|
||||
channel can talk to the bot.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If a needed file cannot be read, confirm the active workspace path.
|
||||
- If a shell command fails under `bwrap`, check whether the command needs files
|
||||
outside the sandbox.
|
||||
- If local HTTP tools are blocked, review the SSRF whitelist and use a narrow
|
||||
CIDR.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Configuration: Security](../configuration.md#security)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [Deployment](../deployment.md)
|
||||
- [Chat Apps](../chat-apps.md)
|
||||
@@ -0,0 +1,83 @@
|
||||
# How to Run a Self-Hosted AI Agent with nanobot
|
||||
|
||||
This guide sets up nanobot as a self-hosted AI agent runtime on your own
|
||||
machine or server. The result is a gateway process that can serve the WebUI,
|
||||
chat apps, automations, and API integrations.
|
||||
|
||||
## What you will build
|
||||
|
||||
- a nanobot config and workspace under your control
|
||||
- a model provider connected through `config.json`
|
||||
- a long-running `nanobot gateway`
|
||||
- optional browser, chat app, and API access
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this path when you want local or server-side ownership of the agent process,
|
||||
workspace files, memory files, and provider keys. It is also the right path when
|
||||
the agent must keep running after one terminal command finishes.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Complete the CLI check before deploying the gateway. A deployment problem is
|
||||
much easier to debug after the provider and model are known to work.
|
||||
|
||||
## Minimal working example
|
||||
|
||||
For chat apps, automations, and WebSocket delivery, start the gateway:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
For the browser surface, use the WebUI launcher instead. It can start and manage
|
||||
the local gateway for you:
|
||||
|
||||
```bash
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Or connect a channel in `~/.nanobot/config.json`, then keep the same gateway
|
||||
process running for messages.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Use Docker, systemd, or a macOS LaunchAgent when the process should survive
|
||||
terminal exits.
|
||||
- Give every deployed instance a distinct config path, workspace path, and port
|
||||
set.
|
||||
- Keep secrets in environment variables and start the service from the same
|
||||
environment.
|
||||
- Use health checks against the gateway or API process, not chat app delivery as
|
||||
the only signal.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Bind local-only services to `127.0.0.1` unless you intentionally expose them.
|
||||
- Set an API key before binding the OpenAI-compatible API to a public interface.
|
||||
- Prefer pairing for DM-capable chat apps, and keep any static `allowFrom`
|
||||
allowlists strict.
|
||||
- Enable `tools.restrictToWorkspace`; on Linux, use the bubblewrap sandbox for
|
||||
shell execution.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Run `nanobot status` with the same `--config` and `--workspace` flags used by
|
||||
the service.
|
||||
- Run `nanobot gateway --verbose` while debugging channel startup.
|
||||
- Check port conflicts if the WebUI, WebSocket channel, or API endpoint fails to
|
||||
bind.
|
||||
|
||||
## Related nanobot docs
|
||||
|
||||
- [Deployment](../deployment.md)
|
||||
- [Multiple Instances](../multiple-instances.md)
|
||||
- [Configuration](../configuration.md)
|
||||
- [Chat Apps](../chat-apps.md)
|
||||
- [OpenAI-Compatible API](../openai-api.md)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Build a Slack AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to Slack through Socket Mode. No public webhook URL
|
||||
is required for the first working setup.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a Slack app with Socket Mode
|
||||
- a bot token and app-level token
|
||||
- the `slack` channel enabled in nanobot
|
||||
- a DM pairing flow and mention test from an approved Slack user
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- Permission to create a Slack app in a workspace.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Slack channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable slack
|
||||
```
|
||||
|
||||
In Slack, create an app, enable Socket Mode, create an app-level token with
|
||||
`connections:write`, add bot scopes, subscribe to bot events, and install the
|
||||
app to your workspace.
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"botToken": "xoxb-...",
|
||||
"appToken": "xapp-...",
|
||||
"groupPolicy": "mention",
|
||||
"dm": {
|
||||
"policy": "allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slack DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
|
||||
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
|
||||
code before using the bot normally.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
DM the Slack bot directly. It should return a pairing code. Approve it from a
|
||||
trusted local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Then DM the bot again, or mention it in a channel:
|
||||
|
||||
```text
|
||||
@nanobot Hello from Slack
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Keep `groupPolicy` as `mention` unless the bot is intentionally listening to
|
||||
every channel message.
|
||||
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
|
||||
- Use `groupAllowFrom` with allowlist mode for approved channels.
|
||||
- Reinstall the Slack app after changing scopes.
|
||||
- Keep bot and app tokens out of committed config files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If Socket Mode fails, confirm the app-level token starts with `xapp-`.
|
||||
- If the bot cannot send files, add `files:write`, reinstall the app, and
|
||||
restart nanobot.
|
||||
- If a DM responds normally without pairing, check that `dm.policy` is
|
||||
`"allowlist"`.
|
||||
- If channel messages are ignored, check event subscriptions and group policy.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Configure web search](./configure-web-search.md)
|
||||
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,109 @@
|
||||
# Build a Telegram AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to Telegram so a paired Telegram user can message a
|
||||
self-hosted AI agent backed by your normal nanobot config, tools, memory, and
|
||||
workspace.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- a Telegram bot created through BotFather
|
||||
- the `telegram` channel enabled in nanobot
|
||||
- a running nanobot gateway
|
||||
- one pairing-approved Telegram account
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working nanobot CLI reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A Telegram account.
|
||||
- A bot token from `@BotFather`.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the Telegram channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable telegram
|
||||
```
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
|
||||
gets a pairing code instead of agent access.
|
||||
|
||||
Telegram uses long polling by default. Webhook mode is available for public
|
||||
HTTPS deployments; start with long polling for the first test.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the gateway running while you test messages.
|
||||
|
||||
## Test a message
|
||||
|
||||
Open Telegram, DM the bot, and send:
|
||||
|
||||
```text
|
||||
Hello from Telegram
|
||||
```
|
||||
|
||||
The bot should reply with a pairing code. Approve it from an already trusted
|
||||
surface, such as the local CLI:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Send the message again after approval. The reply should use the same model and
|
||||
workspace as your local CLI check.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||
static allowlist instead of code approval.
|
||||
- Do not use `allowFrom: ["*"]` unless the bot is isolated or intentionally public.
|
||||
- Rotate the BotFather token if it is pasted into logs or shared files.
|
||||
- Review tool access before adding group chats or more users.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If the channel is not listed, run `nanobot plugins enable telegram` again in
|
||||
the same Python environment.
|
||||
- If messages do not arrive, run `nanobot gateway --verbose` and check the bot
|
||||
token.
|
||||
- If a first DM returns a pairing code, that is expected. Approve the code before
|
||||
testing normal agent replies.
|
||||
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [Long-running AI Agent](./long-running-ai-agent.md)
|
||||
- [Configure MCP tools](./configure-mcp-tools.md)
|
||||
@@ -0,0 +1,103 @@
|
||||
# Build a WeChat AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to WeChat through the `weixin` channel. The channel
|
||||
uses HTTP long polling with QR-code login through the supported upstream API.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- the `weixin` channel enabled in nanobot
|
||||
- a QR-code login session
|
||||
- one pairing-approved WeChat sender
|
||||
- a running gateway for message delivery
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A WeChat account that can complete QR-code login.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the WeChat channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable weixin
|
||||
```
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"weixin": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode. The first private WeChat message
|
||||
from a new sender gets a pairing code instead of agent access.
|
||||
|
||||
Log in:
|
||||
|
||||
```bash
|
||||
nanobot channels login weixin
|
||||
```
|
||||
|
||||
Use `--force` if you need to discard saved login state and authenticate again.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
Send a private WeChat message to the bot. It should reply with a pairing code.
|
||||
Approve it from a trusted local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Send the message again after approval and watch gateway logs for the sender ID
|
||||
and reply.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||
static allowlist.
|
||||
- Treat saved login state as sensitive account access.
|
||||
- Avoid connecting personal accounts to untrusted workspaces or broad tool
|
||||
permissions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If login fails, rerun `nanobot channels login weixin --force`.
|
||||
- If a first private message returns a pairing code, that is expected. Approve
|
||||
the code before testing normal agent replies.
|
||||
- If messages are denied without a pairing code, check gateway logs for whether
|
||||
WeChat provided the context token required for nanobot to reply.
|
||||
- If polling disconnects, restart the gateway and check network reachability to
|
||||
the upstream service.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [AI Agent Memory](./ai-agent-memory.md)
|
||||
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -0,0 +1,107 @@
|
||||
# Build a WhatsApp AI Agent with nanobot
|
||||
|
||||
This guide connects nanobot to WhatsApp through the `whatsapp` channel. The
|
||||
channel links as a WhatsApp device and uses the same nanobot agent runtime,
|
||||
tools, memory, and workspace as the CLI and WebUI.
|
||||
|
||||
## What this guide builds
|
||||
|
||||
- WhatsApp optional dependencies installed
|
||||
- a linked WhatsApp device session
|
||||
- the `whatsapp` channel enabled in `config.json`
|
||||
- one pairing-approved WhatsApp sender
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A working local nanobot reply:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
- A WhatsApp account that can link a new device.
|
||||
- A machine that can keep `nanobot gateway` running.
|
||||
|
||||
## Install nanobot
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
## Enable the WhatsApp channel
|
||||
|
||||
Install the optional channel dependency:
|
||||
|
||||
```bash
|
||||
nanobot plugins enable whatsapp
|
||||
```
|
||||
|
||||
Link WhatsApp as a device:
|
||||
|
||||
```bash
|
||||
nanobot channels login whatsapp
|
||||
```
|
||||
|
||||
Scan the QR code from WhatsApp -> Settings -> Linked Devices.
|
||||
|
||||
Merge this snippet into `~/.nanobot/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"groupPolicy": "mention"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omitting `allowFrom` enables pairing-only mode for private chats. `groupPolicy`
|
||||
defaults to `"open"` in the channel, but `"mention"` is safer for a first
|
||||
deployment.
|
||||
|
||||
## Run nanobot gateway
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
## Test a message
|
||||
|
||||
Send the bot a private WhatsApp message. It should return a pairing code.
|
||||
Approve it from a trusted local surface:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "/pairing approve ABCD-EFGH"
|
||||
```
|
||||
|
||||
Send the message again after approval. The reply should use the same model and
|
||||
workspace as your local CLI check.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Treat the WhatsApp session database as account access.
|
||||
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
|
||||
static allowlist.
|
||||
- Keep `groupPolicy` as `"mention"` before adding the bot to groups.
|
||||
- Avoid `allowFrom: ["*"]` unless the bot is intentionally public or isolated.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If QR linking fails, rerun `nanobot channels login whatsapp`.
|
||||
- If you are migrating from the old bridge, remove `bridgeUrl` and
|
||||
`bridgeToken`, then re-login.
|
||||
- If a sender appears as a LID instead of a phone number, let nanobot learn the
|
||||
mapping at runtime or use `lidMappings` in the full reference.
|
||||
- If a first private message returns a pairing code, approve it before testing
|
||||
normal replies.
|
||||
|
||||
## Next: memory, automations, MCP tools
|
||||
|
||||
- [Chat Apps reference](../chat-apps.md)
|
||||
- [Pairing](../configuration.md#pairing)
|
||||
- [Secure local AI agent](./secure-local-ai-agent.md)
|
||||
- [Deployment](../deployment.md)
|
||||
@@ -1,11 +1,20 @@
|
||||
# Image Generation
|
||||
|
||||
nanobot can generate and edit images through the `generate_image` tool. In the WebUI, users can enable **Image Generation** from the composer, choose an aspect ratio, and keep iterating on generated images inside the same chat.
|
||||
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
||||
|
||||
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
||||
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, save, and restart when prompted. If that screen is not available in your installed version, use the manual config below.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
**WebUI**
|
||||
|
||||
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
||||
2. Open **Settings → Image**.
|
||||
3. Select the provider and image model, then enable image generation.
|
||||
4. Save, restart when prompted, and ask for a simple test image.
|
||||
|
||||
**Manual config**
|
||||
|
||||
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
|
||||
|
||||
```json
|
||||
@@ -32,11 +41,9 @@ See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Oll
|
||||
|
||||
## WebUI Usage
|
||||
|
||||
In the WebUI composer:
|
||||
|
||||
1. Click **Image Generation**.
|
||||
2. Choose an aspect ratio: `Auto`, `1:1`, `3:4`, `9:16`, `4:3`, or `16:9`.
|
||||
3. Describe the image or the edit you want.
|
||||
1. Open Settings and enable **Image Generation** with a configured provider and model.
|
||||
2. Describe the image or edit you want in chat.
|
||||
3. Include an aspect ratio or size in the request when the configured defaults are not suitable.
|
||||
4. Attach reference images when editing an existing image.
|
||||
|
||||
Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact.
|
||||
@@ -272,7 +279,7 @@ StepPlan is StepFun's subscription tier and uses a different API base URL. The i
|
||||
"providers": {
|
||||
"stepfun": {
|
||||
"apiKey": "${STEPFUN_API_KEY}",
|
||||
"apiBase": "https://api.stepfun.com/step_plan/v1"
|
||||
"apiBase": "https://api.stepfun.ai/step_plan/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -285,7 +292,7 @@ StepPlan is StepFun's subscription tier and uses a different API base URL. The i
|
||||
}
|
||||
```
|
||||
|
||||
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.com/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
|
||||
`apiBase` takes precedence over the registry default, so with the StepPlan base URL configured, image requests are sent to `https://api.stepfun.ai/step_plan/v1/images/generations` — the same path prefix used for LLM calls. The API key is shared with the standard StepFun provider.
|
||||
|
||||
### Zhipu
|
||||
|
||||
|
||||
+32
-1
@@ -1,4 +1,8 @@
|
||||
# Memory in nanobot
|
||||
# AI Agent Memory in nanobot
|
||||
|
||||
This page explains how nanobot implements long-term AI agent memory: session
|
||||
history, compressed archives, durable knowledge files, Dream consolidation, and
|
||||
Git-backed memory changes.
|
||||
|
||||
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
|
||||
|
||||
@@ -64,6 +68,9 @@ This is why nanobot's memory is not just archival. It is interpretive.
|
||||
workspace/
|
||||
├── SOUL.md # The bot's long-term voice and communication style
|
||||
├── USER.md # Stable knowledge about the user
|
||||
├── prompts/
|
||||
│ ├── README.md # Notes for memory guidance files
|
||||
│ └── dream.md # Optional instructions for how Dream organizes memory
|
||||
└── memory/
|
||||
├── MEMORY.md # Project facts, decisions, and durable context
|
||||
├── history.jsonl # Append-only history summaries
|
||||
@@ -120,6 +127,8 @@ Memory is not hidden behind the curtain. Users can inspect and guide it.
|
||||
| `/dream-log <sha>` | Show a specific Dream change |
|
||||
| `/dream-restore` | List recent Dream memory versions |
|
||||
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
||||
| `/dream-prompt` | Show how Dream is being guided for memory |
|
||||
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
|
||||
|
||||
These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it.
|
||||
|
||||
@@ -135,6 +144,28 @@ This gives memory a history of its own:
|
||||
|
||||
That turns memory from a silent mutation into an auditable process.
|
||||
|
||||
## Guiding Dream
|
||||
|
||||
Dream decides what to keep, update, or forget using nanobot's built-in memory instructions. Most users can leave this alone.
|
||||
|
||||
If one workspace needs a different memory style, create an editable guide:
|
||||
|
||||
```text
|
||||
/dream-prompt init
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
```text
|
||||
workspace/prompts/dream.md
|
||||
```
|
||||
|
||||
Edit that file in plain Markdown. When it has content, Dream follows it for this workspace before reading the latest conversation history. You do not need to paste history into the file; Dream adds the current `## Conversation History` block automatically.
|
||||
|
||||
To return to nanobot's default behavior, delete `prompts/dream.md` or leave it empty.
|
||||
|
||||
Each workspace has its own guide. Changing this file does not affect other nanobot workspaces.
|
||||
|
||||
## Configuration
|
||||
|
||||
Dream is configured under `agents.defaults.dream`:
|
||||
|
||||
@@ -22,6 +22,9 @@ Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. w
|
||||
**Run instances:**
|
||||
|
||||
```bash
|
||||
# Check one instance before starting it
|
||||
nanobot status --config ~/.nanobot-telegram/config.json
|
||||
|
||||
# Instance A - Telegram bot
|
||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||
|
||||
@@ -42,6 +45,9 @@ To open a CLI session against one of these instances locally:
|
||||
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
|
||||
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
|
||||
|
||||
# Open the browser workbench for a specific instance
|
||||
nanobot webui -c ~/.nanobot-telegram/config.json
|
||||
|
||||
# Optional one-off workspace override
|
||||
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
||||
```
|
||||
@@ -94,6 +100,7 @@ The copied base config can keep using the same `modelPresets` and `agents.defaul
|
||||
Start separate instances:
|
||||
|
||||
```bash
|
||||
nanobot status --config ~/.nanobot-telegram/config.json
|
||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||
nanobot gateway --config ~/.nanobot-discord/config.json
|
||||
```
|
||||
|
||||
+13
-8
@@ -38,8 +38,8 @@ Without parameters, returns a key config overview:
|
||||
```text
|
||||
my(action="check")
|
||||
# → max_iterations: 40
|
||||
# context_window_tokens: 65536
|
||||
# model: 'anthropic/claude-sonnet-4-20250514'
|
||||
# context_window_tokens: 200000
|
||||
# model: 'anthropic/claude-sonnet-4-6'
|
||||
# workspace: PosixPath('/tmp/workspace')
|
||||
# provider_retry_mode: 'standard'
|
||||
# max_tool_result_chars: 16000
|
||||
@@ -66,6 +66,7 @@ my(action="check", key="web_config.enable")
|
||||
| Scenario | How |
|
||||
|----------|-----|
|
||||
| "What model are you using?" | `check("model")` |
|
||||
| "Which model preset is active?" | `check("model_preset")` |
|
||||
| "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` |
|
||||
| "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns |
|
||||
| "Where is your working directory?" | `check("workspace")` |
|
||||
@@ -82,10 +83,13 @@ Changes take effect immediately, no restart required.
|
||||
my(action="set", key="max_iterations", value=80)
|
||||
# → Bump iteration limit from 40 to 80
|
||||
|
||||
my(action="set", key="model", value="fast-model")
|
||||
# → Switch to a faster model
|
||||
my(action="set", key="model_preset", value="fast")
|
||||
# → Switch to a configured model preset
|
||||
|
||||
my(action="set", key="context_window_tokens", value=131072)
|
||||
my(action="set", key="model", value="fast-model")
|
||||
# → Switch to a raw model and clear the active preset
|
||||
|
||||
my(action="set", key="context_window_tokens", value=262144)
|
||||
# → Expand context window for long documents
|
||||
```
|
||||
|
||||
@@ -107,6 +111,7 @@ These parameters have type and range validation — invalid values are rejected:
|
||||
| `max_iterations` | int | 1–100 | Max tool calls per conversation turn |
|
||||
| `context_window_tokens` | int | 4,096–1,000,000 | Context window size |
|
||||
| `model` | str | non-empty | LLM model to use |
|
||||
| `model_preset` | str | configured preset name | Named preset to use |
|
||||
|
||||
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
|
||||
|
||||
@@ -118,14 +123,14 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
|
||||
|
||||
```text
|
||||
Agent: This codebase is large, let me expand my context window to handle it.
|
||||
→ my(action="set", key="context_window_tokens", value=131072)
|
||||
→ my(action="set", key="context_window_tokens", value=262144)
|
||||
```
|
||||
|
||||
### "Simple question, don't waste compute"
|
||||
|
||||
```text
|
||||
Agent: This is a straightforward question, let me switch to a faster model.
|
||||
→ my(action="set", key="model", value="fast-model")
|
||||
Agent: This is a straightforward question, let me switch to the fast preset.
|
||||
→ my(action="set", key="model_preset", value="fast")
|
||||
```
|
||||
|
||||
### "Remember user preferences across turns"
|
||||
|
||||
+28
-2
@@ -1,9 +1,9 @@
|
||||
# OpenAI-Compatible API
|
||||
# Nanobot OpenAI-Compatible API: Run a Local Agent Behind /v1/chat/completions
|
||||
|
||||
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
|
||||
|
||||
```bash
|
||||
python -m pip install "nanobot-ai[api]"
|
||||
nanobot plugins enable api
|
||||
nanobot agent -m "Hello!"
|
||||
nanobot serve
|
||||
```
|
||||
@@ -12,6 +12,32 @@ Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or c
|
||||
|
||||
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
|
||||
|
||||
## Authentication
|
||||
|
||||
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
|
||||
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
|
||||
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
|
||||
endpoint on the network.
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8900,
|
||||
"apiKey": "${NANOBOT_API_KEY}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
|
||||
endpoint remains unauthenticated so local probes and load balancers can still
|
||||
check process health.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8900/v1/models \
|
||||
-H "Authorization: Bearer $NANOBOT_API_KEY"
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
|
||||
|
||||
+120
-2
@@ -15,8 +15,10 @@ Match the recipe to the credential or endpoint you already have:
|
||||
| What you have | Recipe | Must match |
|
||||
|---|---|---|
|
||||
| A gateway key and model IDs that include a model family path, such as `provider/model-name` | [OpenRouter Gateway](#recipe-openrouter-gateway) | API key, provider config key, preset provider, and gateway model ID |
|
||||
| An OpenCode Zen or Go key | [OpenCode Zen or Go](#recipe-opencode-zen-or-go) | `OPENCODE_API_KEY`, the Zen/Go provider key, and a model ID from the matching OpenCode endpoint |
|
||||
| An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account |
|
||||
| An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID |
|
||||
| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, and `model: "kimi-for-coding"` |
|
||||
| An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint |
|
||||
| Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability |
|
||||
| vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name |
|
||||
@@ -25,7 +27,7 @@ Match the recipe to the credential or endpoint you already have:
|
||||
|
||||
## How to Use a Recipe
|
||||
|
||||
1. Install nanobot and run `nanobot onboard` or `nanobot onboard --wizard` once so `~/.nanobot/config.json` exists.
|
||||
1. Install nanobot and run `nanobot onboard` once so `~/.nanobot/config.json` exists. Use `nanobot onboard --wizard` if you prefer prompts over hand-editing JSON.
|
||||
2. Put secrets in environment variables when possible.
|
||||
3. Merge the recipe snippet into `~/.nanobot/config.json`.
|
||||
4. Run `nanobot status`.
|
||||
@@ -94,6 +96,79 @@ nanobot agent -m "Hello!"
|
||||
|
||||
If this fails with `401` or `unauthorized`, check that `OPENROUTER_API_KEY` is visible in the same terminal or service that starts nanobot. If it fails with `model not found`, choose a model ID that OpenRouter lists for your account.
|
||||
|
||||
## Recipe: OpenCode Zen or Go
|
||||
|
||||
This recipe applies when your credential comes from OpenCode Zen or OpenCode Go.
|
||||
Both providers use `OPENCODE_API_KEY`; pick the provider block that matches the
|
||||
subscription or balance you want to use.
|
||||
|
||||
OpenCode Zen:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeZen": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "OpenCode Zen",
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode Go:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "OpenCode Go",
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
OpenCode's docs list models across multiple endpoint types. The `opencode_zen`
|
||||
and `opencode_go` providers in nanobot use the OpenAI-compatible
|
||||
`chat/completions` path. If a model fails with `model not found` or an endpoint
|
||||
shape error, choose a model that OpenCode lists under `chat/completions` for the
|
||||
matching Zen or Go endpoint.
|
||||
|
||||
## Recipe: OpenAI Direct
|
||||
|
||||
This recipe applies when you have an OpenAI API key and want to call OpenAI directly instead of through a gateway.
|
||||
@@ -198,6 +273,43 @@ If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic
|
||||
|
||||
Do not configure Anthropic-compatible endpoints as arbitrary custom provider names; named custom providers use the OpenAI-compatible request format.
|
||||
|
||||
## Recipe: Kimi Coding Plan
|
||||
|
||||
This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobot uses a dedicated `kimi_coding` provider for this Anthropic Messages API endpoint; do not configure it as a generic `custom` provider.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"kimiCoding": {
|
||||
"apiKey": "${KIMI_CODING_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"kimiCoding": {
|
||||
"label": "Kimi Coding",
|
||||
"provider": "kimi_coding",
|
||||
"model": "kimi-for-coding",
|
||||
"maxTokens": 4096,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "kimiCoding"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
The default base URL is `https://api.kimi.com/coding/v1`. This endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default. If your account requires a different value, override it with `providers.kimiCoding.extraHeaders.User-Agent`.
|
||||
|
||||
## Recipe: Custom OpenAI-Compatible Provider
|
||||
|
||||
This recipe applies to an OpenAI-compatible service that is not a named nanobot provider.
|
||||
@@ -319,7 +431,13 @@ curl -sS http://localhost:11434/v1/models
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`.
|
||||
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If every response is slow, try a smaller local model or lower `contextWindowTokens`.
|
||||
|
||||
If direct Ollama responses are fast but tool-using nanobot turns repeatedly evaluate
|
||||
thousands of prompt tokens, the model's chat template may be moving its tool
|
||||
definitions between requests. See
|
||||
[Improve Ollama Tool-Calling Prompt Cache Reuse](./guides/configure-ollama-prompt-cache.md)
|
||||
for a diagnostic procedure and an optional model-specific workaround.
|
||||
|
||||
## Recipe: vLLM or LM Studio
|
||||
|
||||
|
||||
+81
-3
@@ -2,6 +2,8 @@
|
||||
|
||||
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
|
||||
|
||||
For normal local setup, open **Settings → Models** in the WebUI to add provider credentials, create a model preset, and select the active model. Use the JSON below for manual deployments, local endpoints, provider-specific fields, or diagnosis.
|
||||
|
||||
For every setup, answer three questions:
|
||||
|
||||
1. Which provider owns the credential or endpoint?
|
||||
@@ -17,6 +19,7 @@ The docs show concrete provider names so the JSON is copyable, not because nanob
|
||||
| If you have... | Configure... |
|
||||
|---|---|
|
||||
| An API key from a hosted provider or gateway | That provider's `providers.<name>.apiKey`, then a preset with that provider name and a model ID from that service. |
|
||||
| An OpenCode Zen or Go key | `providers.opencodeZen.apiKey` or `providers.opencodeGo.apiKey`, then a preset with `provider: "opencode_zen"` or `provider: "opencode_go"`. |
|
||||
| A company proxy or regional endpoint | The matching provider block plus `apiBase` if the proxy gives you a URL. |
|
||||
| A local OpenAI-compatible server | A local provider block such as `ollama`, `vllm`, `lmStudio`, or `custom`, usually with `apiBase`. |
|
||||
| An OAuth-based account | Run the matching `nanobot provider login ...` command, then select that provider explicitly in a preset. |
|
||||
@@ -60,9 +63,12 @@ These fields answer different questions:
|
||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
||||
|
||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
|
||||
## Common Provider Patterns
|
||||
|
||||
### OpenRouter Gateway
|
||||
@@ -94,6 +100,62 @@ Gateway-style setup for model IDs served through OpenRouter.
|
||||
|
||||
Use the model ID exactly as OpenRouter lists it.
|
||||
|
||||
### OpenCode Zen and Go
|
||||
|
||||
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
|
||||
They share `OPENCODE_API_KEY`, but use separate provider config keys and default base
|
||||
URLs in nanobot.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeZen": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For OpenCode Go, switch the provider block and preset:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"opencodeGo": {
|
||||
"apiKey": "${OPENCODE_API_KEY}"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"provider": "opencode_go",
|
||||
"model": "opencode-go/deepseek-v4-flash",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode documents model IDs with `opencode/<model-id>` for Zen and
|
||||
`opencode-go/<model-id>` for Go. nanobot accepts those prefixes and strips them
|
||||
before sending the request to OpenCode. Use model IDs that OpenCode lists under
|
||||
the `chat/completions` endpoint; models listed only under `responses`,
|
||||
`messages`, or provider-specific endpoints are not handled by this
|
||||
OpenAI-compatible provider path.
|
||||
|
||||
### Anthropic Direct
|
||||
|
||||
```json
|
||||
@@ -236,6 +298,8 @@ If you have more than one custom OpenAI-compatible endpoint, give each endpoint
|
||||
|
||||
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
|
||||
|
||||
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
|
||||
|
||||
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
|
||||
|
||||
### Ollama
|
||||
@@ -267,6 +331,13 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
||||
|
||||
Most Ollama setups do not require an API key.
|
||||
|
||||
Ollama renders the OpenAI-compatible messages and tools through each model's chat
|
||||
template. If ordinary model responses are fast but tool-using turns show low prompt
|
||||
cache reuse, diagnose the rendered template before changing nanobot's context or
|
||||
memory settings. The
|
||||
[Ollama prompt-cache guide](./guides/configure-ollama-prompt-cache.md) explains the
|
||||
log pattern and a tested `llama3.1:8b` workaround.
|
||||
|
||||
### vLLM or Other Local OpenAI-Compatible Server
|
||||
|
||||
```json
|
||||
@@ -356,12 +427,19 @@ See [`configuration.md#providers`](./configuration.md#providers) for Bedrock-spe
|
||||
|
||||
Some providers do not use API keys in `config.json`.
|
||||
|
||||
For OpenAI Codex:
|
||||
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
nanobot provider login github-copilot
|
||||
nanobot provider login openai-codex --set-main
|
||||
```
|
||||
|
||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
||||
For GitHub Copilot:
|
||||
|
||||
```bash
|
||||
nanobot provider login github-copilot --set-main
|
||||
```
|
||||
|
||||
Each command authenticates the selected provider and makes its current default model active. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
|
||||
+546
-22
@@ -1,16 +1,64 @@
|
||||
# Python SDK
|
||||
# Nanobot Python SDK: Run an AI Agent from Python
|
||||
|
||||
Use nanobot as a library — no CLI, no gateway, just Python.
|
||||
Use nanobot as a Python library. The SDK gives you the same agent runtime used
|
||||
by the CLI, but from code: model routing, tools, workspace access, conversation
|
||||
history, memory, streaming events, and runtime helpers.
|
||||
|
||||
Before debugging SDK code, prove the same config works from the CLI:
|
||||
If you have used the OpenAI SDK before, the most important difference is this:
|
||||
|
||||
- OpenAI SDK calls a model.
|
||||
- nanobot SDK runs an agent around a model.
|
||||
|
||||
That means one SDK call can read files, call tools, keep session history, use
|
||||
memory, stream progress, and return structured runtime information.
|
||||
|
||||
```text
|
||||
your Python code
|
||||
-> Nanobot SDK
|
||||
-> agent runtime
|
||||
-> configured model provider
|
||||
-> tools
|
||||
-> workspace
|
||||
-> session history
|
||||
-> memory
|
||||
```
|
||||
|
||||
## Before You Start
|
||||
|
||||
Install and configure nanobot first. If you have not done that yet, follow the
|
||||
[Quick Start](quick-start.md) and complete the setup wizard. For SDK-only Python
|
||||
environments, install the package with:
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
`Nanobot.from_config()` reuses your normal `~/.nanobot/config.json` and
|
||||
`~/.nanobot/workspace/`. Provider, model, tools, memory, and session behavior
|
||||
match the CLI unless you override them. For the difference between config and
|
||||
workspace, see [Concepts: Config vs Workspace](concepts.md#config-vs-workspace).
|
||||
|
||||
Before writing SDK code, run the same first-run checks from the main
|
||||
[Install and Quick Start](quick-start.md):
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
`nanobot status` should show the config path, workspace path, active model or
|
||||
preset, and provider summary. Then send one real message:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
`Nanobot.from_config()` reuses your normal `~/.nanobot/config.json`, so provider, model, tools, and workspace behavior match the CLI unless you override them.
|
||||
A normal assistant reply means install, config, provider/model selection, and
|
||||
workspace access are all usable. Once that works, the SDK should see the same
|
||||
runtime.
|
||||
|
||||
## Quick Start
|
||||
## 5-Minute Quick Start
|
||||
|
||||
### Ask One Question
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
@@ -27,21 +75,228 @@ async def main() -> None:
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Use `async with` when possible so MCP connections and background cleanup work are closed before the event loop exits. If you manage the instance manually, call `await bot.aclose()` in a `finally` block.
|
||||
Use `async with` when possible so tool connections and background cleanup are
|
||||
closed before the event loop exits. If you manage the instance manually, call
|
||||
`await bot.aclose()` in a `finally` block.
|
||||
|
||||
The SDK is async-first because agent runs may stream tokens, execute tools, and
|
||||
wait on external services. In a normal Python script, wrap your async function
|
||||
with `asyncio.run(...)` as shown above. In a notebook or another async app, call
|
||||
`await bot.run(...)` directly from your existing event loop.
|
||||
|
||||
### Inspect What Happened
|
||||
|
||||
`bot.run(...)` returns a `RunResult`, not just a string:
|
||||
|
||||
```python
|
||||
result = await bot.run("Review this repository")
|
||||
|
||||
print(result.content) # final answer
|
||||
print(result.tools_used) # tools the agent used
|
||||
print(result.usage) # token usage when available
|
||||
print(result.stop_reason) # why the run stopped
|
||||
```
|
||||
|
||||
### Continue A Conversation
|
||||
|
||||
Use a `session_key` when you want history to carry across turns. Different
|
||||
session keys are isolated from each other:
|
||||
|
||||
```python
|
||||
await bot.run("My name is Alice.", session_key="user:alice")
|
||||
result = await bot.run("What is my name?", session_key="user:alice")
|
||||
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
This is the SDK equivalent of giving each user, task, eval case, or workflow
|
||||
its own conversation thread.
|
||||
|
||||
### Stream A Long Answer
|
||||
|
||||
For live output, use `bot.stream(...)`:
|
||||
|
||||
```python
|
||||
from nanobot import STREAM_EVENT_TEXT_DELTA
|
||||
|
||||
async for event in bot.stream("Write a migration plan"):
|
||||
if event.type == STREAM_EVENT_TEXT_DELTA:
|
||||
print(event.delta, end="", flush=True)
|
||||
```
|
||||
|
||||
Streaming returns structured events, so you can also observe tool calls,
|
||||
reasoning chunks, completion, and failures.
|
||||
|
||||
## Complete Starter Script
|
||||
|
||||
Save this as `sdk_demo.py` after `nanobot agent -m "Hello!"` works:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from nanobot import (
|
||||
STREAM_EVENT_RUN_COMPLETED,
|
||||
STREAM_EVENT_RUN_FAILED,
|
||||
STREAM_EVENT_TEXT_DELTA,
|
||||
STREAM_EVENT_TOOL_STARTED,
|
||||
Nanobot,
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
prompt = " ".join(sys.argv[1:]) or "Explain what nanobot is in one paragraph."
|
||||
session_key = "sdk:demo"
|
||||
|
||||
async with Nanobot.from_config() as bot:
|
||||
print(f"model: {bot.runtime.model}")
|
||||
print(f"workspace: {bot.runtime.workspace}")
|
||||
print()
|
||||
|
||||
final_result = None
|
||||
async for event in bot.stream(prompt, session_key=session_key):
|
||||
if event.type == STREAM_EVENT_TEXT_DELTA:
|
||||
print(event.delta, end="", flush=True)
|
||||
elif event.type == STREAM_EVENT_TOOL_STARTED:
|
||||
print(f"\n[tool] {event.name}", flush=True)
|
||||
elif event.type == STREAM_EVENT_RUN_COMPLETED:
|
||||
final_result = event.result
|
||||
elif event.type == STREAM_EVENT_RUN_FAILED:
|
||||
raise RuntimeError(event.error or "nanobot run failed")
|
||||
|
||||
print()
|
||||
if final_result is not None:
|
||||
print(f"\nstop_reason: {final_result.stop_reason}")
|
||||
print(f"tools_used: {final_result.tools_used}")
|
||||
print(f"usage: {final_result.usage}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
python sdk_demo.py "List the top-level files in the current workspace."
|
||||
```
|
||||
|
||||
You should see the configured model, workspace path, streamed assistant text,
|
||||
and final run metadata. The exact answer depends on your config and workspace,
|
||||
but a file-listing prompt may look like this:
|
||||
|
||||
```text
|
||||
model: openai/gpt-4.1-mini
|
||||
workspace: /Users/alice/.nanobot/workspace
|
||||
|
||||
[tool] list_dir
|
||||
Here are the top-level files I found...
|
||||
|
||||
stop_reason: completed
|
||||
tools_used: ['list_dir']
|
||||
usage: {'prompt_tokens': ..., 'completion_tokens': ..., 'total_tokens': ...}
|
||||
```
|
||||
|
||||
This script shows the usual production shape: create one `Nanobot`, choose a
|
||||
stable `session_key`, stream events, keep the final `RunResult`, and let
|
||||
`async with` close runtime resources.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Meaning |
|
||||
|---------|---------|
|
||||
| `Nanobot` | The SDK object that owns one configured agent runtime. |
|
||||
| Run | One call to `bot.run(...)`, `bot.run_streamed(...)`, or `bot.stream(...)`. |
|
||||
| `session_key` | The conversation history key. Reuse it to continue a thread; change it to isolate a thread. |
|
||||
| Workspace | The local directory where file tools and shell tools operate. |
|
||||
| Tools | Capabilities the agent may call, such as file access, shell, web, or custom tools from your config. |
|
||||
| Memory | Long-term memory files managed by nanobot. |
|
||||
| Stream event | A typed event such as `text.delta`, `tool.started`, or `run.completed`. |
|
||||
| Model override | A temporary model or model preset used for one SDK instance or one run. |
|
||||
|
||||
For most users, the mental model is:
|
||||
|
||||
1. Create a `Nanobot` from config.
|
||||
2. Pick a `session_key`.
|
||||
3. Call `run` or `stream`.
|
||||
4. Read `RunResult` or stream events.
|
||||
5. Use session/memory/runtime helpers only when you need more control.
|
||||
|
||||
## SDK Or OpenAI-Compatible API?
|
||||
|
||||
nanobot has two programming surfaces:
|
||||
|
||||
| Use | Choose | Why |
|
||||
|-----|--------|-----|
|
||||
| Python code running in the same process as nanobot | Python SDK | Direct access to `RunResult`, sessions, memory, runtime helpers, hooks, and stream events. |
|
||||
| Existing OpenAI-compatible clients, another language, or a separate process | [OpenAI-Compatible API](openai-api.md) | HTTP `/v1/chat/completions` compatibility with familiar client libraries. |
|
||||
|
||||
The Python SDK is best when you are writing evals, notebooks, benchmark
|
||||
runners, product backends, local scripts, or integrations that should control
|
||||
nanobot directly.
|
||||
|
||||
The OpenAI-compatible API is best when you already have an HTTP client, want
|
||||
process isolation, or need to call nanobot from a non-Python service.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Use a specific config or workspace
|
||||
|
||||
Set the workspace when your agent should work inside a specific project:
|
||||
|
||||
```python
|
||||
from nanobot import Nanobot
|
||||
|
||||
bot = Nanobot.from_config(
|
||||
config_path="~/.nanobot/config.json",
|
||||
workspace="/my/project",
|
||||
)
|
||||
async with Nanobot.from_config(workspace="/my/project") as bot:
|
||||
result = await bot.run("Explain the project structure")
|
||||
```
|
||||
|
||||
Use a custom config when you run multiple nanobot instances or test an isolated
|
||||
setup:
|
||||
|
||||
```python
|
||||
async with Nanobot.from_config(
|
||||
config_path="./bot-a/config.json",
|
||||
workspace="./bot-a/workspace",
|
||||
) as bot:
|
||||
result = await bot.run("Hello from bot A")
|
||||
```
|
||||
|
||||
The config controls what nanobot may use. The workspace is where nanobot keeps
|
||||
state for that instance. See [multiple-instances.md](multiple-instances.md) for
|
||||
multi-instance CLI and gateway examples.
|
||||
|
||||
### Choose a default or per-run model
|
||||
|
||||
Set the SDK instance default model when you create the bot:
|
||||
|
||||
```python
|
||||
bot = Nanobot.from_config(model="openai/gpt-4.1")
|
||||
```
|
||||
|
||||
Override the model for one run without changing the instance default:
|
||||
|
||||
```python
|
||||
result = await bot.run("Summarize this file", model="openai/gpt-4.1-mini")
|
||||
```
|
||||
|
||||
Model presets from `config.json` work the same way:
|
||||
|
||||
```python
|
||||
bot = Nanobot.from_config(model_preset="fast")
|
||||
|
||||
result = await bot.run("Think deeply about this bug", model_preset="reasoning")
|
||||
```
|
||||
|
||||
`model` and `model_preset` are mutually exclusive.
|
||||
|
||||
For first setup, prefer named presets in `config.json`. Mixing an API key from
|
||||
one provider with a model ID from another is the most common first-run failure.
|
||||
For the exact difference between `provider`, `model`, `apiKey`, and `apiBase`,
|
||||
see [Providers: Provider, Model, API Key, and Base URL](providers.md#provider-model-api-key-and-base-url).
|
||||
If a run fails before the SDK does anything interesting, confirm the same
|
||||
provider and model work with `nanobot agent -m "Hello!"` first.
|
||||
|
||||
### Isolate conversations with `session_key`
|
||||
|
||||
Different session keys keep independent conversation history:
|
||||
@@ -51,9 +306,131 @@ await bot.run("hi", session_key="user-alice")
|
||||
await bot.run("hi", session_key="task-42")
|
||||
```
|
||||
|
||||
Use stable keys in product code:
|
||||
|
||||
```python
|
||||
session_key = f"user:{user_id}"
|
||||
result = await bot.run(user_message, session_key=session_key)
|
||||
```
|
||||
|
||||
Avoid using the default `"sdk:default"` for multiple users or unrelated
|
||||
workflows. It is convenient for local experiments, but stable product code
|
||||
should choose explicit keys such as `user:<id>`, `project:<id>`, or
|
||||
`eval:<case-id>`.
|
||||
|
||||
### Handle failures
|
||||
|
||||
For a normal non-streamed run, catch exceptions around `bot.run(...)` and inspect
|
||||
`RunResult.error` when the runtime returns a structured failure:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = await bot.run("Review this repo", session_key="project:demo")
|
||||
except Exception as exc:
|
||||
print(f"SDK call failed before a result was returned: {exc}")
|
||||
else:
|
||||
if result.error:
|
||||
print(f"Agent run failed: {result.error}")
|
||||
else:
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
For streamed runs, either consume the stream to completion or close it:
|
||||
|
||||
```python
|
||||
run = await bot.run_streamed("Write a long answer", session_key="task:123")
|
||||
try:
|
||||
async for event in run.stream_events():
|
||||
...
|
||||
finally:
|
||||
if not run.done:
|
||||
await run.aclose()
|
||||
```
|
||||
|
||||
Use `await run.cancel()` when the user presses a stop button or leaves the page
|
||||
before the stream finishes.
|
||||
|
||||
### Stream long-running output
|
||||
|
||||
Use `bot.stream()` when you want Cursor/OpenAI-style live events instead of
|
||||
waiting for the final `RunResult`:
|
||||
|
||||
```python
|
||||
from nanobot import (
|
||||
STREAM_EVENT_RUN_COMPLETED,
|
||||
STREAM_EVENT_TEXT_DELTA,
|
||||
STREAM_EVENT_TOOL_STARTED,
|
||||
)
|
||||
|
||||
async for event in bot.stream("Review this repository"):
|
||||
if event.type == STREAM_EVENT_TEXT_DELTA:
|
||||
print(event.delta, end="", flush=True)
|
||||
elif event.type == STREAM_EVENT_TOOL_STARTED:
|
||||
print(f"\nusing {event.name}")
|
||||
elif event.type == STREAM_EVENT_RUN_COMPLETED:
|
||||
print("\nfinal:", event.result.content)
|
||||
```
|
||||
|
||||
Use `run_streamed()` when you also want a handle you can wait on:
|
||||
|
||||
```python
|
||||
from nanobot import STREAM_EVENT_TEXT_DELTA
|
||||
|
||||
run = await bot.run_streamed("Write a detailed migration plan")
|
||||
|
||||
async for event in run.stream_events():
|
||||
if event.type == STREAM_EVENT_TEXT_DELTA:
|
||||
print(event.delta, end="", flush=True)
|
||||
|
||||
result = await run.wait()
|
||||
```
|
||||
|
||||
Always either consume the stream, call `await run.wait()` / `await run.text()`,
|
||||
or close it with `await run.cancel()` / `await run.aclose()`. Exiting
|
||||
`stream_events()` or `bot.stream()` early cancels the underlying run so a
|
||||
half-consumed stream cannot leave a background task stuck behind backpressure.
|
||||
|
||||
### Import an existing transcript
|
||||
|
||||
This is useful for evals, benchmark runners, migrations, and tests.
|
||||
|
||||
Use `bot.sessions.ingest()` when you already have a transcript and want it to
|
||||
become nanobot session history. Ingesting a transcript does not call the model,
|
||||
execute tools, update memory, or compact automatically.
|
||||
|
||||
```python
|
||||
await bot.sessions.ingest(
|
||||
"eval:case-1",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "I graduated with a degree in Business Administration.",
|
||||
"timestamp": "2023/05/30 (Tue) 17:27",
|
||||
"source_session_id": "answer_280352e9",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Congratulations on your degree.",
|
||||
"timestamp": "2023/05/30 (Tue) 17:27",
|
||||
},
|
||||
],
|
||||
source="longmemeval",
|
||||
)
|
||||
|
||||
await bot.runtime.compact_session("eval:case-1")
|
||||
|
||||
result = await bot.run(
|
||||
"Current Date: 2023/05/30 (Tue) 23:40\n"
|
||||
"Question: What degree did I graduate with?",
|
||||
session_key="eval:case-1",
|
||||
)
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
### Attach hooks for observability
|
||||
|
||||
Hooks let you inspect tool calls, streaming, and iteration state without modifying nanobot internals:
|
||||
Hooks are an advanced escape hatch. Use them when you want custom logging,
|
||||
metrics, tracing, or output post-processing without modifying nanobot internals:
|
||||
|
||||
```python
|
||||
from nanobot.agent import AgentHook, AgentHookContext
|
||||
@@ -68,9 +445,25 @@ class AuditHook(AgentHook):
|
||||
result = await bot.run("Review this change", hooks=[AuditHook()])
|
||||
```
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
The SDK page is the programming entry point. The fuller conceptual and
|
||||
configuration docs remain the source of truth for the runtime around it:
|
||||
|
||||
| Need | Read |
|
||||
|------|------|
|
||||
| First working install and config | [Install and Quick Start](quick-start.md) |
|
||||
| Mental model for config, workspace, sessions, tools, and memory | [Concepts](concepts.md) |
|
||||
| Provider/model/API key/base URL matching | [Providers and Models](providers.md) |
|
||||
| Pasteable provider recipes | [Provider Cookbook](provider-cookbook.md) |
|
||||
| Complete configuration reference | [Configuration](configuration.md) |
|
||||
| Long-term memory design | [Memory](memory.md) |
|
||||
| HTTP API instead of Python SDK | [OpenAI-Compatible API](openai-api.md) |
|
||||
| Debugging install, config, provider, or runtime failures | [Troubleshooting](troubleshooting.md) |
|
||||
|
||||
## API Reference
|
||||
|
||||
### `Nanobot.from_config(config_path=None, *, workspace=None)`
|
||||
### `Nanobot.from_config(config_path=None, *, workspace=None, model=None, model_preset=None)`
|
||||
|
||||
Create a `Nanobot` instance from a config file.
|
||||
|
||||
@@ -78,10 +471,13 @@ Create a `Nanobot` instance from a config file.
|
||||
|-------|------|---------|-------------|
|
||||
| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. |
|
||||
| `workspace` | `str \| Path \| None` | `None` | Override the workspace directory from config. |
|
||||
| `model` | `str \| None` | `None` | Override the instance default model. |
|
||||
| `model_preset` | `str \| None` | `None` | Override the instance default model preset from `config.json`. |
|
||||
|
||||
Raises `FileNotFoundError` if an explicit config path does not exist.
|
||||
Raises `ValueError` if both `model` and `model_preset` are provided.
|
||||
|
||||
### `await bot.run(message, *, session_key="sdk:default", hooks=None)`
|
||||
### `await bot.run(...)`
|
||||
|
||||
Run the agent once and return a `RunResult`.
|
||||
|
||||
@@ -89,11 +485,93 @@ Run the agent once and return a `RunResult`.
|
||||
|-------|------|---------|-------------|
|
||||
| `message` | `str` | *(required)* | The user message to process. |
|
||||
| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. |
|
||||
| `channel` | `str` | `"cli"` | Logical channel label used in runtime context. |
|
||||
| `chat_id` | `str` | `"direct"` | Logical chat identifier used in runtime context. |
|
||||
| `sender_id` | `str` | `"user"` | Logical sender identifier used in runtime context. |
|
||||
| `media` | `list[str] \| None` | `None` | Optional local media paths attached to the message. |
|
||||
| `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. |
|
||||
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
|
||||
| `model` | `str \| None` | `None` | Override the model for this run only. |
|
||||
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
|
||||
|
||||
`model` and `model_preset` are per-run overrides and do not change
|
||||
`bot.runtime.model` after the run completes. They are mutually exclusive.
|
||||
|
||||
### `await bot.run_streamed(...)`
|
||||
|
||||
Start a streamed agent turn and return a `RunStream`. It accepts the same
|
||||
parameters as `bot.run(...)`.
|
||||
|
||||
```python
|
||||
run = await bot.run_streamed("Generate a long answer")
|
||||
|
||||
async for event in run.stream_events():
|
||||
...
|
||||
|
||||
result = await run.wait()
|
||||
```
|
||||
|
||||
### `bot.stream(...)`
|
||||
|
||||
Convenience wrapper around `run_streamed()` for direct event iteration. It
|
||||
accepts the same parameters as `bot.run(...)`.
|
||||
|
||||
```python
|
||||
async for event in bot.stream("Generate a long answer"):
|
||||
...
|
||||
```
|
||||
|
||||
### `RunStream`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `stream_events()` | Single-consumer async iterator of `StreamEvent` objects. |
|
||||
| `await wait()` | Wait for the run to finish and return `RunResult`. |
|
||||
| `await text()` | Wait for the run to finish and return `RunResult.content`. |
|
||||
| `await cancel()` | Cancel the run and release stream resources. |
|
||||
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
|
||||
|
||||
Normal SDK runs with different session keys may overlap. Runs that use per-run
|
||||
`model` or `model_preset` overrides are exclusive while the override is active,
|
||||
because the current `AgentLoop` provider/model state is mutable.
|
||||
|
||||
### `StreamEvent`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `StreamEventType` | Event type, such as `text.delta` or `run.completed`. |
|
||||
| `delta` | `str` | Incremental text or reasoning chunk. |
|
||||
| `content` | `str` | Completed text segment or final content. |
|
||||
| `result` | `RunResult \| None` | Present on `run.completed`. |
|
||||
| `name` | `str \| None` | Tool name for tool events. |
|
||||
| `tool_call_id` | `str \| None` | Provider tool call id when available. |
|
||||
| `arguments` | `dict \| None` | Tool arguments when available. |
|
||||
| `iteration` | `int \| None` | Agent loop iteration when available. |
|
||||
| `resuming` | `bool \| None` | Whether a text segment ended before more tool work. |
|
||||
| `usage` | `dict[str, int]` | Token usage on completion events. |
|
||||
| `error` | `str \| None` | Error text on failed events. |
|
||||
| `metadata` | `dict` | Additional event metadata. |
|
||||
|
||||
Use the exported constants instead of hard-coded strings when possible:
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| `STREAM_EVENT_RUN_STARTED` | `run.started` |
|
||||
| `STREAM_EVENT_TEXT_DELTA` | `text.delta` |
|
||||
| `STREAM_EVENT_TEXT_COMPLETED` | `text.completed` |
|
||||
| `STREAM_EVENT_REASONING_DELTA` | `reasoning.delta` |
|
||||
| `STREAM_EVENT_REASONING_COMPLETED` | `reasoning.completed` |
|
||||
| `STREAM_EVENT_TOOL_STARTED` | `tool.started` |
|
||||
| `STREAM_EVENT_TOOL_COMPLETED` | `tool.completed` |
|
||||
| `STREAM_EVENT_TOOL_FAILED` | `tool.failed` |
|
||||
| `STREAM_EVENT_RUN_COMPLETED` | `run.completed` |
|
||||
| `STREAM_EVENT_RUN_FAILED` | `run.failed` |
|
||||
|
||||
`STREAM_EVENT_TYPES` contains all stable v1 event values.
|
||||
|
||||
### `await bot.aclose()`
|
||||
|
||||
Release resources held by the SDK instance, including MCP connections. The async context manager calls this automatically:
|
||||
Release resources held by the SDK instance, including tool connections. The async context manager calls this automatically:
|
||||
|
||||
```python
|
||||
async with Nanobot.from_config() as bot:
|
||||
@@ -105,8 +583,54 @@ async with Nanobot.from_config() as bot:
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `content` | `str` | The agent's final text response. |
|
||||
| `tools_used` | `list[str]` | Reserved for richer SDK introspection; may be empty in current versions. |
|
||||
| `messages` | `list[dict]` | Reserved for richer SDK introspection; may be empty in current versions. |
|
||||
| `tools_used` | `list[str]` | Tool names used during the run. |
|
||||
| `messages` | `list[dict]` | Final message list from the run. |
|
||||
| `usage` | `dict[str, int]` | Token usage reported or estimated by the runtime. |
|
||||
| `stop_reason` | `str \| None` | Why the run stopped, such as `"completed"` or `"max_iterations"`. |
|
||||
| `error` | `str \| None` | Error text when the run failed inside the agent runtime. |
|
||||
| `metadata` | `dict` | Outbound metadata such as latency. |
|
||||
|
||||
## Session, Memory, And Runtime Helpers
|
||||
|
||||
### `bot.sessions`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. |
|
||||
| `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. |
|
||||
| `list()` | Return compact `SessionInfo` rows. |
|
||||
| `export(session_key)` | Return a trusted full `SessionSnapshot`, including model-only runtime context, suitable for JSON serialization. |
|
||||
| `await restore(snapshot, session_key=None, save=True)` | Restore a trusted exported snapshot into an empty session; the returned snapshot is display-safe. |
|
||||
| `clear(session_key)` | Clear and persist one session. |
|
||||
| `delete(session_key)` | Delete one session from disk and cache. |
|
||||
| `flush()` | Flush cached sessions to durable storage. |
|
||||
|
||||
Ingested messages must include `role` and `content`. Roles may be `user`,
|
||||
`assistant`, `tool`, or `system`. Other fields, such as `timestamp`,
|
||||
`source_session_id`, or `source_date`, are persisted as message metadata.
|
||||
|
||||
`get()` and snapshots returned by ordinary SDK operations are display-safe and omit
|
||||
model-only runtime context. `export()` is an explicit backup boundary and includes
|
||||
that internal context so `restore()` can preserve the exact model-visible history.
|
||||
Do not expose exported snapshots directly to chat users.
|
||||
|
||||
### `bot.memory`
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `read()` | Read `memory/MEMORY.md`. |
|
||||
| `write(text)` | Overwrite `memory/MEMORY.md`. |
|
||||
| `append_history(text, session_key=None)` | Append one `memory/history.jsonl` entry and return its cursor. |
|
||||
| `read_history(session_key=None)` | Read memory history entries, optionally filtered by session key. |
|
||||
|
||||
### `bot.runtime`
|
||||
|
||||
| Method / Property | Description |
|
||||
|-------------------|-------------|
|
||||
| `model` | Current runtime model name. |
|
||||
| `workspace` | Current runtime workspace path. |
|
||||
| `await compact_session(session_key)` | Run token/replay-window consolidation for a session. |
|
||||
| `await compact_idle_session(session_key, max_suffix=8)` | Run idle-session compaction and return its summary. |
|
||||
|
||||
## Hooks
|
||||
|
||||
@@ -223,12 +747,12 @@ class TimingHook(AgentHook):
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
bot = Nanobot.from_config(workspace="/my/project")
|
||||
result = await bot.run(
|
||||
"Explain the main function",
|
||||
session_key="sdk:demo",
|
||||
hooks=[TimingHook()],
|
||||
)
|
||||
async with Nanobot.from_config(workspace="/my/project") as bot:
|
||||
result = await bot.run(
|
||||
"Explain the main function",
|
||||
session_key="sdk:demo",
|
||||
hooks=[TimingHook()],
|
||||
)
|
||||
print(result.content)
|
||||
|
||||
|
||||
|
||||
+173
-258
@@ -1,152 +1,197 @@
|
||||
# Install and Quick Start
|
||||
|
||||
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins.
|
||||
This guide has one goal: get a normal nanobot reply in your browser. Do not add chat apps, MCP servers, fallback models, or deployment until this path works.
|
||||
|
||||
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets.
|
||||
If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
|
||||
|
||||
## Before You Start
|
||||
These repository docs follow current `main`. The recommended installer uses the stable package, so a newly documented WebUI screen may not appear until the next release. Each advanced guide also provides a CLI or manual config path.
|
||||
|
||||
You need:
|
||||
## What You Need
|
||||
|
||||
- Python 3.11 or newer.
|
||||
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use OpenRouter only so the snippets are concrete; any supported provider works when the key, provider name, and model ID match.
|
||||
- Git only if you install from source.
|
||||
- Node.js or Bun only if you are developing the WebUI itself.
|
||||
- Access to one supported AI provider, company endpoint, or local model server.
|
||||
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Repository docs may describe features that are available first in source. Install from PyPI or `uv` for the stable day-to-day release; install from source when you want the newest repository behavior or plan to contribute.
|
||||
Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built.
|
||||
|
||||
## 1. Install
|
||||
## 1. Install nanobot
|
||||
|
||||
Pick one install method.
|
||||
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
|
||||
|
||||
**One-command setup:**
|
||||
**macOS / Linux**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
On Windows PowerShell:
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If you finish the wizard and save the config, skip the manual initialize/configure steps and go straight to [Check the Setup](#4-check-the-setup).
|
||||
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
|
||||
|
||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
```
|
||||
## 2. Complete Quick Start
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||
```
|
||||
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
|
||||
|
||||
To install the current `main` branch instead, pass `--dev`:
|
||||
1. Choose the provider or endpoint that owns your credential.
|
||||
2. Enter its API key or base URL when requested.
|
||||
3. Enter a model ID that the same provider can run.
|
||||
4. Let Quick Start enable the local WebUI.
|
||||
5. Set a WebUI password and review the summary.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
Quick Start creates or updates:
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||
```
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
||||
|
||||
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
|
||||
|
||||
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
|
||||
|
||||
**Stable release with `uv`:**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**Stable release with pip:**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
**Latest source checkout:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install -e .
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If your shell cannot find `nanobot` after a pip install, run the module form:
|
||||
|
||||
```bash
|
||||
python -m nanobot --version
|
||||
python -m nanobot onboard
|
||||
```
|
||||
|
||||
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
|
||||
|
||||
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
|
||||
|
||||
## 2. Initialize
|
||||
|
||||
Skip this section if the one-command setup already started the wizard and you saved the config there.
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
```
|
||||
|
||||
Use the wizard if you prefer prompts instead of editing JSON by hand:
|
||||
If the installer did not open the wizard, run it yourself:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Initialization creates:
|
||||
Current source versions also provide `nanobot webui`. When run without a usable model, that launcher offers the same Quick Start flow before starting the browser.
|
||||
|
||||
| Path | What it is |
|
||||
|------|------------|
|
||||
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
|
||||
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
|
||||
## 3. Check the Setup
|
||||
|
||||
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values.
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
## 3. Configure a Provider
|
||||
You want:
|
||||
|
||||
Skip this section if you already configured provider and model settings in the wizard.
|
||||
- a check mark for **Config** and **Workspace**;
|
||||
- the model or preset you selected;
|
||||
- a configured state for the provider used by that model.
|
||||
|
||||
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config.
|
||||
Most other providers can say `not set`. This command validates local setup but does not call the model.
|
||||
|
||||
**API key:**
|
||||
## 4. Get the First Reply
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Quick Start has already prepared the local WebSocket channel. Leave the gateway terminal open and visit `http://127.0.0.1:8765`; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it. On current source versions, you can run `nanobot webui` instead to perform the local WebUI checks, start the gateway, and open the browser automatically.
|
||||
|
||||
Send:
|
||||
|
||||
```text
|
||||
Hello!
|
||||
```
|
||||
|
||||
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
|
||||
|
||||
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
|
||||
|
||||
```bash
|
||||
nanobot gateway --background
|
||||
nanobot gateway status
|
||||
```
|
||||
|
||||
Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gateway.
|
||||
|
||||
## Terminal-Only Check
|
||||
|
||||
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Then start an interactive terminal chat with:
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## Choose One Next Step
|
||||
|
||||
After the first reply works, add one capability and test again:
|
||||
|
||||
| Goal | Recommended path |
|
||||
|---|---|
|
||||
| Learn sessions, workspaces, tools, and access modes | [WebUI guide](./webui.md) |
|
||||
| Connect a chat platform | Open **Settings → Channels**, then use [Chat Apps](./chat-apps.md) for platform prerequisites |
|
||||
| Change or add a model | Open **Settings → Models**; use the [Provider Cookbook](./provider-cookbook.md) for a recipe |
|
||||
| Add web search, voice, or image generation | Use the matching WebUI Settings page, then consult [Configuration](./configuration.md) for advanced fields |
|
||||
| Add an App or MCP integration | Open **Apps** or follow [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||
| Schedule agent work | Read [Automations](./automations.md) |
|
||||
| Run continuously or remotely | Read [Deployment](./deployment.md) |
|
||||
| Integrate from code | Use the [Python SDK](./python-sdk.md) or [OpenAI-Compatible API](./openai-api.md) |
|
||||
|
||||
## Other Install Methods
|
||||
|
||||
Use one method, then continue at [Complete Quick Start](#2-complete-quick-start).
|
||||
|
||||
**uv**
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
**pip in a virtual environment**
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
|
||||
|
||||
**Current source**
|
||||
|
||||
`bun` or `npm` must be available. Activate a virtual environment first, then run:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HKUDS/nanobot.git
|
||||
cd nanobot
|
||||
python -m pip install .
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
|
||||
|
||||
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
|
||||
|
||||
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
|
||||
|
||||
```bash
|
||||
uv tool run --from nanobot-ai nanobot --version
|
||||
pipx run --spec nanobot-ai nanobot --version
|
||||
~/.nanobot/venv/bin/python -m nanobot --version
|
||||
```
|
||||
|
||||
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `onboard --wizard`, `gateway`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
|
||||
|
||||
## Manual Configuration Fallback
|
||||
|
||||
Use this only when the wizard is unavailable or you intentionally manage JSON. First run `nanobot onboard`, then merge a provider and a named model preset into `~/.nanobot/config.json`.
|
||||
|
||||
A generic OpenAI-compatible setup has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
"custom": {
|
||||
"apiKey": "${PROVIDER_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Model preset:**
|
||||
|
||||
```json
|
||||
{
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-opus-4.5",
|
||||
"maxTokens": 8192,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
"provider": "custom",
|
||||
"model": "model-id-from-your-provider"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -157,178 +202,48 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
|
||||
}
|
||||
```
|
||||
|
||||
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
|
||||
|
||||
| Replace | Where |
|
||||
|---|---|
|
||||
| Provider config key, such as `openrouter` | `providers.<provider>` |
|
||||
| API key or environment variable | `providers.<provider>.apiKey` |
|
||||
| Preset provider name | `modelPresets.primary.provider` |
|
||||
| Model ID | `modelPresets.primary.model` |
|
||||
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
|
||||
|
||||
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
|
||||
|
||||
**What about `apiBase` / base URL?**
|
||||
|
||||
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
|
||||
|
||||
- `custom` for a third-party or self-hosted OpenAI-compatible API;
|
||||
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
|
||||
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${CUSTOM_API_KEY}",
|
||||
"apiBase": "https://api.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
|
||||
|
||||
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "${OPENROUTER_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Check the Setup
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
|
||||
|
||||
Read it like this:
|
||||
|
||||
| Status line | What you want |
|
||||
|---|---|
|
||||
| `Config` | A check mark. |
|
||||
| `Workspace` | A check mark. |
|
||||
| `Model` | The model or preset you expect. |
|
||||
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
|
||||
|
||||
## 5. Test One Message
|
||||
|
||||
Run a one-shot CLI message:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
A successful first run proves that:
|
||||
|
||||
- the `nanobot` command is installed;
|
||||
- `~/.nanobot/config.json` can be loaded;
|
||||
- the selected provider and model can answer;
|
||||
- the default workspace can be created and used.
|
||||
|
||||
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
|
||||
|
||||
If that works, start an interactive CLI chat:
|
||||
|
||||
```bash
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
|
||||
|
||||
Example prompt:
|
||||
|
||||
```text
|
||||
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
|
||||
Then update ~/.nanobot/config.json to add an OpenRouter model preset named "primary".
|
||||
Tell me exactly what changed and whether I need to run /restart.
|
||||
```
|
||||
|
||||
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## 6. Choose Your Next Step
|
||||
|
||||
| Want to... | Go to |
|
||||
|---|---|
|
||||
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
|
||||
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
||||
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
||||
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
||||
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
||||
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
||||
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
||||
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
|
||||
Replace the provider, endpoint, and model together. Do not pair a credential from one service with a model ID from another. See [Provider Cookbook](./provider-cookbook.md) for hosted, OAuth, company, and local examples, and [Configuration](./configuration.md) for exact fields.
|
||||
|
||||
## Updating
|
||||
|
||||
**pip:**
|
||||
Upgrade with the same method you used to install:
|
||||
|
||||
```bash
|
||||
python -m pip install -U nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
# Recommended installer
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||
|
||||
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
|
||||
|
||||
**uv:**
|
||||
|
||||
```bash
|
||||
# Or one of these
|
||||
uv tool upgrade nanobot-ai
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
**pipx:**
|
||||
|
||||
```bash
|
||||
pipx upgrade nanobot-ai
|
||||
nanobot --version
|
||||
python -m pip install -U nanobot-ai
|
||||
```
|
||||
|
||||
**Source checkout:**
|
||||
For a source checkout:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
python -m pip install -e .
|
||||
nanobot --version
|
||||
python -m pip install .
|
||||
```
|
||||
|
||||
If you use WhatsApp, rebuild the local bridge after upgrading:
|
||||
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
|
||||
|
||||
## If the First Reply Fails
|
||||
|
||||
Do not change several settings at once. Start with:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.nanobot/bridge
|
||||
nanobot channels login whatsapp
|
||||
nanobot --version
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
## First-Run Troubleshooting
|
||||
| Symptom | First check |
|
||||
|---|---|
|
||||
| `nanobot: command not found` | Reuse the installer command or method-specific runner described under [Other Install Methods](#other-install-methods) |
|
||||
| JSON parse error | Check commas and braces; remember that docs examples are usually snippets |
|
||||
| `401` or invalid API key | Verify the selected provider owns that key and remove accidental spaces |
|
||||
| Model not found | Use a model ID available from the provider selected in the active preset |
|
||||
| CLI works but WebUI does not open | Use port `8765`, not gateway health port `18790` |
|
||||
| WebUI works but a chat app does not | Check **Settings → Channels**, then run `nanobot channels status` |
|
||||
|
||||
| Symptom | What to check |
|
||||
|---------|---------------|
|
||||
| `nanobot: command not found` | Use `python -m nanobot ...`, or add your Python scripts directory to `PATH`. |
|
||||
| `ModuleNotFoundError: nanobot` | Confirm you installed into the same Python environment that is running the command. |
|
||||
| JSON parse errors | Check commas and braces in `~/.nanobot/config.json`; examples above are partial snippets to merge. |
|
||||
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
|
||||
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
|
||||
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
|
||||
| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. |
|
||||
|
||||
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
|
||||
Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Release Archive
|
||||
|
||||
This page keeps release and daily update history outside the README so the project homepage can stay focused on what nanobot is, what it can do, and how to start.
|
||||
|
||||
For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/releases).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
|
||||
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
|
||||
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
|
||||
- **2026-07-09** 📝 Live file-edit diffs, safer localhost setup, Matrix image fixes.
|
||||
- **2026-07-08** 🔐 Safer WebUI/API setup, onboard refresh, responsive prompt rail.
|
||||
- **2026-07-07** ⌨️ CLI multiline input, steadier slash commands, safer web fetching.
|
||||
- **2026-07-06** 💬 Mattermost channel, Serper search, safer Windows shells.
|
||||
- **2026-07-04** 🔌 MCP reconnects, safer Copilot refresh, Windows shutdown fixes.
|
||||
- **2026-07-03** 🧙 Guided WebUI setup, plugin controls, Claude Sonnet 4.6 default.
|
||||
- **2026-07-02** ⏰ Local triggers with recovery, audit history, WebUI pending status.
|
||||
- **2026-07-01** 🛡️ API keys for remote binds, `$skill` shortcuts, clearer tool errors.
|
||||
- **2026-06-30** 🌐 Provider proxies, Copilot Enterprise, steadier WhatsApp and Weixin.
|
||||
- **2026-06-29** 🧠 Context replay scaled to model windows, without fixed message caps.
|
||||
- **2026-06-28** 🖼️ MCP images, steadier WebUI reconnects, safer tool calls.
|
||||
- **2026-06-27** 🔒 Collision-safe sessions, safer shells, Neonize WhatsApp.
|
||||
- **2026-06-25** 🎛️ Thinking controls, MiMo voice input, opt-in Telegram rich messages.
|
||||
- **2026-06-24** 🌙 Kimi Coding and OpenCode, steadier reasoning and Anthropic tool calls.
|
||||
- **2026-06-22** 🚀 Released **v0.2.2** — **The Durability Release** makes nanobot sturdier for daily agent work: segmented WebUI transcripts, first-class Python SDK runtime controls, automation management, richer search/STT providers, and stronger gateway/session/provider reliability. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2) for details.
|
||||
- **2026-06-21** 🧰 Python SDK runtime controls, optional Keenable key, cleaner run hooks.
|
||||
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
|
||||
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
|
||||
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
|
||||
- **2026-06-17** 🧠 Default idle auto-compact, clearer `/dream`, macOS installer fixes.
|
||||
- **2026-06-16** 🎯 Fresher goal context, Kimi K2.7 thinking, cleaner API retries.
|
||||
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
|
||||
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
|
||||
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
|
||||
|
||||
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
|
||||
- **2026-06-11** ✂️ Fenced-code message splitting.
|
||||
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
|
||||
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
|
||||
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
|
||||
- **2026-06-06** 🧰 SDK MCP cleanup, removable OpenAI image defaults.
|
||||
- **2026-06-05** 🖼️ Azure AAD, custom image providers, `/skill`, steadier pairing.
|
||||
- **2026-06-04** 🔌 MCP reconnects, `uv pip` install fallback, QQ pairing.
|
||||
- **2026-06-03** 🧠 Hidden-history recovery, quieter email progress handling.
|
||||
- **2026-06-02** 📬 Email attachments, Napcat QQ, Volcengine search, simpler Dream.
|
||||
- **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
|
||||
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
|
||||
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
|
||||
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
|
||||
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
|
||||
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
|
||||
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
|
||||
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
|
||||
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
|
||||
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
|
||||
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
|
||||
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
|
||||
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
|
||||
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
|
||||
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
|
||||
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
|
||||
- **2026-05-15** 🚀 Released **v0.2.0** — **`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
|
||||
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
|
||||
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
|
||||
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
|
||||
- **2026-05-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
|
||||
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
|
||||
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
|
||||
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
|
||||
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
|
||||
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
|
||||
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
|
||||
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
|
||||
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
|
||||
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
|
||||
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
|
||||
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
|
||||
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
|
||||
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
|
||||
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
|
||||
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
|
||||
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
|
||||
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
|
||||
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
|
||||
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
|
||||
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
|
||||
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
|
||||
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
|
||||
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
|
||||
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
|
||||
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
|
||||
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
|
||||
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
|
||||
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
|
||||
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
|
||||
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
|
||||
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
|
||||
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
|
||||
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
|
||||
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
|
||||
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
|
||||
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
|
||||
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
|
||||
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
|
||||
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
|
||||
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
|
||||
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
|
||||
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
|
||||
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
|
||||
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
|
||||
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
|
||||
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
|
||||
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
|
||||
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
|
||||
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
|
||||
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
|
||||
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
|
||||
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
|
||||
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
|
||||
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
|
||||
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
|
||||
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
|
||||
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
|
||||
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
|
||||
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
|
||||
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
|
||||
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
|
||||
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
|
||||
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
||||
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
||||
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
||||
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
||||
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
||||
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
||||
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
|
||||
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
|
||||
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
||||
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
||||
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
||||
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
||||
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
||||
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
||||
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
|
||||
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
|
||||
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
|
||||
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
|
||||
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
|
||||
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
|
||||
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
|
||||
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
|
||||
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./configuration.md#mcp-model-context-protocol) for details.
|
||||
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
|
||||
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./configuration.md#providers).
|
||||
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
|
||||
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
|
||||
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
||||
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
||||
@@ -1,82 +1,62 @@
|
||||
# Start Without Technical Background
|
||||
|
||||
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
|
||||
This walkthrough is for people who have not used a terminal, API key, or JSON config file before. The goal is only to get one reply in a browser. You do not need to understand nanobot's architecture or edit its config by hand.
|
||||
|
||||
The goal is small: get one local nanobot reply. Do not connect Telegram, Discord, WebUI, Docker, local models, or deployment yet. Those are easier after the first reply works.
|
||||
## What You Will Need
|
||||
|
||||
## What You Are Setting Up
|
||||
- A Windows, macOS, or Linux computer.
|
||||
- Python 3.11 or newer.
|
||||
- An account or endpoint that can run an AI model.
|
||||
- The API key, login, endpoint, and model name required by that service. A local model such as Ollama may not require an API key.
|
||||
|
||||
You will see these words during setup:
|
||||
An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
|
||||
|
||||
| Word | Plain meaning |
|
||||
## A Few Useful Words
|
||||
|
||||
| Word | Meaning |
|
||||
|---|---|
|
||||
| Terminal | A text window where you paste commands and press Enter. |
|
||||
| Command | One line of text you run in the terminal. |
|
||||
| API key | A password-like token from an AI provider. Do not share it publicly. |
|
||||
| Provider | The service that owns the API key or local model endpoint. |
|
||||
| Model | The AI model ID that the provider can run. |
|
||||
| Config file | The settings file nanobot reads when it starts. |
|
||||
| Wizard | An interactive terminal menu that edits the config file for you. |
|
||||
| Model preset | A named model choice in the config file. |
|
||||
| `apiBase` | The HTTP address of a provider endpoint. Leave it blank unless your provider, proxy, or local server tells you to set one. |
|
||||
| Terminal | A text window where you paste a command and press Enter |
|
||||
| Command | One instruction typed into the terminal |
|
||||
| Provider | The service or local server that runs the AI model |
|
||||
| Model ID | The exact model name expected by that provider |
|
||||
| API key | A secret credential that lets software call the provider |
|
||||
| Wizard | A question-and-answer setup menu |
|
||||
| WebUI | The local browser page where you use nanobot |
|
||||
|
||||
## 1. Open a Terminal
|
||||
## 1. Install Python
|
||||
|
||||
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
|
||||
Download Python from [python.org](https://www.python.org/downloads/) if you do not already have version 3.11 or newer. On Windows, enable **Add python.exe to PATH** if the installer shows that option.
|
||||
|
||||
| System | How to open it |
|
||||
Open a terminal:
|
||||
|
||||
| System | How |
|
||||
|---|---|
|
||||
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
|
||||
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
|
||||
| Linux | Open your app launcher, search for `Terminal`, then open it. |
|
||||
| Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
|
||||
| macOS | Press `Command+Space`, type `Terminal`, and press Enter |
|
||||
| Linux | Open your application menu and search for Terminal |
|
||||
|
||||
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal.
|
||||
|
||||
## 2. Install Python
|
||||
|
||||
Install Python 3.11 or newer from [python.org](https://www.python.org/downloads/).
|
||||
|
||||
On Windows, enable **Add python.exe to PATH** during installation if the installer shows that option.
|
||||
|
||||
In that terminal, check Python:
|
||||
Check Python:
|
||||
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
|
||||
The result should start with `Python 3.11` or a newer number. If the command is not found, close and reopen the terminal. You can also try `python3 --version` on macOS/Linux or `py --version` on Windows.
|
||||
|
||||
```bash
|
||||
py --version
|
||||
```
|
||||
## 2. Prepare Your Model Details
|
||||
|
||||
If `py` works but `python` does not, replace `python` with `py` in the commands below.
|
||||
nanobot does not create an AI provider account for you. Before setup, have these details nearby:
|
||||
|
||||
If macOS or Linux says `python` is not found, try:
|
||||
1. The provider or company endpoint name.
|
||||
2. Its API key, if it requires one.
|
||||
3. Its base URL, if its documentation gives you one.
|
||||
4. A model ID your account can use.
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
The provider, credential, endpoint, and model must belong together. For example, an API key from one provider usually cannot call a model name copied from a different provider.
|
||||
|
||||
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`.
|
||||
## 3. Install nanobot
|
||||
|
||||
## 3. Get a Provider API Key
|
||||
|
||||
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. The steps below use OpenRouter only as a concrete example so the commands and wizard choices have real names; it is not a ranking, default choice, or endorsement.
|
||||
|
||||
If you use another provider, keep the same shape but replace the provider name, API key, and model ID with values from that provider. [`provider-cookbook.md`](./provider-cookbook.md) has copyable snippets for several common patterns.
|
||||
|
||||
For the example path:
|
||||
|
||||
1. Open [openrouter.ai/keys](https://openrouter.ai/keys).
|
||||
2. Create or copy an API key.
|
||||
3. Keep the key private.
|
||||
|
||||
An OpenRouter key usually starts with `sk-or-v1-`. Other providers use different key shapes. Keep the key nearby because the setup wizard will ask you to paste it.
|
||||
|
||||
## 4. Install nanobot
|
||||
|
||||
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
|
||||
Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
|
||||
|
||||
**macOS / Linux**
|
||||
|
||||
@@ -90,350 +70,112 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
|
||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
|
||||
The installer downloads the stable nanobot package into an isolated Python environment and opens the setup wizard. It can take a few minutes on the first run. When it finishes, it prints the exact command it used to run nanobot. Keep that command: if `nanobot` is not found later, reuse the whole printed command instead of switching to a different Python command.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||
If your organization blocks downloaded install scripts, use the [alternative install methods](./quick-start.md#other-install-methods) or ask your administrator to review the scripts first.
|
||||
|
||||
## 4. Follow Quick Start
|
||||
|
||||
The wizard shows a menu similar to:
|
||||
|
||||
```text
|
||||
> What would you like to do?
|
||||
[Q] Quick Start
|
||||
[A] Advanced Settings
|
||||
[X] Exit
|
||||
```
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||
```
|
||||
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
|
||||
|
||||
Use the development installer only when a maintainer asks you to test the current `main` branch:
|
||||
The wizard asks for only the information needed for the first reply:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||
```
|
||||
1. Choose your provider.
|
||||
2. Choose an endpoint option if the provider offers several plans.
|
||||
3. Paste the API key if asked.
|
||||
4. Enter the base URL if asked.
|
||||
5. Enter a model ID.
|
||||
6. Confirm the local WebUI setup.
|
||||
7. Choose a WebUI password.
|
||||
8. Review the summary and save.
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||
```
|
||||
When you paste a password or API key, the terminal may hide the characters. That is normal.
|
||||
|
||||
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below.
|
||||
|
||||
If `uv` is installed, use:
|
||||
|
||||
```bash
|
||||
uv tool install nanobot-ai
|
||||
```
|
||||
|
||||
If you prefer pip, use it only inside an environment you control:
|
||||
|
||||
```bash
|
||||
python -m pip install nanobot-ai
|
||||
```
|
||||
|
||||
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
|
||||
|
||||
Then check that nanobot is installed:
|
||||
|
||||
```bash
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If the terminal cannot find `nanobot`, use the module form:
|
||||
|
||||
```bash
|
||||
python -m nanobot --version
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the Python command that worked in step 2.
|
||||
|
||||
## 5. Run the Setup Wizard
|
||||
|
||||
The one-command installer starts this for you after installation. If you installed manually, run:
|
||||
If the installer finishes without opening the wizard and `nanobot` is available, run:
|
||||
|
||||
```bash
|
||||
nanobot onboard --wizard
|
||||
```
|
||||
|
||||
If `nanobot` is not found, run:
|
||||
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `onboard --wizard`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
|
||||
|
||||
```bash
|
||||
python -m nanobot onboard --wizard
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
|
||||
|
||||
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
|
||||
|
||||
You will see a menu like this:
|
||||
|
||||
```text
|
||||
> What would you like to configure?
|
||||
[P] LLM Provider
|
||||
[M] Model Presets
|
||||
[C] Chat Channel
|
||||
[H] Channel Common
|
||||
[A] Agent Settings
|
||||
[I] API Server
|
||||
[G] Gateway
|
||||
[T] Tools
|
||||
[V] View Configuration Summary
|
||||
[S] Save and Exit
|
||||
[X] Exit Without Saving
|
||||
```
|
||||
|
||||
Move through the wizard like this:
|
||||
|
||||
| When you see | Do this |
|
||||
|---|---|
|
||||
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
|
||||
| A text field | Type or paste the value, then press `Enter`. |
|
||||
| A field you do not need | Keep the shown default or leave it blank, then press `Enter`. |
|
||||
| A back option | Choose it to return to the previous menu. |
|
||||
|
||||
For the first setup, only configure the model provider and one model preset.
|
||||
|
||||
If you are following the OpenRouter example:
|
||||
|
||||
1. Choose `[P] LLM Provider`.
|
||||
2. Select OpenRouter.
|
||||
3. Paste your OpenRouter API key.
|
||||
4. Keep the default `apiBase`, or leave it blank if the wizard shows no default. Only change it if OpenRouter or your deployment guide explicitly tells you to set one.
|
||||
5. Return to the main menu.
|
||||
6. Choose `[M] Model Presets`.
|
||||
7. Add or edit a preset named `primary`.
|
||||
8. Set:
|
||||
|
||||
```text
|
||||
label: Primary
|
||||
provider: openrouter
|
||||
model: anthropic/claude-sonnet-4.5
|
||||
maxTokens: 4096
|
||||
contextWindowTokens: 65536
|
||||
temperature: 0.1
|
||||
```
|
||||
|
||||
If OpenRouter says your account cannot use that model, use another OpenRouter model ID that your account can access.
|
||||
|
||||
If you are using another provider, use the same wizard choices but substitute that provider's values:
|
||||
|
||||
| Wizard field | What to enter |
|
||||
|---|---|
|
||||
| Provider menu | The provider that owns your API key or endpoint. |
|
||||
| API key | The key from that provider, or leave it blank only if the provider does not use one. |
|
||||
| `apiBase` | Leave blank unless the provider docs, proxy docs, or local server docs give you a URL. |
|
||||
| Preset `provider` | The nanobot provider name, such as the one shown in [`provider-cookbook.md`](./provider-cookbook.md). |
|
||||
| Preset `model` | A model ID that provider can actually serve. |
|
||||
| Preset name | `primary` is fine for the first setup. |
|
||||
|
||||
Then choose `[S] Save and Exit`.
|
||||
|
||||
The wizard creates or updates:
|
||||
|
||||
| Path | Meaning |
|
||||
|---|---|
|
||||
| `~/.nanobot/config.json` | Settings file. |
|
||||
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
|
||||
|
||||
## How to Merge JSON Snippets
|
||||
|
||||
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
|
||||
|
||||
Do not paste two separate JSON objects into one file:
|
||||
|
||||
```text
|
||||
{
|
||||
"providers": { "...": "..." }
|
||||
}
|
||||
{
|
||||
"channels": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
Merge them into one object:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-your-key-here"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
|
||||
|
||||
## 6. Manual Config Fallback
|
||||
|
||||
Use this only if the wizard is unavailable or you prefer opening the file yourself.
|
||||
|
||||
Use one of these commands:
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
notepad "$env:USERPROFILE\.nanobot\config.json"
|
||||
```
|
||||
|
||||
**macOS**
|
||||
|
||||
```bash
|
||||
open -e ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
xdg-open ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-your-key-here"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Primary",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.5",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `sk-or-v1-your-key-here` with your real OpenRouter key.
|
||||
|
||||
If you use another provider, replace `openrouter`, `sk-or-v1-your-key-here`, and the `model` value with that provider's values. If the provider needs `apiBase`, add it under that provider's config block.
|
||||
|
||||
Save the file.
|
||||
|
||||
## 7. Send the First Message
|
||||
|
||||
First check that nanobot can read the saved setup:
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
This should show the config file path, workspace path, and the active model or preset. If `nanobot` is not found, use `python -m nanobot status`, `python3 -m nanobot status`, or `py -m nanobot status`, matching the Python command that worked in step 2.
|
||||
|
||||
It is normal for most providers to say `not set`. Only the provider you selected for the active preset needs to look configured.
|
||||
## 5. Open the Browser
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
Leave the terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password from the wizard if the browser asks for it. Current source versions also provide `nanobot webui`, which starts the gateway and opens the browser automatically.
|
||||
|
||||
Send this message:
|
||||
|
||||
```text
|
||||
Hello!
|
||||
```
|
||||
|
||||
A normal assistant reply means setup is complete. The exact reply does not matter.
|
||||
|
||||
The first-run address is local to your computer. It is not automatically available to other computers on your network.
|
||||
|
||||
## 6. Add One Thing at a Time
|
||||
|
||||
Do not configure every feature immediately. Choose one next goal:
|
||||
|
||||
| Goal | What to do |
|
||||
|---|---|
|
||||
| Change the AI model | Open **Settings → Models** |
|
||||
| Add a provider credential | Open **Settings → Models**, then find the provider |
|
||||
| Connect Telegram, Discord, Slack, Feishu, WeChat, or another chat app | Open **Settings → Channels**, choose the platform, and follow its connection steps |
|
||||
| Add a tool integration | Open **Apps** and choose an App or MCP integration |
|
||||
| Schedule a reminder or recurring task | Ask nanobot in the target chat, then manage it in **Automations** |
|
||||
| Work with project files | Start a new chat, choose the project workspace, and review the access setting before sending the task |
|
||||
|
||||
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
|
||||
|
||||
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot gateway` again.
|
||||
|
||||
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
|
||||
|
||||
## If Something Fails
|
||||
|
||||
Run these commands one at a time:
|
||||
|
||||
```bash
|
||||
nanobot --version
|
||||
nanobot status
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
If that works, nanobot is installed and can call the model.
|
||||
|
||||
You should see a normal assistant reply in the terminal. The exact words will differ, but it should look like this shape:
|
||||
|
||||
```text
|
||||
Hello! How can I help you today?
|
||||
```
|
||||
|
||||
If `nanobot` is not found, run:
|
||||
|
||||
```bash
|
||||
python -m nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
Use `python3 -m nanobot agent -m "Hello!"` or `py -m nanobot agent -m "Hello!"` if that is the Python command that worked in step 2.
|
||||
|
||||
Once this works, nanobot can help with its own next setup step. Run `nanobot agent`, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to enable the browser UI, add one provider preset, or configure one chat app.
|
||||
|
||||
## 8. If Something Fails
|
||||
|
||||
Do not change many things at once. Check the exact error:
|
||||
|
||||
| Error or symptom | What it usually means |
|
||||
| What you see | What it usually means |
|
||||
|---|---|
|
||||
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. |
|
||||
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
|
||||
| `model not found` | The model ID is not available through the selected provider or your account cannot use it. |
|
||||
| `nanobot: command not found` | The install worked in Python, but your shell cannot find the script. Use `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`, matching the Python command that worked earlier. |
|
||||
| No response after editing config | Restart the command. Long-running processes read config when they start. |
|
||||
| `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
|
||||
| `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
|
||||
| Model not found | The model ID is misspelled or unavailable to your provider account |
|
||||
| Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
|
||||
| Browser opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
|
||||
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
|
||||
|
||||
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
|
||||
If you ask for help, include your operating system, `nanobot --version`, `nanobot status`, the exact command, and the exact error. Remove every API key, bot token, password, OAuth token, and private account ID first.
|
||||
|
||||
## What Not to Configure Yet
|
||||
Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
|
||||
|
||||
Skip these until the first local message works:
|
||||
## Open nanobot Later
|
||||
|
||||
- `apiBase`: hosted built-in providers often already have default endpoints. You only need `apiBase` for local models, proxies, custom OpenAI-compatible providers, or special regional/subscription endpoints.
|
||||
- WebUI and chat apps: first prove `nanobot agent -m "Hello!"`.
|
||||
- fallback models: useful later, but not needed for the first reply.
|
||||
- Langfuse: useful for observability, but not needed for first setup.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app.
|
||||
|
||||
### Open the Browser UI
|
||||
|
||||
1. Add this snippet to `~/.nanobot/config.json`. Merge it into the existing file instead of replacing the whole file:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
```
|
||||
|
||||
2. Run:
|
||||
Run:
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
3. Leave that terminal open.
|
||||
4. Open `http://127.0.0.1:8765` in your browser.
|
||||
|
||||
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
||||
|
||||
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
||||
|
||||
### Connect a Chat App
|
||||
|
||||
1. Read the section for one app in [`chat-apps.md`](./chat-apps.md).
|
||||
2. Add only that app's config snippet. Merge it into the existing file instead of replacing the whole file.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
nanobot channels status
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
4. Leave the gateway terminal open, then send a message from the allowed account.
|
||||
|
||||
Start with a private chat or a test server. Do not set `allowFrom` to `["*"]` unless you intentionally want anyone who can reach that channel to talk to the bot.
|
||||
|
||||
### Change Models or Add Backups
|
||||
|
||||
Use [`providers.md`](./providers.md) when a provider/model pair fails, and [`provider-cookbook.md`](./provider-cookbook.md) when you want copyable snippets. Keep model choices in `modelPresets`, then select the active one with `agents.defaults.modelPreset`.
|
||||
|
||||
### Ask for Help
|
||||
|
||||
When you ask for help, include:
|
||||
|
||||
- your operating system;
|
||||
- the command you ran;
|
||||
- `nanobot --version`;
|
||||
- `nanobot status`;
|
||||
- whether `nanobot agent -m "Hello!"` works;
|
||||
- the exact error text;
|
||||
- a config snippet with API keys and tokens removed.
|
||||
|
||||
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into a public issue or chat.
|
||||
|
||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
||||
Leave that terminal open and visit `http://127.0.0.1:8765`. To stop nanobot, return to the terminal and press `Ctrl+C`. Use `nanobot gateway --background` only after the normal foreground start works; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
|
||||
+11
-5
@@ -31,7 +31,7 @@ If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram,
|
||||
|
||||
## How to Read `nanobot status`
|
||||
|
||||
`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary.
|
||||
`nanobot status` does not call a model. It only checks whether nanobot can find the selected config, selected workspace, active model or preset, and provider setup summary.
|
||||
|
||||
The output has this shape:
|
||||
|
||||
@@ -90,9 +90,10 @@ Default workspace path:
|
||||
~/.nanobot/workspace/
|
||||
```
|
||||
|
||||
`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances:
|
||||
`nanobot status` reads the default config unless you pass explicit paths. Use the same `--config` and `--workspace` across status checks and runtime commands when debugging multiple instances:
|
||||
|
||||
```bash
|
||||
nanobot status --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
|
||||
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||
```
|
||||
@@ -110,10 +111,10 @@ Common config mistakes:
|
||||
To refresh missing defaults without overwriting existing settings, run:
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
nanobot onboard --refresh
|
||||
```
|
||||
|
||||
When prompted about overwriting the config, choose the option that keeps current values and merges missing defaults.
|
||||
For an interactive choice between resetting and refreshing, run `nanobot onboard` and choose the option that keeps current values and merges missing defaults.
|
||||
|
||||
## Provider and Model Problems
|
||||
|
||||
@@ -134,7 +135,12 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
|
||||
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
||||
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
|
||||
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
|
||||
| OAuth provider fails | Run `nanobot provider login openai-codex` or `nanobot provider login github-copilot`, then select the provider explicitly. |
|
||||
| OAuth provider fails | Run `nanobot provider login openai-codex --set-main` or `nanobot provider login github-copilot --set-main`. |
|
||||
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
|
||||
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. |
|
||||
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
|
||||
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
|
||||
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
|
||||
|
||||
## Langfuse Problems
|
||||
|
||||
|
||||
+11
-10
@@ -16,17 +16,18 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
|
||||
|
||||
### 1. Configure
|
||||
|
||||
Add to `config.json` under `channels.websocket`:
|
||||
The WebSocket channel is enabled by default. Add only the fields you want to
|
||||
override under `channels.websocket`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"path": "/",
|
||||
"websocketRequiresToken": false,
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true,
|
||||
"allowFrom": ["*"],
|
||||
"streaming": true
|
||||
}
|
||||
@@ -207,7 +208,7 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable the WebSocket server. |
|
||||
| `enabled` | bool | `true` | Enable the WebSocket server. Set to `false` only when you intentionally do not want the bundled WebUI/WebSocket surface. |
|
||||
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
|
||||
| `port` | int | `8765` | Listen port. |
|
||||
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
|
||||
@@ -220,7 +221,7 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). |
|
||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret` or `token`. |
|
||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||
|
||||
### Access Control
|
||||
@@ -265,13 +266,17 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
||||
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
||||
4. The token is consumed (single use) and cannot be reused.
|
||||
|
||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||
It returns a separate `api_token` for REST routes to same-machine localhost
|
||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`
|
||||
or the static `token`.
|
||||
|
||||
### Example setup
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"port": 8765,
|
||||
"path": "/ws",
|
||||
"tokenIssuePath": "/auth/token",
|
||||
@@ -366,7 +371,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"websocketRequiresToken": false,
|
||||
@@ -383,7 +387,6 @@ Outbound `message` events may include a `media` field containing local filesyste
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"token": "my-shared-secret",
|
||||
"allowFrom": ["alice", "bob"]
|
||||
}
|
||||
@@ -399,7 +402,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"path": "/ws",
|
||||
@@ -420,7 +422,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"path": "/chat/ws",
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
|
||||
+162
-37
@@ -1,52 +1,83 @@
|
||||
# WebUI
|
||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||
|
||||
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already
|
||||
works, when you want a persistent chat workspace, visible agent activity,
|
||||
workspace controls, Apps, Skills, settings, and Automations in one place.
|
||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent chat sessions, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||
|
||||
The WebUI is nanobot's browser workbench for persistent chat sessions, visible
|
||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||
one place.
|
||||
|
||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||
the `webui/` source directory when you are changing the frontend itself.
|
||||
|
||||
## Open the WebUI
|
||||
|
||||
First confirm your provider and model can answer:
|
||||
Use the launcher:
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`:
|
||||
`nanobot webui` creates the config/workspace when needed, checks provider setup,
|
||||
offers Quick Start when the model provider is not ready, enables the local
|
||||
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||
one is missing, starts the gateway, and opens the browser. The first-run path
|
||||
binds the WebUI to `127.0.0.1` by default, so it is not available from other
|
||||
devices on your LAN.
|
||||
|
||||
Run it in the background when you do not want to keep a terminal open:
|
||||
|
||||
```bash
|
||||
nanobot webui --background
|
||||
```
|
||||
|
||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||
|
||||
Manual config still works. Same-machine localhost WebUI access can run without
|
||||
a browser password. Set `tokenIssueSecret` when you intentionally expose the
|
||||
WebUI beyond localhost or want a browser password:
|
||||
|
||||
```json
|
||||
{ "channels": { "websocket": { "enabled": true } } }
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"tokenIssueSecret": "your-webui-password",
|
||||
"websocketRequiresToken": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you are new to JSON snippets, see
|
||||
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets).
|
||||
The WebUI is served by the WebSocket channel on port `8765` by default. The
|
||||
gateway health endpoint, `18790` by default, is not the browser UI.
|
||||
|
||||
Start the gateway:
|
||||
## First 10 Minutes
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
Use the WebUI as the primary setup surface after Quick Start:
|
||||
|
||||
Leave the gateway running and open
|
||||
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the
|
||||
WebSocket channel on port `8765` by default. The gateway health endpoint,
|
||||
`18790` by default, is not the browser UI.
|
||||
1. Send `Hello!` in a new chat to prove the selected model works.
|
||||
2. Open **Settings → Models** and confirm the active model preset.
|
||||
3. Start a separate chat before project work, then choose the intended workspace and access mode.
|
||||
4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
|
||||
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
|
||||
|
||||
This path avoids hand-editing `config.json` for normal setup. Use the reference docs when you need an option the WebUI does not expose or when you manage config as code.
|
||||
|
||||
## What It Is For
|
||||
|
||||
| Area | Use it for |
|
||||
|---|---|
|
||||
| Chat | Start, switch, search, fork, and delete browser sessions |
|
||||
| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context |
|
||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
|
||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||
|
||||
## Chat Workspace
|
||||
@@ -59,6 +90,16 @@ without changing the original thread.
|
||||
The message timeline shows both user-visible replies and agent activity. Long
|
||||
tool or reasoning sections can be expanded when you need the details.
|
||||
|
||||
When the agent writes or edits files, the activity item shows the target path,
|
||||
status, changed line counts, and, when available, a unified diff. Use **View
|
||||
diff** to expand the change; large diffs may hide unchanged lines or truncate the
|
||||
inline preview. Use **Open file** from a file edit to open the read-only file
|
||||
preview panel.
|
||||
|
||||
File previews follow the active session access mode. Restricted workspace access
|
||||
previews only files under the selected workspace. Full Access can preview files
|
||||
outside the workspace when that access mode is allowed by the gateway.
|
||||
|
||||
## Workspace and Access
|
||||
|
||||
Use the workspace picker before starting project-specific work. This gives the
|
||||
@@ -70,6 +111,10 @@ chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
||||
system configuration; it only selects among the capabilities that are already
|
||||
available to this WebUI session.
|
||||
|
||||
Remote WebUI sessions may reduce access for the current workspace. Selecting a
|
||||
different workspace or enabling Full Access remains limited to local and native
|
||||
clients.
|
||||
|
||||
## Composer
|
||||
|
||||
The composer supports plain messages, image attachments, voice input when
|
||||
@@ -81,15 +126,47 @@ For image generation, configure an image provider first and then use the WebUI
|
||||
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
||||
for provider setup and output behavior.
|
||||
|
||||
## Channels
|
||||
|
||||
Open **Settings → Channels** to connect chat apps without assembling JSON by hand. Search for a platform, open its setup panel, and follow the fields or QR flow shown for that channel. The guided setup can:
|
||||
|
||||
- install missing optional channel support when the WebUI is running locally;
|
||||
- collect platform credentials while preserving previously saved values;
|
||||
- handle supported QR-based login flows;
|
||||
- validate the connection and show actionable setup errors;
|
||||
- tell you when the gateway needs to restart.
|
||||
|
||||
The platform itself may still require you to create a bot, enable event permissions, copy a token, or configure a webhook. Use [`chat-apps.md`](./chat-apps.md) for those platform-side prerequisites and for manual JSON/reference options.
|
||||
|
||||
Test a new channel with a private DM. When a supported channel sends a pairing code, the WebUI surfaces the pending request so you can approve the sender. Keep access narrow; do not use a wildcard allowlist unless public access is intentional.
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar or settings navigation to manage integrations that
|
||||
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs
|
||||
on your machine; they do not modify the native apps themselves. MCP presets add
|
||||
predefined MCP server configurations.
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
|
||||
After an App or MCP preset is available, mention it from the composer with `@`
|
||||
to attach that capability to the next message.
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||
|
||||
Apps intentionally does not list nanobot runtime support packages such as
|
||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||
are not tools that can be attached to a turn with `@`. Manage them from
|
||||
**System**, **Models**, or **Web**. PDF and common Office document readers are
|
||||
included in nanobot and activate automatically when a file is attached. The
|
||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||
|
||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||
extraction tools without requiring an API key. This does not replace nanobot's
|
||||
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
||||
turn needs Firecrawl's richer web data tools.
|
||||
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -100,36 +177,61 @@ to perform that task.
|
||||
|
||||
## Automations
|
||||
|
||||
Automations are scheduled agent turns. They should be created from the chat,
|
||||
channel, or session where they are supposed to run so nanobot keeps the correct
|
||||
target context.
|
||||
Automations are agent turns that run later in a linked chat/session. They should
|
||||
be created from the chat, channel, or session where they are supposed to run so
|
||||
nanobot keeps the correct target context. When an automation runs, it normally
|
||||
delivers the result back to that linked chat.
|
||||
|
||||
For the full automation model, creation flow, trigger CLI usage, and delivery
|
||||
semantics, see [`automations.md`](./automations.md).
|
||||
|
||||
There are two user-facing automation types:
|
||||
|
||||
- Scheduled automations, created by the agent's cron tool, run at a time,
|
||||
interval, or cron expression.
|
||||
- Local triggers, created with `/trigger <name>`, run when you call a local
|
||||
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
|
||||
|
||||
For recurring background checks that should stay quiet unless there is something
|
||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||
instead of creating a chat automation.
|
||||
|
||||
Use the Automations view to:
|
||||
|
||||
- Filter by all, active, paused, needs-attention, or system jobs.
|
||||
- Search by task name, message, linked chat, schedule, or status.
|
||||
- Search by task name, message, trigger command, linked chat, schedule, or status.
|
||||
- Sort by next run, last run, updated time, or name.
|
||||
- Run now, pause or resume, edit, or delete user-created automations.
|
||||
- Run scheduled automations now.
|
||||
- Pause or resume, rename, or delete user-created automations.
|
||||
- Copy the CLI command for local triggers.
|
||||
- Inspect protected system automations without changing them.
|
||||
|
||||
Search accepts plain text and field filters such as `name:backup`,
|
||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
|
||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
|
||||
`status:paused`.
|
||||
|
||||
An automation without a linked chat cannot be enabled or run from the WebUI,
|
||||
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
||||
from the target chat or channel so the automation has complete context.
|
||||
|
||||
Local triggers do not have a WebUI "Run now" action because each run needs a
|
||||
message. Use the copied `nanobot trigger ...` command and replace `"message"`
|
||||
with the content that should be delivered.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings is the control surface for the browser session and gateway-backed
|
||||
runtime configuration. Use it to review or adjust model presets, provider
|
||||
visibility, image generation, voice transcription, web tools, Apps, Automations,
|
||||
Skills, runtime identity, and advanced safety controls.
|
||||
runtime configuration. Use it to review or adjust model presets, providers,
|
||||
image generation, voice transcription, web tools, chat channels, Apps,
|
||||
Automations, Skills, runtime identity, and advanced safety controls.
|
||||
|
||||
Some settings take effect immediately. Runtime settings that affect the gateway
|
||||
or agent process may require a restart; the WebUI shows that requirement next to
|
||||
the relevant control.
|
||||
|
||||
Browser-only display preferences, such as file edit display mode, take effect
|
||||
immediately for the current browser and do not change gateway configuration.
|
||||
|
||||
## LAN Access
|
||||
|
||||
To open the WebUI from another device on the same network, bind the WebSocket
|
||||
@@ -139,7 +241,6 @@ channel to all interfaces and set a token or token issue secret:
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"tokenIssueSecret": "your-secret-here"
|
||||
@@ -153,12 +254,36 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
environment through the WebUI, opt in explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"webuiAllowRemotePackageInstall": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use this only for a private deployment where every authenticated WebUI user is
|
||||
trusted to change the Python environment that nanobot runs in. If you publish
|
||||
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||
as remote access and leave package installs disabled unless that is intentional.
|
||||
|
||||
Optional feature installs use pip's configured package index, including
|
||||
`PIP_INDEX_URL`.
|
||||
|
||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||
private, trusted network.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the page does not open, check these in order:
|
||||
|
||||
1. `nanobot agent -m "Hello!"` works in the same Python environment.
|
||||
2. The WebSocket channel is enabled in `~/.nanobot/config.json`.
|
||||
2. `~/.nanobot/config.json` does not explicitly set `channels.websocket.enabled` to `false`.
|
||||
3. `nanobot gateway` is still running.
|
||||
4. You are opening port `8765`, not the gateway health port.
|
||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
#!/bin/sh
|
||||
dir="$HOME/.nanobot"
|
||||
|
||||
# Render deploy path (see render.yaml + render-config.json). Gated on Render's
|
||||
# automatic RENDER=true env var so local Docker/podman usage is unaffected.
|
||||
# Initializes the on-disk config from the committed template (wiring secrets via
|
||||
# ${VAR} env vars, keeping runtime data on the persistent disk) and appends the
|
||||
# --config flag. Logs each decision so a failed start is diagnosable in Render's
|
||||
# logs. Privilege dropping is handled below, for every root start (not just here).
|
||||
if [ "$RENDER" = "true" ]; then
|
||||
echo "[entrypoint] Render deploy — starting as $(id)"
|
||||
mkdir -p "$dir" || echo "[entrypoint] warning: mkdir $dir failed"
|
||||
config="$dir/config.json"
|
||||
# Initialize config only when it does not already exist, so WebUI/provider
|
||||
# settings edited at runtime survive restarts. The disk persists config.json
|
||||
# across deploys; overwriting it every boot would discard those changes.
|
||||
if [ ! -f "$config" ]; then
|
||||
echo "[entrypoint] initializing $config from render-config.json"
|
||||
cp /app/render-config.json "$config" || echo "[entrypoint] warning: cp config failed"
|
||||
else
|
||||
echo "[entrypoint] existing $config found — leaving it in place"
|
||||
fi
|
||||
set -- "$@" --config "$config"
|
||||
fi
|
||||
|
||||
# Drop privileges whenever the container starts as root. Render mounts the
|
||||
# persistent disk root-owned, and a plain `docker run` also defaults to root now,
|
||||
# so this covers both. Chown the data dir so the non-root user can write it, then
|
||||
# re-exec as nanobot. Fail closed: if the privilege drop cannot be performed,
|
||||
# exit rather than run the agent as root.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R nanobot:nanobot "$dir" 2>/dev/null || echo "[entrypoint] warning: chown $dir failed"
|
||||
if setpriv --reuid=nanobot --regid=nanobot --init-groups true 2>/dev/null; then
|
||||
echo "[entrypoint] dropping privileges to nanobot via setpriv"
|
||||
exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@"
|
||||
fi
|
||||
echo "[entrypoint] error: started as root but setpriv privilege drop failed — refusing to run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Already non-root: make sure the data dir is writable before starting.
|
||||
if [ -d "$dir" ] && [ ! -w "$dir" ]; then
|
||||
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
|
||||
cat >&2 <<EOF
|
||||
@@ -12,4 +51,5 @@ Fix (pick one):
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec nanobot "$@"
|
||||
|
||||
+32
-30
@@ -4,7 +4,7 @@ Triggered automatically by `python -m build` (and any other hatch-driven build)
|
||||
so published wheels and sdists ship a fresh webui without requiring developers
|
||||
to remember `cd webui && bun run build` beforehand.
|
||||
|
||||
Behaviour:
|
||||
Behavior:
|
||||
|
||||
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
|
||||
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
|
||||
@@ -12,7 +12,7 @@ Behaviour:
|
||||
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
|
||||
already contains a prebuilt `nanobot/web/dist/`).
|
||||
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
|
||||
- Skips when `nanobot/web/dist/index.html` already exists, unless
|
||||
- Reuses `nanobot/web/dist/` only when it is already fresh, unless
|
||||
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
|
||||
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
|
||||
performs `install` followed by `run build`.
|
||||
@@ -21,12 +21,22 @@ Behaviour:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
|
||||
def _load_webui_build_module() -> ModuleType:
|
||||
from nanobot.webui import build as webui_build
|
||||
|
||||
return webui_build
|
||||
|
||||
|
||||
class WebUIBuildHook(BuildHookInterface):
|
||||
PLUGIN_NAME = "webui-build"
|
||||
@@ -58,24 +68,32 @@ class WebUIBuildHook(BuildHookInterface):
|
||||
)
|
||||
return
|
||||
|
||||
webui_build = _load_webui_build_module()
|
||||
status = webui_build.inspect_webui_bundle(source_dir=webui_dir, dist_dir=dist_dir)
|
||||
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
|
||||
if index_html.is_file() and not force:
|
||||
if not status.needs_build and not force:
|
||||
self.app.display_info(
|
||||
f"[webui-build] reusing existing build at {dist_dir} "
|
||||
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||
"(already fresh; set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
|
||||
)
|
||||
return
|
||||
|
||||
runner = self._pick_runner()
|
||||
if runner is None:
|
||||
raise RuntimeError(
|
||||
"[webui-build] neither `bun` nor `npm` is available on PATH; "
|
||||
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||
if status.needs_build and not force:
|
||||
self.app.display_info(
|
||||
f"[webui-build] {webui_build.describe_webui_bundle_status(status)}"
|
||||
)
|
||||
|
||||
self.app.display_info(f"[webui-build] using {runner} to build webui")
|
||||
self._run([runner, "install"], cwd=webui_dir)
|
||||
self._run([runner, "run", "build"], cwd=webui_dir)
|
||||
try:
|
||||
webui_build.build_webui_bundle(
|
||||
source_dir=webui_dir,
|
||||
dist_dir=dist_dir,
|
||||
output=self.app.display_info,
|
||||
)
|
||||
except webui_build.WebUIBuildError as exc:
|
||||
raise RuntimeError(
|
||||
"[webui-build] "
|
||||
f"{exc}. Install `bun` or `npm`, or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
|
||||
) from exc
|
||||
|
||||
if not index_html.is_file():
|
||||
raise RuntimeError(
|
||||
@@ -83,19 +101,3 @@ class WebUIBuildHook(BuildHookInterface):
|
||||
"check webui/vite.config.ts outDir."
|
||||
)
|
||||
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
|
||||
|
||||
@staticmethod
|
||||
def _pick_runner() -> str | None:
|
||||
for candidate in ("bun", "npm"):
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _run(self, cmd: list[str], *, cwd: Path) -> None:
|
||||
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
|
||||
try:
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
|
||||
) from exc
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 657 KiB |
+37
-2
@@ -22,7 +22,7 @@ def _resolve_version() -> str:
|
||||
return _pkg_version("nanobot-ai")
|
||||
except PackageNotFoundError:
|
||||
# Source checkouts often import nanobot without installed dist-info.
|
||||
return _read_pyproject_version() or "0.2.1"
|
||||
return _read_pyproject_version() or "0.2.2"
|
||||
|
||||
|
||||
__version__ = _resolve_version()
|
||||
@@ -30,7 +30,23 @@ __logo__ = "🐈"
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"Nanobot": ".nanobot",
|
||||
"RunStream": ".nanobot",
|
||||
"RunResult": ".nanobot",
|
||||
"SessionInfo": ".nanobot",
|
||||
"SessionSnapshot": ".nanobot",
|
||||
"STREAM_EVENT_REASONING_COMPLETED": ".nanobot",
|
||||
"STREAM_EVENT_REASONING_DELTA": ".nanobot",
|
||||
"STREAM_EVENT_RUN_COMPLETED": ".nanobot",
|
||||
"STREAM_EVENT_RUN_FAILED": ".nanobot",
|
||||
"STREAM_EVENT_RUN_STARTED": ".nanobot",
|
||||
"STREAM_EVENT_TEXT_COMPLETED": ".nanobot",
|
||||
"STREAM_EVENT_TEXT_DELTA": ".nanobot",
|
||||
"STREAM_EVENT_TOOL_COMPLETED": ".nanobot",
|
||||
"STREAM_EVENT_TOOL_FAILED": ".nanobot",
|
||||
"STREAM_EVENT_TOOL_STARTED": ".nanobot",
|
||||
"STREAM_EVENT_TYPES": ".nanobot",
|
||||
"StreamEvent": ".nanobot",
|
||||
"StreamEventType": ".nanobot",
|
||||
}
|
||||
|
||||
|
||||
@@ -45,4 +61,23 @@ def __getattr__(name: str):
|
||||
return val
|
||||
|
||||
|
||||
__all__ = ["Nanobot", "RunResult"]
|
||||
__all__ = [
|
||||
"Nanobot",
|
||||
"RunResult",
|
||||
"RunStream",
|
||||
"SessionInfo",
|
||||
"SessionSnapshot",
|
||||
"STREAM_EVENT_REASONING_COMPLETED",
|
||||
"STREAM_EVENT_REASONING_DELTA",
|
||||
"STREAM_EVENT_RUN_COMPLETED",
|
||||
"STREAM_EVENT_RUN_FAILED",
|
||||
"STREAM_EVENT_RUN_STARTED",
|
||||
"STREAM_EVENT_TEXT_COMPLETED",
|
||||
"STREAM_EVENT_TEXT_DELTA",
|
||||
"STREAM_EVENT_TOOL_COMPLETED",
|
||||
"STREAM_EVENT_TOOL_FAILED",
|
||||
"STREAM_EVENT_TOOL_STARTED",
|
||||
"STREAM_EVENT_TYPES",
|
||||
"StreamEvent",
|
||||
"StreamEventType",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Agent core module."""
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext, CompositeHook
|
||||
from nanobot.agent.hook import (
|
||||
AgentHook,
|
||||
AgentHookContext,
|
||||
AgentRunHookContext,
|
||||
AgentTurnHookContext,
|
||||
AgentTurnHookFactory,
|
||||
CompositeHook,
|
||||
)
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
@@ -11,6 +18,8 @@ __all__ = [
|
||||
"AgentHook",
|
||||
"AgentHookContext",
|
||||
"AgentRunHookContext",
|
||||
"AgentTurnHookContext",
|
||||
"AgentTurnHookFactory",
|
||||
"AgentLoop",
|
||||
"CompositeHook",
|
||||
"ContextBuilder",
|
||||
|
||||
@@ -12,6 +12,7 @@ from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.memory import Consolidator
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
class AutoCompact:
|
||||
@@ -34,6 +35,26 @@ class AutoCompact:
|
||||
ts = datetime.fromisoformat(ts)
|
||||
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
||||
|
||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||
session = self.sessions.get_or_create(key)
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
return False
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=tail,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
self._RECENT_SUFFIX_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
return bool(messages_to_remove)
|
||||
|
||||
@staticmethod
|
||||
def _format_summary(text: str, last_active: datetime) -> str:
|
||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
||||
@@ -42,8 +63,12 @@ class AutoCompact:
|
||||
def _is_internal_session(cls, key: str) -> bool:
|
||||
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
|
||||
|
||||
def check_expired(self, schedule_background: Callable[[Coroutine], None],
|
||||
active_session_keys: Collection[str] = ()) -> None:
|
||||
def check_expired(
|
||||
self,
|
||||
schedule_background: Callable[[Coroutine], None],
|
||||
resolve_runtime: Callable[[], LLMRuntime],
|
||||
active_session_keys: Collection[str] = (),
|
||||
) -> None:
|
||||
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
|
||||
now = datetime.now()
|
||||
for info in self.sessions.list_sessions():
|
||||
@@ -52,17 +77,21 @@ class AutoCompact:
|
||||
continue
|
||||
if key in active_session_keys:
|
||||
continue
|
||||
if self._is_expired(info.get("updated_at"), now):
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
runtime = resolve_runtime()
|
||||
self._archiving.add(key)
|
||||
schedule_background(self._archive(key))
|
||||
schedule_background(self._archive(key, runtime=runtime))
|
||||
|
||||
async def _archive(self, key: str) -> None:
|
||||
async def _archive(self, key: str, *, runtime: LLMRuntime) -> None:
|
||||
if self._is_internal_session(key):
|
||||
self._archiving.discard(key)
|
||||
return
|
||||
try:
|
||||
summary = await self.consolidator.compact_idle_session(
|
||||
key, self._RECENT_SUFFIX_MESSAGES,
|
||||
key,
|
||||
runtime=runtime,
|
||||
max_suffix=self._RECENT_SUFFIX_MESSAGES,
|
||||
)
|
||||
if summary and summary != "(nothing)":
|
||||
session = self.sessions.get_or_create(key)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Shared coordination for session-bound automation turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
|
||||
class AutomationTurnError(RuntimeError):
|
||||
"""Raised when an automation turn reaches the agent and finishes with an error."""
|
||||
|
||||
|
||||
async def publish_next_deferred_turn(
|
||||
*,
|
||||
deferred_queues: dict[str, list[InboundMessage]],
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
session_key: str,
|
||||
) -> bool:
|
||||
"""Publish the next deferred automation turn for a session."""
|
||||
queue = deferred_queues.get(session_key)
|
||||
if not queue:
|
||||
return False
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
deferred_queues.pop(session_key, None)
|
||||
await publish_inbound(msg)
|
||||
return True
|
||||
|
||||
|
||||
class AutomationTurnCoordinator:
|
||||
"""Manage automation turns without mixing them into live injections."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||
is_running: Callable[[], bool],
|
||||
turn_id: Callable[[InboundMessage], str | None],
|
||||
pending_id: Callable[[InboundMessage], str | None],
|
||||
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
|
||||
missing_id_error: str,
|
||||
duplicate_id_error: Callable[[str], str],
|
||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
||||
) -> None:
|
||||
self._publish_inbound = publish_inbound
|
||||
self._dispatch = dispatch
|
||||
self._is_running = is_running
|
||||
self._turn_id = turn_id
|
||||
self._pending_id = pending_id
|
||||
self._should_defer_turn = should_defer_turn
|
||||
self._missing_id_error = missing_id_error
|
||||
self._duplicate_id_error = duplicate_id_error
|
||||
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
|
||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
|
||||
|
||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit an automation turn and wait for its session response."""
|
||||
turn_id = self._turn_id(msg)
|
||||
if not turn_id:
|
||||
raise ValueError(self._missing_id_error)
|
||||
if turn_id in self._waiters:
|
||||
raise RuntimeError(self._duplicate_id_error(turn_id))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
self._waiters[turn_id] = future
|
||||
self._pending_messages_by_turn_id[turn_id] = msg
|
||||
try:
|
||||
if self._is_running():
|
||||
await self._publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
try:
|
||||
return await future
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except AutomationTurnError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
|
||||
finally:
|
||||
self._waiters.pop(turn_id, None)
|
||||
self._pending_messages_by_turn_id.pop(turn_id, None)
|
||||
|
||||
def defer_if_active(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
"""Defer an automation turn when its target session is already active."""
|
||||
if not self._should_defer_turn(msg, session_key, active_session_keys):
|
||||
return False
|
||||
pending_msg = msg
|
||||
if session_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=session_key,
|
||||
)
|
||||
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
|
||||
return True
|
||||
|
||||
def complete(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
turn_id = self._turn_id(msg)
|
||||
if not turn_id:
|
||||
return
|
||||
future = self._waiters.get(turn_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
if isinstance(error, asyncio.CancelledError):
|
||||
error = AutomationTurnError(str(error) or error.__class__.__name__)
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def pending_ids_for_session(self, session_key: str) -> set[str]:
|
||||
"""Return automation IDs that are waiting for or running in *session_key*."""
|
||||
pending_ids: set[str] = set()
|
||||
for msg in self.deferred_queues.get(session_key, []):
|
||||
pending_id = self._pending_id(msg)
|
||||
if pending_id:
|
||||
pending_ids.add(pending_id)
|
||||
for msg in self._pending_messages_by_turn_id.values():
|
||||
if msg.session_key != session_key:
|
||||
continue
|
||||
pending_id = self._pending_id(msg)
|
||||
if pending_id:
|
||||
pending_ids.add(pending_id)
|
||||
return pending_ids
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||
return await publish_next_deferred_turn(
|
||||
deferred_queues=self.deferred_queues,
|
||||
publish_inbound=self._publish_inbound,
|
||||
session_key=session_key,
|
||||
)
|
||||
+29
-69
@@ -12,9 +12,14 @@ from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session.goal_state import goal_state_runtime_lines
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_END,
|
||||
RUNTIME_CONTEXT_MESSAGE_META,
|
||||
RUNTIME_CONTEXT_TAG,
|
||||
RuntimeContextBlock,
|
||||
append_runtime_context,
|
||||
)
|
||||
from nanobot.utils.helpers import (
|
||||
current_time_str,
|
||||
detect_image_mime,
|
||||
load_bundled_template,
|
||||
truncate_text_to_tokens,
|
||||
@@ -27,28 +32,14 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
||||
|
||||
|
||||
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||
"""Return model-visible runtime annotations for turn-attached capabilities."""
|
||||
lines = [
|
||||
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
|
||||
*mcp_tools.runtime_lines(
|
||||
msg,
|
||||
configured_server_names=set(state._mcp_servers),
|
||||
connected_server_names=set(state._mcp_stacks),
|
||||
skip=skip,
|
||||
),
|
||||
]
|
||||
if not skip and getattr(state, "subagents", None) is not None:
|
||||
session_key = getattr(msg, "session_key", None)
|
||||
if session_key:
|
||||
lines.extend(state.subagents.runtime_status_lines(session_key))
|
||||
return lines
|
||||
|
||||
|
||||
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||
await mcp_tools.connect_missing_servers(state, tools)
|
||||
|
||||
|
||||
async def close_mcp(state: Any) -> None:
|
||||
await mcp_tools.close_mcp_servers(state)
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||
return await mcp_tools.handle_runtime_control(state, msg, tools)
|
||||
|
||||
@@ -57,10 +48,10 @@ class ContextBuilder:
|
||||
"""Builds the context (system prompt + messages) for the agent."""
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||
_MAX_RECENT_HISTORY = 50
|
||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||
|
||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||
self.workspace = workspace
|
||||
@@ -136,28 +127,14 @@ class ContextBuilder:
|
||||
channel=channel or "",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_runtime_context(
|
||||
channel: str | None,
|
||||
chat_id: str | None,
|
||||
timezone: str | None = None,
|
||||
sender_id: str | None = None,
|
||||
supplemental_lines: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Build untrusted runtime metadata block appended after user content."""
|
||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
||||
if channel and chat_id:
|
||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||
if sender_id:
|
||||
lines += [f"Sender ID: {sender_id}"]
|
||||
if supplemental_lines:
|
||||
lines.extend(supplemental_lines)
|
||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
if isinstance(left, str) and isinstance(right, str):
|
||||
return f"{left}\n\n{right}" if left else right
|
||||
if not left:
|
||||
return right
|
||||
if not right:
|
||||
return left
|
||||
return f"{left}\n\n{right}"
|
||||
|
||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
@@ -201,41 +178,17 @@ class ContextBuilder:
|
||||
sender_id: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
session_metadata: Mapping[str, Any] | None = None,
|
||||
current_runtime_lines: Sequence[str] | None = None,
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||
workspace: Path | None = None,
|
||||
runtime_state: Any | None = None,
|
||||
inbound_message: Any | None = None,
|
||||
skip_runtime_lines: bool = False,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
root = workspace or self.workspace
|
||||
extra = [
|
||||
*goal_state_runtime_lines(session_metadata),
|
||||
]
|
||||
if runtime_state is not None and inbound_message is not None:
|
||||
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
|
||||
if current_runtime_lines:
|
||||
extra.extend(line for line in current_runtime_lines if line)
|
||||
runtime_ctx = self._build_runtime_context(
|
||||
channel,
|
||||
chat_id,
|
||||
self.timezone,
|
||||
sender_id=sender_id,
|
||||
supplemental_lines=extra or None,
|
||||
)
|
||||
user_content = self._build_user_content(current_message, media)
|
||||
|
||||
# Merge runtime context and user content into a single user message
|
||||
# to avoid consecutive same-role messages that some providers reject.
|
||||
# Runtime context is appended to keep the user-content prefix stable
|
||||
# for prompt-cache hits (the context changes every turn due to time).
|
||||
if isinstance(user_content, str):
|
||||
merged = f"{user_content}\n\n{runtime_ctx}"
|
||||
else:
|
||||
merged = user_content + [{"type": "text", "text": runtime_ctx}]
|
||||
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -254,9 +207,16 @@ class ContextBuilder:
|
||||
if messages[-1].get("role") == current_role:
|
||||
last = dict(messages[-1])
|
||||
last["content"] = self._merge_message_content(last.get("content"), merged)
|
||||
if current_role == "user" and runtime_context_meta is not None:
|
||||
internal_meta = dict(last.get("_meta") or {})
|
||||
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta
|
||||
last["_meta"] = internal_meta
|
||||
messages[-1] = last
|
||||
return messages
|
||||
messages.append({"role": current_role, "content": merged})
|
||||
current = {"role": current_role, "content": merged}
|
||||
if current_role == "user" and runtime_context_meta is not None:
|
||||
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
||||
messages.append(current)
|
||||
return messages
|
||||
|
||||
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Model-message governance for agent runner requests.
|
||||
|
||||
This module owns model-facing message shaping and tool-result content normalization.
|
||||
It may return copied messages or persisted-result placeholders, but it must not
|
||||
mutate an existing session history list in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.runtime import ensure_nonempty_tool_result
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
SNIP_SAFETY_BUFFER = 1024
|
||||
MICROCOMPACT_KEEP_RECENT = 10
|
||||
MICROCOMPACT_MIN_CHARS = 500
|
||||
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
||||
COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
PLACEHOLDER_TEXTS = frozenset({
|
||||
"[Previous assistant message omitted.]",
|
||||
})
|
||||
|
||||
|
||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||
|
||||
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
|
||||
message history: a degenerate call with ``name=None`` / ``""`` cannot be
|
||||
executed and is rejected by upstream APIs if replayed.
|
||||
"""
|
||||
if not isinstance(tool_call, dict):
|
||||
return False
|
||||
fn = tool_call.get("function")
|
||||
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
|
||||
return isinstance(name, str) and bool(name)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextGovernanceConfig:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
tools: Any
|
||||
workspace: Path | None
|
||||
session_key: str | None
|
||||
max_tool_result_chars: int
|
||||
context_window_tokens: int | None = None
|
||||
context_block_limit: int | None = None
|
||||
max_tokens: int | None = None
|
||||
inflight_start_index: int = 0
|
||||
|
||||
|
||||
class ContextGovernor:
|
||||
"""Prepare model-copy messages while preserving persisted history."""
|
||||
|
||||
def prepare_for_model(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = self.strip_placeholder_assistant_messages(messages)
|
||||
updated = self.strip_malformed_tool_calls(updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
updated = self.apply_tool_result_budget(config, updated)
|
||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||
updated = self.snip_history(config, updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
return self.backfill_missing_tool_results(updated)
|
||||
|
||||
@staticmethod
|
||||
def input_budget(config: ContextGovernanceConfig) -> int:
|
||||
if not config.context_window_tokens:
|
||||
return 0
|
||||
|
||||
provider_max_tokens = getattr(
|
||||
getattr(config.provider, "generation", None),
|
||||
"max_tokens",
|
||||
4096,
|
||||
)
|
||||
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = config.context_block_limit or (
|
||||
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
|
||||
)
|
||||
return budget if budget > 0 else 0
|
||||
|
||||
@staticmethod
|
||||
def normalize_tool_result(
|
||||
config: ContextGovernanceConfig,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
config.workspace,
|
||||
config.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=config.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
config.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
|
||||
return truncate_text(content, config.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def strip_placeholder_assistant_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Remove assistant messages that are compaction placeholders.
|
||||
|
||||
Messages like ``[Previous assistant message omitted.]`` carry no useful
|
||||
context for the model and can cause it to repeatedly attempt tool calls
|
||||
that previously failed, producing malformed responses in a loop.
|
||||
Consecutive same-role messages that result from removal are handled
|
||||
downstream by the provider's merge-consecutive logic. Only the
|
||||
model-facing copy is repaired; the persisted transcript is untouched
|
||||
(a copy is returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
text = content if isinstance(content, str) else ""
|
||||
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
|
||||
has_tool_calls = bool(msg.get("tool_calls"))
|
||||
if is_placeholder and not has_tool_calls:
|
||||
if updated is None:
|
||||
updated = list(messages[:idx])
|
||||
logger.debug(
|
||||
"Stripping placeholder assistant message from history: {!r}",
|
||||
text[:60],
|
||||
)
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def strip_malformed_tool_calls(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop persisted assistant tool_calls whose name is missing/non-string.
|
||||
|
||||
A degenerate tool call (``name=None`` or ``""``) that slipped into the
|
||||
saved history before this guard existed gets replayed on every turn and
|
||||
makes upstream APIs reject the whole request
|
||||
(``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||
permanently wedging the session. Removing the bad call here lets the
|
||||
existing orphan-result cleanup drop its now-dangling tool result, so a
|
||||
polluted session self-heals on its next turn. The persisted transcript
|
||||
is left untouched; only the model-facing copy is repaired (a copy is
|
||||
returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
calls = msg.get("tool_calls")
|
||||
if not calls:
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
|
||||
if len(kept) == len(calls):
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
logger.warning(
|
||||
"Stripping {} malformed tool_call(s) with missing/non-string "
|
||||
"name from assistant history before request",
|
||||
len(calls) - len(kept),
|
||||
)
|
||||
repaired = dict(msg)
|
||||
if kept:
|
||||
repaired["tool_calls"] = kept
|
||||
else:
|
||||
repaired.pop("tool_calls", None)
|
||||
# An assistant turn with neither content nor any valid tool call is
|
||||
# itself invalid upstream; drop it entirely in that case.
|
||||
has_content = bool(repaired.get("content"))
|
||||
if not kept and not has_content:
|
||||
continue
|
||||
updated.append(repaired)
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
|
||||
declared: list[tuple[int, str, str]] = []
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
def apply_tool_result_budget(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self.normalize_tool_result(
|
||||
config,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def compact_inflight_overflow(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compact in-flight tool results only when the request would overflow."""
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return updated
|
||||
|
||||
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
|
||||
candidates = self._inflight_compaction_candidates(
|
||||
config,
|
||||
updated,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
if not candidates:
|
||||
return updated
|
||||
|
||||
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
|
||||
is_newest_candidate = candidate_idx == len(candidates) - 1
|
||||
if is_newest_candidate and estimate <= budget:
|
||||
break
|
||||
if tool_call_id in compacted_tool_call_ids:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
compacted_tool_call_ids.add(tool_call_id)
|
||||
self._compact_tool_result_at(updated, idx)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
|
||||
config.session_key or "default",
|
||||
estimate,
|
||||
budget,
|
||||
target,
|
||||
source,
|
||||
len(compacted_tool_call_ids),
|
||||
)
|
||||
return updated
|
||||
|
||||
def snip_history(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not config.context_window_tokens:
|
||||
return messages
|
||||
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
system_messages,
|
||||
tools,
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
return system_messages + self._legal_history_tail(kept, non_system)
|
||||
|
||||
@staticmethod
|
||||
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
|
||||
name = message.get("name", "tool")
|
||||
return (
|
||||
f"Error: The previous {name} result was compacted to fit context because it was too "
|
||||
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
|
||||
"range, or result limit, use another tool, or tell the user the task cannot fit in "
|
||||
"the available context."
|
||||
)
|
||||
|
||||
def _legal_history_tail(
|
||||
self,
|
||||
kept: list[dict[str, Any]],
|
||||
non_system: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
fallback = kept if kept else (non_system[-1:] if non_system else [])
|
||||
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
|
||||
|
||||
start = find_legal_message_start(kept)
|
||||
return kept[start:] if start else kept
|
||||
|
||||
@staticmethod
|
||||
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
|
||||
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
|
||||
for idx in indexes:
|
||||
if messages[idx].get("role") == "user":
|
||||
return messages[idx:]
|
||||
return []
|
||||
|
||||
def _apply_recorded_compactions(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not compacted_tool_call_ids:
|
||||
return messages
|
||||
updated = messages
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||
continue
|
||||
compaction_message = self._tool_result_compaction_message(msg)
|
||||
if msg.get("content") == compaction_message:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = compaction_message
|
||||
return updated
|
||||
|
||||
def _inflight_compaction_candidates(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[tuple[int, str]]:
|
||||
compactable: list[tuple[int, str]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if idx < config.inflight_start_index:
|
||||
continue
|
||||
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
compactable.append((idx, str(tool_call_id)))
|
||||
|
||||
if not compactable:
|
||||
return []
|
||||
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
|
||||
primary = compactable[:primary_count]
|
||||
# Hard overflow beats the keep-recent preference. Return recent results
|
||||
# after stale ones so the newest result is naturally last.
|
||||
fallback = compactable[primary_count:]
|
||||
return primary + fallback
|
||||
|
||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
||||
+22
-107
@@ -2,11 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.agent.automation_turns import AutomationTurnCoordinator
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.cron.session_turns import (
|
||||
cron_run_id,
|
||||
cron_trigger,
|
||||
@@ -14,7 +13,7 @@ from nanobot.cron.session_turns import (
|
||||
)
|
||||
|
||||
|
||||
class CronTurnCoordinator:
|
||||
class CronTurnCoordinator(AutomationTurnCoordinator):
|
||||
"""Manage scheduled cron turns without mixing them into live injections."""
|
||||
|
||||
def __init__(
|
||||
@@ -23,115 +22,31 @@ class CronTurnCoordinator:
|
||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||
is_running: Callable[[], bool],
|
||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
||||
) -> None:
|
||||
self._publish_inbound = publish_inbound
|
||||
self._dispatch = dispatch
|
||||
self._is_running = is_running
|
||||
self.deferred_queues: dict[str, list[InboundMessage]] = {}
|
||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
|
||||
|
||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
"""Submit a scheduled cron turn and wait for its session response."""
|
||||
run_id = cron_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
raise ValueError("cron turn metadata must include a run_id")
|
||||
if run_id in self._waiters:
|
||||
raise RuntimeError(f"cron run {run_id!r} is already pending")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||
self._waiters[run_id] = future
|
||||
self._pending_messages_by_run_id[run_id] = msg
|
||||
try:
|
||||
if self._is_running():
|
||||
await self._publish_inbound(msg)
|
||||
else:
|
||||
await self._dispatch(msg)
|
||||
return await future
|
||||
finally:
|
||||
self._waiters.pop(run_id, None)
|
||||
self._pending_messages_by_run_id.pop(run_id, None)
|
||||
|
||||
def should_defer(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return (
|
||||
defer_cron_until_session_idle(msg.metadata)
|
||||
and session_key in active_session_keys
|
||||
super().__init__(
|
||||
publish_inbound=publish_inbound,
|
||||
dispatch=dispatch,
|
||||
is_running=is_running,
|
||||
turn_id=lambda msg: cron_run_id(msg.metadata),
|
||||
pending_id=_cron_job_id,
|
||||
should_defer_turn=_should_defer_cron_turn,
|
||||
missing_id_error="cron turn metadata must include a run_id",
|
||||
duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
|
||||
deferred_queues=deferred_queues,
|
||||
)
|
||||
|
||||
def defer_if_active(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
"""Defer a cron turn when its target session is already active."""
|
||||
if not self.should_defer(
|
||||
msg,
|
||||
session_key=session_key,
|
||||
active_session_keys=active_session_keys,
|
||||
):
|
||||
return False
|
||||
pending_msg = msg
|
||||
if session_key != msg.session_key:
|
||||
pending_msg = dataclasses.replace(
|
||||
msg,
|
||||
session_key_override=session_key,
|
||||
)
|
||||
self.defer(session_key, pending_msg)
|
||||
return True
|
||||
|
||||
def complete(
|
||||
self,
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
response: OutboundMessage | None = None,
|
||||
error: BaseException | None = None,
|
||||
) -> None:
|
||||
run_id = cron_run_id(msg.metadata)
|
||||
if not run_id:
|
||||
return
|
||||
future = self._waiters.get(run_id)
|
||||
if future is None or future.done():
|
||||
return
|
||||
if error is not None:
|
||||
future.set_exception(error)
|
||||
else:
|
||||
future.set_result(response)
|
||||
|
||||
def defer(self, session_key: str, msg: InboundMessage) -> None:
|
||||
self.deferred_queues.setdefault(session_key, []).append(msg)
|
||||
|
||||
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
|
||||
"""Return cron jobs that are waiting for or running in *session_key*."""
|
||||
job_ids: set[str] = set()
|
||||
for msg in self.deferred_queues.get(session_key, []):
|
||||
job_id = _cron_job_id(msg)
|
||||
if job_id:
|
||||
job_ids.add(job_id)
|
||||
for msg in self._pending_messages_by_run_id.values():
|
||||
if msg.session_key != session_key:
|
||||
continue
|
||||
job_id = _cron_job_id(msg)
|
||||
if job_id:
|
||||
job_ids.add(job_id)
|
||||
return job_ids
|
||||
return self.pending_ids_for_session(session_key)
|
||||
|
||||
async def publish_next_deferred(self, session_key: str) -> None:
|
||||
queue = self.deferred_queues.get(session_key)
|
||||
if not queue:
|
||||
return
|
||||
msg = queue.pop(0)
|
||||
if not queue:
|
||||
self.deferred_queues.pop(session_key, None)
|
||||
await self._publish_inbound(msg)
|
||||
|
||||
def _should_defer_cron_turn(
|
||||
msg: InboundMessage,
|
||||
session_key: str,
|
||||
active_session_keys: Iterable[str],
|
||||
) -> bool:
|
||||
return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
|
||||
|
||||
|
||||
def _cron_job_id(msg: InboundMessage) -> str | None:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Turn-local permission for explicit sustained-goal mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
_GOAL_MUTATION_ALLOWED: ContextVar[bool] = ContextVar(
|
||||
"nanobot_goal_mutation_allowed",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
def goal_mutation_allowed() -> bool:
|
||||
return _GOAL_MUTATION_ALLOWED.get()
|
||||
|
||||
|
||||
def revoke_goal_mutation_permission() -> None:
|
||||
_GOAL_MUTATION_ALLOWED.set(False)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def goal_mutation_permission(allowed: bool):
|
||||
"""Bind goal permission for one agent-run or direct tool execution scope."""
|
||||
token = _GOAL_MUTATION_ALLOWED.set(allowed)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_GOAL_MUTATION_ALLOWED.reset(token)
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -44,6 +46,20 @@ class AgentRunHookContext:
|
||||
exception: BaseException | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentTurnHookContext:
|
||||
"""Turn-local inputs available when constructing per-turn hooks."""
|
||||
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None
|
||||
workspace: Path | None = None
|
||||
channel: str = "cli"
|
||||
chat_id: str = "direct"
|
||||
message_id: str | None = None
|
||||
session_key: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
ephemeral: bool = False
|
||||
|
||||
|
||||
class AgentHook:
|
||||
"""Minimal lifecycle surface for shared runner customization."""
|
||||
|
||||
@@ -77,6 +93,35 @@ class AgentHook:
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
pass
|
||||
|
||||
async def before_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def after_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
result: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def on_execute_tool_error(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
error: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
pass
|
||||
|
||||
@@ -95,6 +140,9 @@ class AgentHook:
|
||||
return content
|
||||
|
||||
|
||||
AgentTurnHookFactory = Callable[[AgentTurnHookContext], AgentHook | None]
|
||||
|
||||
|
||||
class CompositeHook(AgentHook):
|
||||
"""Fan-out hook that delegates to an ordered list of hooks.
|
||||
|
||||
@@ -147,6 +195,49 @@ class CompositeHook(AgentHook):
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
await self._for_each_hook_safe("before_execute_tools", context)
|
||||
|
||||
async def before_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
) -> None:
|
||||
await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params)
|
||||
|
||||
async def after_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
result: Any,
|
||||
) -> None:
|
||||
await self._for_each_hook_safe(
|
||||
"after_execute_tool",
|
||||
context,
|
||||
tool_call,
|
||||
tool,
|
||||
params,
|
||||
result,
|
||||
)
|
||||
|
||||
async def on_execute_tool_error(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
error: Any,
|
||||
) -> None:
|
||||
await self._for_each_hook_safe(
|
||||
"on_execute_tool_error",
|
||||
context,
|
||||
tool_call,
|
||||
tool,
|
||||
params,
|
||||
error,
|
||||
)
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
||||
|
||||
@@ -176,12 +267,26 @@ class SDKCaptureHook(AgentHook):
|
||||
super().__init__()
|
||||
self.tools_used: list[str] = []
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
self.usage: dict[str, int] = {}
|
||||
self.stop_reason: str | None = None
|
||||
self.error: str | None = None
|
||||
self.tool_events: list[dict[str, str]] = []
|
||||
self.had_injections: bool = False
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
for call in context.tool_calls:
|
||||
self.tools_used.append(call.name)
|
||||
self.messages = list(context.messages)
|
||||
self.usage = dict(context.usage)
|
||||
self.stop_reason = context.stop_reason
|
||||
self.error = context.error
|
||||
self.tool_events = list(context.tool_events)
|
||||
|
||||
async def after_run(self, context: AgentRunHookContext) -> None:
|
||||
self.tools_used = list(context.tools_used)
|
||||
self.messages = list(context.messages)
|
||||
self.usage = dict(context.usage)
|
||||
self.stop_reason = context.stop_reason
|
||||
self.error = context.error
|
||||
self.tool_events = list(context.tool_events)
|
||||
self.had_injections = context.had_injections
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Concrete agent hook implementations."""
|
||||
|
||||
from nanobot.agent.hooks.file_edit_activity import (
|
||||
FileEditActivityHook,
|
||||
create_file_edit_activity_hook,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FileEditActivityHook",
|
||||
"create_file_edit_activity_hook",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Agent hook that observes file-editing tools and emits file-edit activity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.hook import (
|
||||
AgentHook,
|
||||
AgentHookContext,
|
||||
AgentRunHookContext,
|
||||
AgentTurnHookContext,
|
||||
)
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.utils.file_edit_events import (
|
||||
FileEditTracker,
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_error_event,
|
||||
build_file_edit_start_event,
|
||||
prepare_file_edit_trackers,
|
||||
)
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
|
||||
|
||||
class FileEditActivityHook(AgentHook):
|
||||
"""Translate file-editing tool lifecycle events into WebUI progress events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_progress: Callable[..., Awaitable[None]] | None,
|
||||
workspace: Path | None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._on_progress = (
|
||||
on_progress
|
||||
if on_progress is not None and on_progress_accepts_file_edit_events(on_progress)
|
||||
else None
|
||||
)
|
||||
self._workspace = workspace
|
||||
self._trackers_by_call: dict[str, list[FileEditTracker]] = {}
|
||||
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
self._trackers_by_call.clear()
|
||||
|
||||
async def before_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
) -> None:
|
||||
if self._on_progress is None or not isinstance(params, dict):
|
||||
return
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool=tool,
|
||||
workspace=self._workspace,
|
||||
params=params,
|
||||
)
|
||||
if not trackers:
|
||||
return
|
||||
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
|
||||
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
|
||||
|
||||
async def after_execute_tool(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
result: Any,
|
||||
) -> None:
|
||||
key = self._tool_call_key(tool_call)
|
||||
trackers = self._trackers_by_call.get(key, [])
|
||||
if trackers:
|
||||
await self._emit([build_file_edit_end_event(tracker) for tracker in trackers])
|
||||
self._trackers_by_call.pop(key, None)
|
||||
|
||||
async def on_execute_tool_error(
|
||||
self,
|
||||
context: AgentHookContext,
|
||||
tool_call: ToolCallRequest,
|
||||
tool: Any,
|
||||
params: Any,
|
||||
error: Any,
|
||||
) -> None:
|
||||
key = self._tool_call_key(tool_call)
|
||||
trackers = self._trackers_by_call.get(key, [])
|
||||
if trackers:
|
||||
await self._emit([
|
||||
build_file_edit_error_event(tracker, str(error)) for tracker in trackers
|
||||
])
|
||||
self._trackers_by_call.pop(key, None)
|
||||
|
||||
async def on_finally(self, context: AgentRunHookContext) -> None:
|
||||
if context.stop_reason != "cancelled" or not self._trackers_by_call:
|
||||
return
|
||||
trackers = [
|
||||
tracker
|
||||
for trackers in self._trackers_by_call.values()
|
||||
for tracker in trackers
|
||||
]
|
||||
self._trackers_by_call.clear()
|
||||
await self._emit([
|
||||
build_file_edit_error_event(
|
||||
tracker,
|
||||
"Task interrupted before this tool finished.",
|
||||
)
|
||||
for tracker in trackers
|
||||
])
|
||||
|
||||
async def _emit(self, events: list[dict[str, Any]]) -> None:
|
||||
if self._on_progress is not None:
|
||||
await invoke_file_edit_progress(self._on_progress, events)
|
||||
|
||||
@staticmethod
|
||||
def _tool_call_key(tool_call: ToolCallRequest) -> str:
|
||||
call_id = getattr(tool_call, "id", "") or ""
|
||||
return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}"
|
||||
|
||||
|
||||
def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None:
|
||||
"""Create the default file-edit observer for one agent turn."""
|
||||
if context.on_progress is None:
|
||||
return None
|
||||
return FileEditActivityHook(
|
||||
on_progress=context.on_progress,
|
||||
workspace=context.workspace,
|
||||
)
|
||||
+535
-362
File diff suppressed because it is too large
Load Diff
@@ -1,415 +0,0 @@
|
||||
"""Durable mailbox primitives for manager-worker task coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||
|
||||
TaskState = str # running | completed | failed | cancelled
|
||||
MailboxReadState = str # ready | running | not_found | consumed | timeout
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskRequest:
|
||||
"""Task request recorded when the manager dispatches a worker."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
origin: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskResult:
|
||||
"""Worker result written to the manager mailbox."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
status: str
|
||||
content: str
|
||||
sender: str = "subagent"
|
||||
completed_at: float = field(default_factory=time.time)
|
||||
dedupe_key: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskSnapshot:
|
||||
"""Read-only view of a task in the mailbox."""
|
||||
|
||||
task_id: str
|
||||
session_key: str
|
||||
label: str
|
||||
task: str
|
||||
state: TaskState
|
||||
created_at: float
|
||||
completed_at: float | None = None
|
||||
consumed_at: float | None = None
|
||||
result_status: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MailboxRead:
|
||||
"""Result of a mailbox wait/consume operation."""
|
||||
|
||||
state: MailboxReadState
|
||||
task: TaskSnapshot | None = None
|
||||
result: TaskResult | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TaskRecord:
|
||||
request: TaskRequest
|
||||
state: TaskState = "running"
|
||||
result: TaskResult | None = None
|
||||
consumed_at: float | None = None
|
||||
completed_at: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MailboxStore:
|
||||
"""Durable task mailbox for local subagent coordination.
|
||||
|
||||
JSON files are the source of truth. The condition variable only wakes
|
||||
waiters inside this process; persisted records remain readable after a
|
||||
manager restart.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: str | Path, *, root: str | Path | None = None) -> None:
|
||||
base = Path(root).expanduser() if root is not None else Path(workspace) / "tasks" / "subagents"
|
||||
self.root = ensure_dir(base)
|
||||
self._changed = asyncio.Condition()
|
||||
|
||||
async def dispatch(self, request: TaskRequest) -> None:
|
||||
"""Record that a task was dispatched."""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(request.task_id, session_key=request.session_key)
|
||||
if record is not None:
|
||||
return
|
||||
path = self._record_path(request.session_key, request.task_id)
|
||||
self._write_record(path, _TaskRecord(request=request))
|
||||
self._changed.notify_all()
|
||||
|
||||
async def record_result(self, result: TaskResult) -> bool:
|
||||
"""Record a worker result.
|
||||
|
||||
Returns ``True`` when this call writes a new terminal result and
|
||||
``False`` when the task was already finalized.
|
||||
"""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(result.task_id, session_key=result.session_key)
|
||||
if record is None:
|
||||
request = TaskRequest(
|
||||
task_id=result.task_id,
|
||||
session_key=result.session_key,
|
||||
label=result.label,
|
||||
task=result.task,
|
||||
origin=dict(result.metadata),
|
||||
created_at=result.completed_at,
|
||||
)
|
||||
record = _TaskRecord(request=request)
|
||||
path = self._record_path(result.session_key, result.task_id)
|
||||
elif record.result is not None or record.state != "running":
|
||||
return False
|
||||
|
||||
record.result = result
|
||||
record.completed_at = result.completed_at
|
||||
record.state = self._state_for_result(result.status)
|
||||
record.error = result.content if result.status in {"error", "cancelled"} else None
|
||||
self._write_record(path, record)
|
||||
self._changed.notify_all()
|
||||
return True
|
||||
|
||||
async def mark_cancelled(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
reason: str = "Cancelled.",
|
||||
) -> bool:
|
||||
"""Mark a task cancelled and make the cancellation consumable once."""
|
||||
async with self._changed:
|
||||
path, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None or record.result is not None or record.state != "running":
|
||||
return False
|
||||
result = TaskResult(
|
||||
task_id=task_id,
|
||||
session_key=record.request.session_key,
|
||||
label=record.request.label,
|
||||
task=record.request.task,
|
||||
status="cancelled",
|
||||
content=reason,
|
||||
dedupe_key=task_id,
|
||||
)
|
||||
record.result = result
|
||||
record.completed_at = result.completed_at
|
||||
record.state = "cancelled"
|
||||
record.error = reason
|
||||
self._write_record(path, record)
|
||||
self._changed.notify_all()
|
||||
return True
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Return snapshots for one task or all tasks in a session."""
|
||||
async with self._changed:
|
||||
return self.snapshot_sync(session_key, task_id=task_id)
|
||||
|
||||
def snapshot_sync(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Synchronous snapshot used while building runtime context."""
|
||||
if task_id is not None:
|
||||
_, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None:
|
||||
return []
|
||||
return [self._snapshot(record)]
|
||||
|
||||
records = self._load_session_records(session_key)
|
||||
snapshots = [self._snapshot(record) for record in records]
|
||||
snapshots.sort(key=lambda item: (item.completed_at is None, item.created_at, item.task_id))
|
||||
return snapshots
|
||||
|
||||
async def wait_for_result(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> MailboxRead:
|
||||
"""Wait for and consume a result once."""
|
||||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||||
async with self._changed:
|
||||
while True:
|
||||
read = self._consume_ready_locked(session_key, task_id)
|
||||
if read.state != "running":
|
||||
return read
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return MailboxRead("timeout", task=read.task)
|
||||
try:
|
||||
await asyncio.wait_for(self._changed.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return MailboxRead("timeout", task=read.task)
|
||||
|
||||
def _consume_ready_locked(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None,
|
||||
) -> MailboxRead:
|
||||
if task_id is not None:
|
||||
path, record = self._load_by_task_id(task_id, session_key=session_key)
|
||||
if record is None:
|
||||
return MailboxRead("not_found")
|
||||
snapshot = self._snapshot(record)
|
||||
if record.result is None:
|
||||
return MailboxRead("running", task=snapshot)
|
||||
if record.consumed_at is not None:
|
||||
return MailboxRead("consumed", task=snapshot, result=record.result)
|
||||
record.consumed_at = time.time()
|
||||
self._write_record(path, record)
|
||||
return MailboxRead("ready", task=self._snapshot(record), result=record.result)
|
||||
|
||||
records_with_paths = self._load_session_records_with_paths(session_key)
|
||||
ready = [
|
||||
(path, record)
|
||||
for path, record in records_with_paths
|
||||
if record.result is not None and record.consumed_at is None
|
||||
]
|
||||
if ready:
|
||||
ready.sort(key=lambda item: (
|
||||
item[1].completed_at or item[1].request.created_at,
|
||||
item[1].request.task_id,
|
||||
))
|
||||
path, record = ready[0]
|
||||
record.consumed_at = time.time()
|
||||
self._write_record(path, record)
|
||||
return MailboxRead("ready", task=self._snapshot(record), result=record.result)
|
||||
|
||||
running = [record for _, record in records_with_paths if record.result is None]
|
||||
if running:
|
||||
running.sort(key=lambda record: (record.request.created_at, record.request.task_id))
|
||||
return MailboxRead("running", task=self._snapshot(running[0]))
|
||||
if records_with_paths:
|
||||
records = [record for _, record in records_with_paths]
|
||||
records.sort(key=lambda record: (
|
||||
record.completed_at or record.request.created_at,
|
||||
record.request.task_id,
|
||||
))
|
||||
return MailboxRead("consumed", task=self._snapshot(records[-1]))
|
||||
return MailboxRead("not_found")
|
||||
|
||||
def _session_dir(self, session_key: str) -> Path:
|
||||
return self.root / safe_filename(session_key)
|
||||
|
||||
def _record_path(self, session_key: str, task_id: str) -> Path:
|
||||
return ensure_dir(self._session_dir(session_key)) / f"{safe_filename(task_id)}.json"
|
||||
|
||||
def _load_by_task_id(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
session_key: str | None = None,
|
||||
) -> tuple[Path, _TaskRecord | None]:
|
||||
if session_key is not None:
|
||||
path = self._record_path(session_key, task_id)
|
||||
return path, self._read_record(path)
|
||||
|
||||
filename = f"{safe_filename(task_id)}.json"
|
||||
for path in self.root.glob(f"*/{filename}"):
|
||||
record = self._read_record(path)
|
||||
if record is not None:
|
||||
return path, record
|
||||
return self.root / "_missing" / filename, None
|
||||
|
||||
def _load_session_records(self, session_key: str) -> list[_TaskRecord]:
|
||||
return [record for _, record in self._load_session_records_with_paths(session_key)]
|
||||
|
||||
def _load_session_records_with_paths(self, session_key: str) -> list[tuple[Path, _TaskRecord]]:
|
||||
directory = self._session_dir(session_key)
|
||||
if not directory.exists():
|
||||
return []
|
||||
records: list[tuple[Path, _TaskRecord]] = []
|
||||
for path in directory.glob("*.json"):
|
||||
record = self._read_record(path)
|
||||
if record is not None:
|
||||
records.append((path, record))
|
||||
return records
|
||||
|
||||
def _read_record(self, path: Path) -> _TaskRecord | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return self._record_from_json(data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _write_record(self, path: Path, record: _TaskRecord) -> None:
|
||||
ensure_dir(path.parent)
|
||||
payload = json.dumps(self._record_to_json(record), ensure_ascii=False, indent=2)
|
||||
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
f.write("\n")
|
||||
with suppress(OSError):
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
with suppress(OSError):
|
||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _record_to_json(record: _TaskRecord) -> dict[str, Any]:
|
||||
result = record.result
|
||||
return {
|
||||
"version": 1,
|
||||
"task_id": record.request.task_id,
|
||||
"session_key": record.request.session_key,
|
||||
"label": record.request.label,
|
||||
"task": record.request.task,
|
||||
"origin": record.request.origin,
|
||||
"state": record.state,
|
||||
"result": None if result is None else {
|
||||
"task_id": result.task_id,
|
||||
"session_key": result.session_key,
|
||||
"label": result.label,
|
||||
"task": result.task,
|
||||
"status": result.status,
|
||||
"content": result.content,
|
||||
"sender": result.sender,
|
||||
"completed_at": result.completed_at,
|
||||
"dedupe_key": result.dedupe_key,
|
||||
"metadata": result.metadata,
|
||||
},
|
||||
"consumed_at": record.consumed_at,
|
||||
"created_at": record.request.created_at,
|
||||
"completed_at": record.completed_at,
|
||||
"updated_at": time.time(),
|
||||
"error": record.error,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _record_from_json(data: dict[str, Any]) -> _TaskRecord:
|
||||
request = TaskRequest(
|
||||
task_id=str(data["task_id"]),
|
||||
session_key=str(data["session_key"]),
|
||||
label=str(data.get("label") or data["task_id"]),
|
||||
task=str(data.get("task") or ""),
|
||||
origin=dict(data.get("origin") or {}),
|
||||
created_at=float(data.get("created_at") or time.time()),
|
||||
)
|
||||
raw_result = data.get("result")
|
||||
result = None
|
||||
if isinstance(raw_result, dict):
|
||||
result = TaskResult(
|
||||
task_id=str(raw_result.get("task_id") or request.task_id),
|
||||
session_key=str(raw_result.get("session_key") or request.session_key),
|
||||
label=str(raw_result.get("label") or request.label),
|
||||
task=str(raw_result.get("task") or request.task),
|
||||
status=str(raw_result.get("status") or "error"),
|
||||
content=str(raw_result.get("content") or ""),
|
||||
sender=str(raw_result.get("sender") or "subagent"),
|
||||
completed_at=float(raw_result.get("completed_at") or time.time()),
|
||||
dedupe_key=raw_result.get("dedupe_key"),
|
||||
metadata=dict(raw_result.get("metadata") or {}),
|
||||
)
|
||||
return _TaskRecord(
|
||||
request=request,
|
||||
state=str(data.get("state") or "running"),
|
||||
result=result,
|
||||
consumed_at=data.get("consumed_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_for_result(status: str) -> TaskState:
|
||||
if status == "ok":
|
||||
return "completed"
|
||||
if status == "cancelled":
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(record: _TaskRecord) -> TaskSnapshot:
|
||||
result = record.result
|
||||
return TaskSnapshot(
|
||||
task_id=record.request.task_id,
|
||||
session_key=record.request.session_key,
|
||||
label=record.request.label,
|
||||
task=record.request.task,
|
||||
state=record.state,
|
||||
created_at=record.request.created_at,
|
||||
completed_at=record.completed_at,
|
||||
consumed_at=record.consumed_at,
|
||||
result_status=result.status if result is not None else None,
|
||||
error=record.error,
|
||||
)
|
||||
+213
-88
@@ -15,23 +15,29 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
from nanobot.utils.helpers import (
|
||||
ensure_dir,
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
find_legal_message_start,
|
||||
recent_message_start_index,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
truncate_text_to_tokens,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.workspace_prompts import (
|
||||
WORKSPACE_PROMPT_MAX_CHARS,
|
||||
has_workspace_prompt_override,
|
||||
load_workspace_prompt_override,
|
||||
workspace_prompt_file,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryStore — pure file I/O layer
|
||||
@@ -41,6 +47,14 @@ class MemoryStore:
|
||||
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
|
||||
|
||||
_DEFAULT_MAX_HISTORY = 1000
|
||||
# Durable files whose real working-tree delta grounds Dream commit messages
|
||||
# and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so
|
||||
# that advancing the cursor itself is never mistaken for a productive edit.
|
||||
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
|
||||
# Per-file cap when embedding current contents into the Dream prompt. The
|
||||
# durable files are tiny in practice (~5 KB total), but a runaway file must
|
||||
# not unbounded the prompt.
|
||||
_DREAM_FILE_EMBED_CAP = 8000
|
||||
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
|
||||
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
|
||||
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
|
||||
@@ -60,9 +74,10 @@ class MemoryStore:
|
||||
self.user_file = workspace / "USER.md"
|
||||
self._cursor_file = self.memory_dir / ".cursor"
|
||||
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
|
||||
self._corruption_logged = False # rate-limit non-int cursor warning
|
||||
self._corruption_logged = False # rate-limit invalid cursor warning
|
||||
self._malformed_entry_logged = False # rate-limit bad history shape warning
|
||||
self._oversize_logged = False # rate-limit oversized-entry warning
|
||||
self._dream_prompt_oversize_logged = False
|
||||
self._append_lock = threading.Lock() # serialize cursor allocation + append
|
||||
self._git = GitStore(workspace, tracked_files=[
|
||||
"SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor",
|
||||
@@ -290,8 +305,8 @@ class MemoryStore:
|
||||
|
||||
@staticmethod
|
||||
def _valid_cursor(value: Any) -> int | None:
|
||||
"""Int cursors only — reject bool (``isinstance(True, int)`` is True)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
"""Non-negative int cursors only; reject bool (``isinstance(True, int)`` is True)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
@@ -314,7 +329,7 @@ class MemoryStore:
|
||||
if poisoned is not None and not self._corruption_logged:
|
||||
self._corruption_logged = True
|
||||
logger.warning(
|
||||
"history.jsonl contains a non-int cursor ({!r}); dropping it. "
|
||||
"history.jsonl contains an invalid cursor ({!r}); dropping it. "
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
poisoned,
|
||||
)
|
||||
@@ -335,18 +350,32 @@ class MemoryStore:
|
||||
session_key = entry.get("session_key")
|
||||
return session_key is None or isinstance(session_key, str)
|
||||
|
||||
def _read_cursor_counter(self) -> int | None:
|
||||
"""Return the persisted cursor counter when it is usable."""
|
||||
if not self._cursor_file.exists():
|
||||
return None
|
||||
with suppress(ValueError, OSError):
|
||||
cursor = int(self._cursor_file.read_text(encoding="utf-8").strip())
|
||||
if cursor >= 0:
|
||||
return cursor
|
||||
return None
|
||||
|
||||
def _next_cursor(self) -> int:
|
||||
"""Read the current cursor counter and return the next value."""
|
||||
if self._cursor_file.exists():
|
||||
with suppress(ValueError, OSError):
|
||||
return int(self._cursor_file.read_text(encoding="utf-8").strip()) + 1
|
||||
cursor_counter = self._read_cursor_counter()
|
||||
last = self._read_last_entry() or {}
|
||||
last_cursor = self._valid_cursor(last.get("cursor"))
|
||||
if cursor_counter is not None:
|
||||
if last_cursor is not None:
|
||||
return max(cursor_counter, last_cursor) + 1
|
||||
max_history_cursor = max((c for _, c in self._iter_valid_entries()), default=0)
|
||||
return max(cursor_counter, max_history_cursor) + 1
|
||||
|
||||
# Fast path: trust the tail when intact. Otherwise scan the whole
|
||||
# file and take ``max`` — that stays correct even if the monotonic
|
||||
# invariant was broken by external writes.
|
||||
last = self._read_last_entry() or {}
|
||||
cursor = self._valid_cursor(last.get("cursor"))
|
||||
if cursor is not None:
|
||||
return cursor + 1
|
||||
if last_cursor is not None:
|
||||
return last_cursor + 1
|
||||
return max((c for _, c in self._iter_valid_entries()), default=0) + 1
|
||||
|
||||
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
|
||||
@@ -464,13 +493,52 @@ class MemoryStore:
|
||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||
|
||||
def get_latest_cursor(self) -> int:
|
||||
return max(self._next_cursor() - 1, 0)
|
||||
|
||||
@property
|
||||
def dream_prompt_file(self) -> Path:
|
||||
return workspace_prompt_file(self.workspace, "dream")
|
||||
|
||||
def has_dream_prompt_override(self) -> bool:
|
||||
return has_workspace_prompt_override(self.dream_prompt_file)
|
||||
|
||||
@staticmethod
|
||||
def default_dream_prompt() -> str:
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
return render_template(
|
||||
"agent/dream.md",
|
||||
strip=True,
|
||||
skill_creator_path=str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"),
|
||||
)
|
||||
|
||||
def _dream_template(self) -> str:
|
||||
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
|
||||
if text is not None:
|
||||
if (
|
||||
original_chars > WORKSPACE_PROMPT_MAX_CHARS
|
||||
and not self._dream_prompt_oversize_logged
|
||||
):
|
||||
self._dream_prompt_oversize_logged = True
|
||||
logger.warning(
|
||||
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
||||
"Further occurrences suppressed.",
|
||||
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
||||
)
|
||||
return text
|
||||
return self.default_dream_prompt()
|
||||
|
||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||
"""Build the Dream prompt with unprocessed history context.
|
||||
|
||||
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
|
||||
"""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
The current contents of the durable memory files (SOUL.md, USER.md,
|
||||
memory/MEMORY.md) are embedded so the model edits the real files rather
|
||||
than a stale mental model — eliminating a class of failed/out-of-bounds
|
||||
edits that previously produced hallucinated audit records.
|
||||
"""
|
||||
last_cursor = self.get_last_dream_cursor()
|
||||
entries = self.read_unprocessed_history(since_cursor=last_cursor)
|
||||
if not entries:
|
||||
@@ -481,13 +549,47 @@ class MemoryStore:
|
||||
f"[{e['timestamp']}] {truncate_text(e['content'], 500)}"
|
||||
for e in batch
|
||||
)
|
||||
skill_creator_path = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
||||
template = render_template(
|
||||
"agent/dream.md", strip=True, skill_creator_path=skill_creator_path,
|
||||
template = self._dream_template()
|
||||
files_section = self._render_current_memory_files()
|
||||
prompt = (
|
||||
f"{template}\n\n{files_section}\n\n"
|
||||
f"## Conversation History\n{history_text}"
|
||||
)
|
||||
prompt = f"{template}\n\n## Conversation History\n{history_text}"
|
||||
return (prompt, batch[-1]["cursor"])
|
||||
|
||||
def _render_current_memory_files(self) -> str:
|
||||
"""Render the durable memory files' current contents for the Dream prompt.
|
||||
|
||||
Missing files render as ``(empty)``; oversized files are capped. The
|
||||
section is the ground truth the model must edit against.
|
||||
"""
|
||||
files = [
|
||||
("SOUL.md", self.soul_file),
|
||||
("USER.md", self.user_file),
|
||||
("memory/MEMORY.md", self.memory_file),
|
||||
]
|
||||
blocks = []
|
||||
for label, path in files:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
except OSError:
|
||||
content = ""
|
||||
if len(content) > self._DREAM_FILE_EMBED_CAP:
|
||||
content = truncate_text(content, self._DREAM_FILE_EMBED_CAP) + "\n...[truncated]"
|
||||
blocks.append(f"### {label}\n{content}" if content.strip() else f"### {label}\n(empty)")
|
||||
return "## Current Memory Files\n" + "\n\n".join(blocks)
|
||||
|
||||
def dream_content_diff(self) -> str:
|
||||
"""Structured summary of uncommitted changes to the durable memory files.
|
||||
|
||||
Returns "" when git is unavailable or no content file changed. This is
|
||||
the ground-truth input for diff-grounded Dream commit messages and for
|
||||
gating cursor advance on real edits (never on LLM self-report).
|
||||
"""
|
||||
if not self._git.is_initialized():
|
||||
return ""
|
||||
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
|
||||
|
||||
def build_dream_tools(self):
|
||||
"""Build the restricted tool registry used by Dream runs."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
@@ -503,24 +605,24 @@ class MemoryStore:
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
extra_read = [BUILTIN_SKILLS_DIR] if BUILTIN_SKILLS_DIR.exists() else None
|
||||
editable_roots = [self.soul_file, self.user_file, skills_dir]
|
||||
editable_files = [self.memory_file, self.soul_file, self.user_file]
|
||||
|
||||
tools.register(ReadFileTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=workspace,
|
||||
extra_allowed_dirs=extra_read,
|
||||
extra_read_allowed_dirs=extra_read,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(EditFileTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=self.memory_dir,
|
||||
extra_allowed_dirs=editable_roots,
|
||||
allowed_dir=skills_dir,
|
||||
extra_write_allowed_files=editable_files,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(ApplyPatchTool(
|
||||
workspace=workspace,
|
||||
allowed_dir=self.memory_dir,
|
||||
extra_allowed_dirs=editable_roots,
|
||||
allowed_dir=skills_dir,
|
||||
extra_write_allowed_files=editable_files,
|
||||
file_states=file_states,
|
||||
))
|
||||
tools.register(WriteFileTool(
|
||||
@@ -559,7 +661,10 @@ class MemoryStore:
|
||||
) -> None:
|
||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||
formatted = truncate_text(self._format_messages(messages), limit)
|
||||
formatted = truncate_text(
|
||||
self._format_messages(public_history_messages(messages)),
|
||||
limit,
|
||||
)
|
||||
self.append_history(
|
||||
f"[RAW] {len(messages)} messages\n"
|
||||
f"{formatted}",
|
||||
@@ -579,23 +684,36 @@ class MemoryStore:
|
||||
return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
|
||||
|
||||
@staticmethod
|
||||
def build_dream_commit_message(prefix: str, resp: object | None) -> str:
|
||||
"""Build a Dream auto-commit message, appending the LLM summary if present."""
|
||||
msg = prefix
|
||||
if resp is not None and getattr(resp, "content", None):
|
||||
msg = f"{msg}\n\n{resp.content.strip()}"
|
||||
return msg
|
||||
def build_dream_commit_message(prefix: str, diff_body: str) -> str:
|
||||
"""Build a Dream commit message grounded in the real working-tree diff.
|
||||
|
||||
*diff_body* is a structured, machine-derived summary of the actual file
|
||||
changes (see :meth:`dream_content_diff` /
|
||||
:meth:`GitStore.summarize_working_tree`). The LLM narrative is
|
||||
deliberately excluded so the audit record (``/dream-log``) reflects the
|
||||
filesystem's truth, not the model's self-report.
|
||||
|
||||
An empty *diff_body* yields the bare *prefix*, which ``auto_commit``
|
||||
turns into a no-op when there is nothing to stage.
|
||||
"""
|
||||
diff_body = (diff_body or "").strip()
|
||||
if not diff_body:
|
||||
return prefix
|
||||
return f"{prefix}\n\n{diff_body}"
|
||||
|
||||
@staticmethod
|
||||
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
||||
"""Remove the oldest Dream session files, keeping only the N most recent.
|
||||
|
||||
Only files matching ``dream_*.jsonl`` are considered. Non-dream session
|
||||
files are never touched.
|
||||
Only current base64url-encoded Dream session keys are considered.
|
||||
Non-dream session files are never touched.
|
||||
"""
|
||||
dream_files = sorted(
|
||||
sessions_dir.glob("dream_*.jsonl"), key=lambda p: p.stat().st_mtime,
|
||||
)
|
||||
dream_files = []
|
||||
for path in sessions_dir.glob("*.jsonl"):
|
||||
decoded_key = SessionManager._decode_storage_key(path.stem)
|
||||
if decoded_key is not None and decoded_key.startswith("dream:"):
|
||||
dream_files.append(path)
|
||||
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
||||
if len(dream_files) <= keep:
|
||||
return
|
||||
|
||||
@@ -630,22 +748,14 @@ class Consolidator:
|
||||
def __init__(
|
||||
self,
|
||||
store: MemoryStore,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
sessions: SessionManager,
|
||||
context_window_tokens: int,
|
||||
build_messages: Callable[..., list[dict[str, Any]]],
|
||||
get_tool_definitions: Callable[[], list[dict[str, Any]]],
|
||||
max_completion_tokens: int = 4096,
|
||||
consolidation_ratio: float = 0.5,
|
||||
unified_session: bool = False,
|
||||
):
|
||||
self.store = store
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.sessions = sessions
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.max_completion_tokens = max_completion_tokens
|
||||
self.consolidation_ratio = consolidation_ratio
|
||||
self.unified_session = unified_session
|
||||
self._build_messages = build_messages
|
||||
@@ -654,17 +764,6 @@ class Consolidator:
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
|
||||
def set_provider(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
context_window_tokens: int,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.context_window_tokens = context_window_tokens
|
||||
self.max_completion_tokens = provider.generation.max_tokens
|
||||
|
||||
def get_lock(self, session_key: str) -> asyncio.Lock:
|
||||
"""Return the shared consolidation lock for one session."""
|
||||
return self._locks.setdefault(session_key, asyncio.Lock())
|
||||
@@ -694,17 +793,12 @@ class Consolidator:
|
||||
@staticmethod
|
||||
def _full_unconsolidated_history(
|
||||
session: Session,
|
||||
*,
|
||||
include_timestamps: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||
if unconsolidated_count <= 0:
|
||||
return []
|
||||
return session.get_history(
|
||||
max_messages=unconsolidated_count,
|
||||
include_timestamps=include_timestamps,
|
||||
)
|
||||
return session.get_history(max_messages=unconsolidated_count)
|
||||
|
||||
@staticmethod
|
||||
def _replay_overflow_boundary(
|
||||
@@ -717,7 +811,13 @@ class Consolidator:
|
||||
if len(tail) <= replay_max_messages:
|
||||
return None
|
||||
|
||||
sliced = tail[-replay_max_messages:]
|
||||
tail_messages = [message for _idx, message in tail]
|
||||
start_idx = recent_message_start_index(
|
||||
tail_messages,
|
||||
replay_max_messages,
|
||||
extend_to_user=True,
|
||||
)
|
||||
sliced = tail[start_idx:]
|
||||
for i, (_idx, message) in enumerate(sliced):
|
||||
if message.get("role") == "user":
|
||||
start = i
|
||||
@@ -741,6 +841,8 @@ class Consolidator:
|
||||
self,
|
||||
session: Session,
|
||||
replay_max_messages: int | None,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
) -> str | None:
|
||||
"""Archive messages that would be hidden by the replay message window."""
|
||||
end_idx = self._replay_overflow_boundary(session, replay_max_messages)
|
||||
@@ -755,7 +857,11 @@ class Consolidator:
|
||||
len(chunk),
|
||||
replay_max_messages,
|
||||
)
|
||||
summary = await self.archive(chunk, session_key=session.key)
|
||||
summary = await self.archive(
|
||||
chunk,
|
||||
runtime=runtime,
|
||||
session_key=session.key,
|
||||
)
|
||||
session.last_consolidated = end_idx
|
||||
self.sessions.save(session)
|
||||
return summary
|
||||
@@ -771,9 +877,11 @@ class Consolidator:
|
||||
def estimate_session_prompt_tokens(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||
history = self._full_unconsolidated_history(session, include_timestamps=True)
|
||||
history = self._full_unconsolidated_history(session)
|
||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
||||
# Include archived summary in estimation so the budget accounts for it.
|
||||
meta = session.metadata.get("_last_summary")
|
||||
@@ -790,20 +898,23 @@ class Consolidator:
|
||||
unified_session=self.unified_session,
|
||||
)
|
||||
return estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
self.model,
|
||||
runtime.provider,
|
||||
runtime.model,
|
||||
probe_messages,
|
||||
self._get_tool_definitions(),
|
||||
)
|
||||
|
||||
@property
|
||||
def _input_token_budget(self) -> int:
|
||||
def _input_token_budget(self, runtime: LLMRuntime) -> int:
|
||||
"""Available input token budget for consolidation LLM."""
|
||||
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
|
||||
return (
|
||||
runtime.context_window_tokens
|
||||
- runtime.generation.max_tokens
|
||||
- self._SAFETY_BUFFER
|
||||
)
|
||||
|
||||
def _truncate_to_token_budget(self, text: str) -> str:
|
||||
def _truncate_to_token_budget(self, text: str, *, runtime: LLMRuntime) -> str:
|
||||
"""Truncate text so it fits within the consolidation LLM's token budget."""
|
||||
budget = self._input_token_budget
|
||||
budget = self._input_token_budget(runtime)
|
||||
if budget <= 0:
|
||||
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
|
||||
return truncate_text_to_tokens(text, budget)
|
||||
@@ -812,6 +923,7 @@ class Consolidator:
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
session_key: str | None = None,
|
||||
summary_messages: list[dict] | None = None,
|
||||
) -> str | None:
|
||||
@@ -826,12 +938,14 @@ class Consolidator:
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
messages_to_summarize = summary_messages if summary_messages is not None else messages
|
||||
messages_to_summarize = public_history_messages(
|
||||
summary_messages if summary_messages is not None else messages
|
||||
)
|
||||
try:
|
||||
formatted = MemoryStore._format_messages(messages_to_summarize)
|
||||
formatted = self._truncate_to_token_budget(formatted)
|
||||
response = await self.provider.chat_with_retry(
|
||||
model=self.model,
|
||||
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
|
||||
response = await runtime.provider.chat_with_retry(
|
||||
model=runtime.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
@@ -844,6 +958,9 @@ class Consolidator:
|
||||
],
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
temperature=runtime.generation.temperature,
|
||||
max_tokens=runtime.generation.max_tokens,
|
||||
reasoning_effort=runtime.generation.reasoning_effort,
|
||||
)
|
||||
if response.finish_reason == "error":
|
||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||
@@ -863,6 +980,7 @@ class Consolidator:
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
replay_max_messages: int | None = None,
|
||||
) -> None:
|
||||
"""Loop: archive old messages until prompt fits within safe budget.
|
||||
@@ -870,7 +988,7 @@ class Consolidator:
|
||||
The budget reserves space for completion tokens and a safety buffer
|
||||
so the LLM request never exceeds the context window.
|
||||
"""
|
||||
if self.context_window_tokens <= 0:
|
||||
if runtime.context_window_tokens <= 0:
|
||||
return
|
||||
|
||||
lock = self.get_lock(session.key)
|
||||
@@ -882,15 +1000,17 @@ class Consolidator:
|
||||
if not session.messages:
|
||||
return
|
||||
|
||||
budget = self._input_token_budget
|
||||
budget = self._input_token_budget(runtime)
|
||||
target = int(budget * self.consolidation_ratio)
|
||||
last_summary = await self._consolidate_replay_overflow(
|
||||
session,
|
||||
replay_max_messages,
|
||||
runtime=runtime,
|
||||
)
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
@@ -904,7 +1024,7 @@ class Consolidator:
|
||||
"Token consolidation idle {}: {}/{} via {}, msgs={}",
|
||||
session.key,
|
||||
estimated,
|
||||
self.context_window_tokens,
|
||||
runtime.context_window_tokens,
|
||||
source,
|
||||
unconsolidated_count,
|
||||
)
|
||||
@@ -935,11 +1055,15 @@ class Consolidator:
|
||||
round_num,
|
||||
session.key,
|
||||
estimated,
|
||||
self.context_window_tokens,
|
||||
runtime.context_window_tokens,
|
||||
source,
|
||||
len(chunk),
|
||||
)
|
||||
summary = await self.archive(chunk, session_key=session.key)
|
||||
summary = await self.archive(
|
||||
chunk,
|
||||
runtime=runtime,
|
||||
session_key=session.key,
|
||||
)
|
||||
# Advance the cursor either way: on success the chunk was
|
||||
# summarized; on failure archive() already raw-archived it as
|
||||
# a breadcrumb. Re-archiving the same chunk on the next call
|
||||
@@ -956,6 +1080,7 @@ class Consolidator:
|
||||
try:
|
||||
estimated, source = self.estimate_session_prompt_tokens(
|
||||
session,
|
||||
runtime=runtime,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Token estimation failed for {}", session.key)
|
||||
@@ -971,6 +1096,8 @@ class Consolidator:
|
||||
async def compact_idle_session(
|
||||
self,
|
||||
session_key: str,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
max_suffix: int = 8,
|
||||
) -> str | None:
|
||||
"""Hard-truncate an idle session under the consolidation lock.
|
||||
@@ -987,7 +1114,6 @@ class Consolidator:
|
||||
|
||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||
if not messages_to_summarize:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -999,12 +1125,11 @@ class Consolidator:
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
messages_to_keep = probe.messages
|
||||
messages_to_remove = dropped[already_consolidated:]
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
|
||||
if not messages_to_remove and not messages_to_keep:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -1015,6 +1140,7 @@ class Consolidator:
|
||||
# the messages that are no longer kept in the live session.
|
||||
summary = await self.archive(
|
||||
messages_to_remove,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
summary_messages=messages_to_summarize,
|
||||
)
|
||||
@@ -1027,7 +1153,6 @@ class Consolidator:
|
||||
|
||||
session.messages = messages_to_keep
|
||||
session.last_consolidated = 0
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
|
||||
if messages_to_remove:
|
||||
|
||||
@@ -34,12 +34,12 @@ def build_static_preset_snapshot(
|
||||
name: str,
|
||||
preset: ModelPresetConfig,
|
||||
) -> ProviderSnapshot:
|
||||
provider.generation = preset.to_generation_settings()
|
||||
return ProviderSnapshot(
|
||||
provider=provider,
|
||||
model=preset.model,
|
||||
context_window_tokens=preset.context_window_tokens,
|
||||
signature=("model_preset", name, preset.model_dump_json()),
|
||||
generation=preset.to_generation_settings(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Public resolution boundary for default and overridden LLM runtimes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import replace
|
||||
|
||||
from nanobot.agent import model_presets as preset_helpers
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
|
||||
from nanobot.utils.llm_runtime import LLMRuntime, runtime_from_provider_snapshot
|
||||
|
||||
|
||||
class ModelRuntimeResolver:
|
||||
"""Own model selection and resolve it to immutable execution values.
|
||||
|
||||
The resolver is deliberately independent of ``AgentLoop``. Command, SDK,
|
||||
and tool admission layers can depend on this public service without reading
|
||||
or mutating private loop state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_runtime: LLMRuntime,
|
||||
*,
|
||||
model_presets: Mapping[str, ModelPresetConfig] | None = None,
|
||||
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
) -> None:
|
||||
self._runtime = initial_runtime
|
||||
self._model_presets = dict(model_presets or {})
|
||||
self._provider_snapshot_loader = provider_snapshot_loader
|
||||
self._preset_snapshot_loader = preset_snapshot_loader
|
||||
self._tracks_provider_generation = initial_runtime.model_preset is None
|
||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||
initial_runtime.snapshot_signature
|
||||
)
|
||||
|
||||
@property
|
||||
def runtime(self) -> LLMRuntime:
|
||||
"""Return the current immutable default without refreshing configuration."""
|
||||
return self._runtime
|
||||
|
||||
@property
|
||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
|
||||
return self._model_presets
|
||||
|
||||
@property
|
||||
def model_preset(self) -> str | None:
|
||||
return self._runtime.model_preset
|
||||
|
||||
@property
|
||||
def provider_signature(self) -> tuple[object, ...] | None:
|
||||
return self._runtime.snapshot_signature
|
||||
|
||||
def current(self, *, refresh: bool = False) -> LLMRuntime:
|
||||
"""Return the selected runtime, optionally refreshing the default source."""
|
||||
if refresh:
|
||||
self.refresh()
|
||||
self._refresh_provider_generation()
|
||||
return self._runtime
|
||||
|
||||
def resolve_snapshot(
|
||||
self,
|
||||
snapshot: ProviderSnapshot,
|
||||
*,
|
||||
model_preset: str | None = None,
|
||||
) -> LLMRuntime:
|
||||
"""Resolve a factory snapshot without changing the selected default."""
|
||||
return runtime_from_provider_snapshot(snapshot, model_preset=model_preset)
|
||||
|
||||
def adopt_snapshot(
|
||||
self,
|
||||
snapshot: ProviderSnapshot,
|
||||
*,
|
||||
model_preset: str | None = None,
|
||||
) -> LLMRuntime:
|
||||
"""Select a snapshot as the default for future turns."""
|
||||
runtime = self.resolve_snapshot(snapshot, model_preset=model_preset)
|
||||
self._runtime = runtime
|
||||
self._tracks_provider_generation = model_preset is None
|
||||
self._default_selection_signature = preset_helpers.default_selection_signature(
|
||||
runtime.snapshot_signature
|
||||
)
|
||||
return runtime
|
||||
|
||||
def resolve_preset(self, name: str | None) -> LLMRuntime:
|
||||
"""Resolve a named preset without changing the selected default."""
|
||||
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
|
||||
snapshot = preset_helpers.build_runtime_preset_snapshot(
|
||||
name=normalized,
|
||||
presets=self._model_presets,
|
||||
provider=self._runtime.provider,
|
||||
loader=self._preset_snapshot_loader,
|
||||
)
|
||||
return self.resolve_snapshot(snapshot, model_preset=normalized)
|
||||
|
||||
def select_preset(self, name: str | None) -> LLMRuntime:
|
||||
"""Select a named preset as the default for future turns."""
|
||||
runtime = self.resolve_preset(name)
|
||||
self._runtime = runtime
|
||||
self._tracks_provider_generation = False
|
||||
return runtime
|
||||
|
||||
def select_model(self, model: str) -> LLMRuntime:
|
||||
"""Change the default model without reconstructing downstream consumers."""
|
||||
if not isinstance(model, str) or not model.strip():
|
||||
raise ValueError("model must be a non-empty string")
|
||||
self._runtime = replace(
|
||||
self._runtime,
|
||||
model=model.strip(),
|
||||
model_preset=None,
|
||||
)
|
||||
return self._runtime
|
||||
|
||||
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
|
||||
"""Change the default context limit for future admissions."""
|
||||
if not isinstance(context_window_tokens, int) or isinstance(
|
||||
context_window_tokens,
|
||||
bool,
|
||||
):
|
||||
raise TypeError("context_window_tokens must be an integer")
|
||||
self._runtime = replace(
|
||||
self._runtime,
|
||||
context_window_tokens=context_window_tokens,
|
||||
)
|
||||
return self._runtime
|
||||
|
||||
def _refresh_provider_generation(self) -> LLMRuntime | None:
|
||||
"""Adopt direct provider-default changes only for provider-backed defaults."""
|
||||
if not self._tracks_provider_generation:
|
||||
return None
|
||||
runtime = self._runtime
|
||||
captured = LLMRuntime.capture(
|
||||
runtime.provider,
|
||||
runtime.model,
|
||||
context_window_tokens=runtime.context_window_tokens,
|
||||
model_preset=runtime.model_preset,
|
||||
snapshot_signature=runtime.snapshot_signature,
|
||||
)
|
||||
if captured.generation == runtime.generation:
|
||||
return None
|
||||
self._runtime = replace(runtime, generation=captured.generation)
|
||||
return self._runtime
|
||||
|
||||
def refresh(self) -> LLMRuntime | None:
|
||||
"""Refresh configured defaults and return the replacement when changed."""
|
||||
if self._provider_snapshot_loader is None:
|
||||
return None
|
||||
|
||||
snapshot = self._provider_snapshot_loader()
|
||||
default_selection = preset_helpers.default_selection_signature(snapshot.signature)
|
||||
active_preset = self._runtime.model_preset
|
||||
if active_preset and self._default_selection_signature in (None, default_selection):
|
||||
runtime = self.resolve_preset(active_preset)
|
||||
else:
|
||||
active_preset = None
|
||||
runtime = self.resolve_snapshot(snapshot)
|
||||
|
||||
unchanged = (
|
||||
runtime.snapshot_signature == self._runtime.snapshot_signature
|
||||
and runtime.model_preset == self._runtime.model_preset
|
||||
)
|
||||
if unchanged:
|
||||
self._default_selection_signature = default_selection
|
||||
return None
|
||||
(
|
||||
self._runtime,
|
||||
self._tracks_provider_generation,
|
||||
self._default_selection_signature,
|
||||
) = (
|
||||
runtime,
|
||||
active_preset is None,
|
||||
default_selection,
|
||||
)
|
||||
return runtime
|
||||
|
||||
def resolve_override(
|
||||
self,
|
||||
*,
|
||||
model: str | None,
|
||||
model_preset: str | None,
|
||||
config: Config | None = None,
|
||||
) -> LLMRuntime | None:
|
||||
"""Resolve an SDK-style per-run override without mutating the default."""
|
||||
if model is not None and model_preset is not None:
|
||||
raise ValueError("model and model_preset are mutually exclusive")
|
||||
if model_preset is not None:
|
||||
return self.resolve_preset(model_preset)
|
||||
if model is None:
|
||||
return None
|
||||
if config is None:
|
||||
return LLMRuntime(
|
||||
provider=self._runtime.provider,
|
||||
model=model,
|
||||
generation=self._runtime.generation,
|
||||
context_window_tokens=self._runtime.context_window_tokens,
|
||||
snapshot_signature=("model_override", model),
|
||||
)
|
||||
|
||||
base = config.resolve_preset(self.model_preset)
|
||||
preset = base.model_copy(update={"model": model, "provider": "auto"})
|
||||
return self.resolve_snapshot(build_provider_snapshot(config, preset=preset))
|
||||
@@ -28,26 +28,16 @@ class AgentProgressHook(AgentHook):
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
*,
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
message_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
tool_hint_max_length: int = 40,
|
||||
set_tool_context: Callable[..., None] | None = None,
|
||||
on_iteration: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__(reraise=True)
|
||||
self._on_progress = on_progress
|
||||
self._on_stream = on_stream
|
||||
self._on_stream_end = on_stream_end
|
||||
self._channel = channel
|
||||
self._chat_id = chat_id
|
||||
self._message_id = message_id
|
||||
self._metadata = metadata or {}
|
||||
self._session_key = session_key
|
||||
self._tool_hint_max_length = tool_hint_max_length
|
||||
self._set_tool_context = set_tool_context
|
||||
self._on_iteration = on_iteration
|
||||
self._stream_buf = ""
|
||||
self._think_extractor = IncrementalThinkExtractor()
|
||||
@@ -124,15 +114,6 @@ class AgentProgressHook(AgentHook):
|
||||
for tc in context.tool_calls:
|
||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||
if self._set_tool_context:
|
||||
self._set_tool_context(
|
||||
self._channel,
|
||||
self._chat_id,
|
||||
self._message_id,
|
||||
self._metadata,
|
||||
session_key=self._session_key,
|
||||
)
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
||||
if (
|
||||
|
||||
+208
-381
@@ -13,34 +13,24 @@ from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.context_governance import (
|
||||
ContextGovernanceConfig,
|
||||
ContextGovernor,
|
||||
)
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.utils.file_edit_events import (
|
||||
StreamingFileEditTracker,
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_error_event,
|
||||
build_file_edit_start_event,
|
||||
prepare_file_edit_trackers,
|
||||
)
|
||||
from nanobot.utils.file_edit_events import (
|
||||
prepare_file_edit_tracker as _prepare_file_edit_tracker,
|
||||
)
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.utils.helpers import (
|
||||
IncrementalThinkExtractor,
|
||||
build_assistant_message,
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
extract_reasoning,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
strip_reasoning_tags,
|
||||
strip_think,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.utils.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
@@ -48,7 +38,6 @@ from nanobot.utils.runtime import (
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
ensure_nonempty_tool_result,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
@@ -66,21 +55,6 @@ _MAX_EMPTY_RETRIES = 2
|
||||
_MAX_LENGTH_RECOVERIES = 3
|
||||
_MAX_INJECTIONS_PER_TURN = 3
|
||||
_MAX_INJECTION_CYCLES = 5
|
||||
_SNIP_SAFETY_BUFFER = 1024
|
||||
_MICROCOMPACT_KEEP_RECENT = 10
|
||||
_MICROCOMPACT_MIN_CHARS = 500
|
||||
_COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
_TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
|
||||
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentRunSpec:
|
||||
@@ -88,12 +62,9 @@ class AgentRunSpec:
|
||||
|
||||
initial_messages: list[dict[str, Any]]
|
||||
tools: ToolRegistry
|
||||
model: str
|
||||
runtime: LLMRuntime
|
||||
max_iterations: int
|
||||
max_tool_result_chars: int
|
||||
temperature: float | None = None
|
||||
max_tokens: int | None = None
|
||||
reasoning_effort: str | None = None
|
||||
hook: AgentHook | None = None
|
||||
error_message: str | None = _DEFAULT_ERROR_MESSAGE
|
||||
max_iterations_message: str | None = None
|
||||
@@ -101,7 +72,6 @@ class AgentRunSpec:
|
||||
fail_on_tool_error: bool = False
|
||||
workspace: Path | None = None
|
||||
session_key: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
context_block_limit: int | None = None
|
||||
provider_retry_mode: str = "standard"
|
||||
progress_callback: Any | None = None
|
||||
@@ -132,8 +102,8 @@ class AgentRunResult:
|
||||
class AgentRunner:
|
||||
"""Run a tool-capable LLM loop without product-layer concerns."""
|
||||
|
||||
def __init__(self, provider: LLMProvider):
|
||||
self.provider = provider
|
||||
def __init__(self) -> None:
|
||||
self.context_governor = ContextGovernor()
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
@@ -164,6 +134,8 @@ class AgentRunner:
|
||||
messages
|
||||
and injection.get("role") == "user"
|
||||
and messages[-1].get("role") == "user"
|
||||
and not is_hidden_history_message(injection)
|
||||
and not is_hidden_history_message(messages[-1])
|
||||
):
|
||||
merged = dict(messages[-1])
|
||||
merged["content"] = cls._merge_message_content(
|
||||
@@ -213,7 +185,7 @@ class AgentRunner:
|
||||
{
|
||||
"phase": "final_response",
|
||||
"iteration": iteration,
|
||||
"model": spec.model,
|
||||
"model": spec.runtime.model,
|
||||
"assistant_message": assistant_message,
|
||||
"completed_tool_results": [],
|
||||
"pending_tool_calls": [],
|
||||
@@ -366,6 +338,19 @@ class AgentRunner:
|
||||
length_recovery_count = 0
|
||||
had_injections = False
|
||||
injection_cycles = 0
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
governance_config = ContextGovernanceConfig(
|
||||
provider=spec.runtime.provider,
|
||||
model=spec.runtime.model,
|
||||
tools=spec.tools,
|
||||
workspace=spec.workspace,
|
||||
session_key=spec.session_key,
|
||||
max_tool_result_chars=spec.max_tool_result_chars,
|
||||
context_window_tokens=spec.runtime.context_window_tokens,
|
||||
context_block_limit=spec.context_block_limit,
|
||||
max_tokens=spec.runtime.generation.max_tokens,
|
||||
inflight_start_index=len(spec.initial_messages),
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
try:
|
||||
@@ -373,14 +358,11 @@ class AgentRunner:
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self._microcompact(messages_for_model)
|
||||
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
||||
messages_for_model = self._snip_history(spec, messages_for_model)
|
||||
# Snipping may have created new orphans; clean them up.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||
@@ -388,8 +370,18 @@ class AgentRunner:
|
||||
spec.session_key or "default",
|
||||
)
|
||||
try:
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
||||
messages
|
||||
)
|
||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
context = AgentHookContext(
|
||||
@@ -433,7 +425,7 @@ class AgentRunner:
|
||||
{
|
||||
"phase": "awaiting_tools",
|
||||
"iteration": iteration,
|
||||
"model": spec.model,
|
||||
"model": spec.runtime.model,
|
||||
"assistant_message": assistant_message,
|
||||
"completed_tool_results": [],
|
||||
"pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||
@@ -447,6 +439,8 @@ class AgentRunner:
|
||||
response.tool_calls,
|
||||
external_lookup_counts,
|
||||
workspace_violation_counts,
|
||||
hook,
|
||||
context,
|
||||
)
|
||||
tool_events.extend(new_events)
|
||||
tools_used.extend(
|
||||
@@ -462,8 +456,8 @@ class AgentRunner:
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"content": self._normalize_tool_result(
|
||||
spec,
|
||||
"content": self.context_governor.normalize_tool_result(
|
||||
governance_config,
|
||||
tool_call.id,
|
||||
tool_call.name,
|
||||
result,
|
||||
@@ -493,7 +487,7 @@ class AgentRunner:
|
||||
{
|
||||
"phase": "tools_completed",
|
||||
"iteration": iteration,
|
||||
"model": spec.model,
|
||||
"model": spec.runtime.model,
|
||||
"assistant_message": assistant_message,
|
||||
"completed_tool_results": completed_tool_results,
|
||||
"pending_tool_calls": [],
|
||||
@@ -647,7 +641,7 @@ class AgentRunner:
|
||||
{
|
||||
"phase": "final_response",
|
||||
"iteration": iteration,
|
||||
"model": spec.model,
|
||||
"model": spec.runtime.model,
|
||||
"assistant_message": messages[-1],
|
||||
"completed_tool_results": [],
|
||||
"pending_tool_calls": [],
|
||||
@@ -704,16 +698,14 @@ class AgentRunner:
|
||||
kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"model": spec.model,
|
||||
"model": spec.runtime.model,
|
||||
"retry_mode": spec.provider_retry_mode,
|
||||
"on_retry_wait": spec.retry_wait_callback,
|
||||
}
|
||||
if spec.temperature is not None:
|
||||
kwargs["temperature"] = spec.temperature
|
||||
if spec.max_tokens is not None:
|
||||
kwargs["max_tokens"] = spec.max_tokens
|
||||
if spec.reasoning_effort is not None:
|
||||
kwargs["reasoning_effort"] = spec.reasoning_effort
|
||||
generation = spec.runtime.generation
|
||||
kwargs["temperature"] = generation.temperature
|
||||
kwargs["max_tokens"] = generation.max_tokens
|
||||
kwargs["reasoning_effort"] = generation.reasoning_effort
|
||||
return kwargs
|
||||
|
||||
async def _request_model(
|
||||
@@ -722,6 +714,8 @@ class AgentRunner:
|
||||
messages: list[dict[str, Any]],
|
||||
hook: AgentHook,
|
||||
context: AgentHookContext,
|
||||
*,
|
||||
malformed_retry: bool = False,
|
||||
):
|
||||
timeout_s: float | None = spec.llm_timeout_s
|
||||
if timeout_s is None:
|
||||
@@ -746,49 +740,38 @@ class AgentRunner:
|
||||
not wants_streaming
|
||||
and spec.stream_progress_deltas
|
||||
and spec.progress_callback is not None
|
||||
and getattr(self.provider, "supports_progress_deltas", False) is True
|
||||
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
||||
)
|
||||
|
||||
progress_state: dict[str, bool] | None = None
|
||||
live_file_edits: StreamingFileEditTracker | None = None
|
||||
|
||||
if (
|
||||
spec.progress_callback is not None
|
||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||
):
|
||||
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
|
||||
await invoke_file_edit_progress(spec.progress_callback, events)
|
||||
|
||||
live_file_edits = StreamingFileEditTracker(
|
||||
workspace=spec.workspace,
|
||||
tools=spec.tools,
|
||||
emit=_emit_live_file_edits,
|
||||
)
|
||||
|
||||
async def _tool_call_delta(delta: dict[str, Any]) -> None:
|
||||
if live_file_edits is not None:
|
||||
await live_file_edits.update(delta)
|
||||
|
||||
if wants_streaming:
|
||||
thinking_buf = ""
|
||||
|
||||
async def _stream(delta: str) -> None:
|
||||
if delta:
|
||||
context.streamed_content = True
|
||||
await hook.on_stream(context, delta)
|
||||
|
||||
async def _thinking(delta: str) -> None:
|
||||
nonlocal thinking_buf
|
||||
if not delta:
|
||||
return
|
||||
context.streamed_reasoning = True
|
||||
await hook.emit_reasoning(delta)
|
||||
prev_clean = strip_reasoning_tags(thinking_buf)
|
||||
thinking_buf += delta
|
||||
new_clean = strip_reasoning_tags(thinking_buf)
|
||||
incremental = new_clean[len(prev_clean):]
|
||||
if incremental:
|
||||
context.streamed_reasoning = True
|
||||
await hook.emit_reasoning(incremental)
|
||||
|
||||
async def _stream_recover() -> None:
|
||||
await hook.on_stream_end(context, resuming=True)
|
||||
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream,
|
||||
on_thinking_delta=_thinking,
|
||||
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||
on_stream_recover=_stream_recover,
|
||||
)
|
||||
elif wants_progress_streaming:
|
||||
@@ -816,48 +799,132 @@ class AgentRunner:
|
||||
context.streamed_content = True
|
||||
await spec.progress_callback(incremental)
|
||||
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream_progress,
|
||||
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||
)
|
||||
else:
|
||||
coro = self.provider.chat_with_retry(**kwargs)
|
||||
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||
|
||||
# Streaming requests already have provider-level idle timeouts
|
||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
||||
# LLM timeout here, or healthy long reasoning streams can be killed just
|
||||
# because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S.
|
||||
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
|
||||
# Streaming requests also have provider-level idle timeouts
|
||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
||||
# very slow deltas can still run forever. Use a more generous wall-clock
|
||||
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
|
||||
# opt-out for all LLM wall-clock timeouts.
|
||||
is_streaming_request = wants_streaming or wants_progress_streaming
|
||||
outer_timeout_s = (
|
||||
max(300.0, timeout_s * 2)
|
||||
if is_streaming_request and timeout_s is not None
|
||||
else timeout_s
|
||||
)
|
||||
try:
|
||||
response = (
|
||||
await coro if outer_timeout_s is None
|
||||
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
|
||||
)
|
||||
if live_file_edits is not None:
|
||||
await live_file_edits.flush()
|
||||
if response.should_execute_tools:
|
||||
live_file_edits.apply_final_call_ids(response.tool_calls)
|
||||
await live_file_edits.error_unmatched(
|
||||
response.tool_calls if response.should_execute_tools else [],
|
||||
"Tool call did not complete.",
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
if outer_timeout_s is None:
|
||||
return LLMResponse(
|
||||
response = LLMResponse(
|
||||
content="Error calling LLM: stream stalled",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
else:
|
||||
response = LLMResponse(
|
||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||
finish_reason="error",
|
||||
error_kind="timeout",
|
||||
)
|
||||
if progress_state and progress_state.get("reasoning_open"):
|
||||
await hook.emit_reasoning_end()
|
||||
dropped, all_dropped, original_finish_reason = (
|
||||
self._drop_malformed_tool_calls(response)
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and not malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Retrying LLM request after all {} malformed tool call(s) were dropped",
|
||||
dropped,
|
||||
)
|
||||
retry_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_model(
|
||||
spec, retry_messages, hook, context,
|
||||
malformed_retry=True,
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Malformed tool calls persisted after retry; falling back to no-tools request",
|
||||
)
|
||||
fallback_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_no_tools(spec, fallback_messages)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _drop_malformed_tool_calls(
|
||||
response: LLMResponse,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Strip tool calls whose name is missing/non-string from the response.
|
||||
|
||||
Returns (dropped_count, all_dropped, original_finish_reason).
|
||||
|
||||
A degenerate call (name=None or "") cannot be executed, and if it were
|
||||
persisted into the assistant message it would be replayed on every
|
||||
subsequent turn, causing upstream validation errors
|
||||
(``tool_use.name: Input should be a valid string``) that permanently
|
||||
wedge the session. Dropping it here keeps it out of execution, the
|
||||
assistant message, and the saved history in one place.
|
||||
"""
|
||||
calls = getattr(response, "tool_calls", None)
|
||||
if not calls:
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
valid = [tc for tc in calls if tc.has_valid_name()]
|
||||
if len(valid) == len(calls):
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
dropped = len(calls) - len(valid)
|
||||
original_finish_reason = getattr(response, "finish_reason", None)
|
||||
logger.warning(
|
||||
"Dropped {} malformed tool call(s) with missing/non-string name "
|
||||
"from LLM response (finish_reason={!r})",
|
||||
dropped,
|
||||
original_finish_reason,
|
||||
)
|
||||
response.tool_calls = valid
|
||||
if not valid:
|
||||
response.finish_reason = "stop"
|
||||
return (dropped, not valid, original_finish_reason)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_tool_call_retry_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
assistant_text: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
retry_messages = list(messages)
|
||||
note = (
|
||||
"The previous model response attempted to call tools, but every tool call "
|
||||
"was malformed: the tool_use blocks had missing or non-string tool names. "
|
||||
"Do not answer with a promise to use tools. Either call the required tools again "
|
||||
"using valid tool names from the provided tool list and JSON object inputs, or give "
|
||||
"a final answer only if no tool is required."
|
||||
)
|
||||
if assistant_text:
|
||||
note += (
|
||||
f"\n\nPrevious assistant text before the malformed calls:\n"
|
||||
f"{assistant_text}"
|
||||
)
|
||||
retry_messages.append({"role": "user", "content": note})
|
||||
return retry_messages
|
||||
|
||||
async def _request_finalization_retry(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
@@ -919,7 +986,7 @@ class AgentRunner:
|
||||
messages: list[dict[str, Any]],
|
||||
) -> LLMResponse:
|
||||
kwargs = self._build_request_kwargs(spec, messages, tools=None)
|
||||
return await self.provider.chat_with_retry(**kwargs)
|
||||
return await spec.runtime.provider.chat_with_retry(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _budget_exhausted_finalization_messages(
|
||||
@@ -967,7 +1034,12 @@ class AgentRunner:
|
||||
tools = spec.tools.get_definitions()
|
||||
except Exception:
|
||||
tools = None
|
||||
prompt_tokens, _ = estimate_prompt_tokens_chain(self.provider, spec.model, messages, tools)
|
||||
prompt_tokens, _ = estimate_prompt_tokens_chain(
|
||||
spec.runtime.provider,
|
||||
spec.runtime.model,
|
||||
messages,
|
||||
tools,
|
||||
)
|
||||
assistant_message = build_assistant_message(
|
||||
response.content or "",
|
||||
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
|
||||
@@ -1021,14 +1093,23 @@ class AgentRunner:
|
||||
tool_calls: list[ToolCallRequest],
|
||||
external_lookup_counts: dict[str, int],
|
||||
workspace_violation_counts: dict[str, int],
|
||||
hook: AgentHook | None = None,
|
||||
context: AgentHookContext | None = None,
|
||||
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
|
||||
hook = hook or AgentHook()
|
||||
context = context or AgentHookContext(iteration=0, messages=[])
|
||||
batches = self._partition_tool_batches(spec, tool_calls)
|
||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||
for batch in batches:
|
||||
if spec.concurrent_tools and len(batch) > 1:
|
||||
batch_results = await asyncio.gather(*(
|
||||
self._run_tool(
|
||||
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||
spec,
|
||||
tool_call,
|
||||
external_lookup_counts,
|
||||
workspace_violation_counts,
|
||||
hook,
|
||||
context,
|
||||
)
|
||||
for tool_call in batch
|
||||
))
|
||||
@@ -1037,7 +1118,12 @@ class AgentRunner:
|
||||
batch_results = []
|
||||
for tool_call in batch:
|
||||
result = await self._run_tool(
|
||||
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||
spec,
|
||||
tool_call,
|
||||
external_lookup_counts,
|
||||
workspace_violation_counts,
|
||||
hook,
|
||||
context,
|
||||
)
|
||||
tool_results.append(result)
|
||||
batch_results.append(result)
|
||||
@@ -1058,7 +1144,11 @@ class AgentRunner:
|
||||
tool_call: ToolCallRequest,
|
||||
external_lookup_counts: dict[str, int],
|
||||
workspace_violation_counts: dict[str, int],
|
||||
hook: AgentHook | None = None,
|
||||
context: AgentHookContext | None = None,
|
||||
) -> tuple[Any, dict[str, str], BaseException | None]:
|
||||
hook = hook or AgentHook()
|
||||
context = context or AgentHookContext(iteration=0, messages=[])
|
||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
lookup_error = repeated_external_lookup_error(
|
||||
tool_call.name,
|
||||
@@ -1099,30 +1189,7 @@ class AgentRunner:
|
||||
return prep_error + hint, event, (
|
||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
)
|
||||
emit_file_edit_events = (
|
||||
spec.progress_callback is not None
|
||||
and on_progress_accepts_file_edit_events(spec.progress_callback)
|
||||
)
|
||||
progress_callback = spec.progress_callback if emit_file_edit_events else None
|
||||
file_edit_trackers = (
|
||||
prepare_file_edit_trackers(
|
||||
call_id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool=tool,
|
||||
workspace=spec.workspace,
|
||||
params=params if isinstance(params, dict) else None,
|
||||
)
|
||||
if progress_callback is not None
|
||||
else None
|
||||
)
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_start_event(
|
||||
file_edit_tracker,
|
||||
params if isinstance(params, dict) else None,
|
||||
) for file_edit_tracker in file_edit_trackers],
|
||||
)
|
||||
await hook.before_execute_tool(context, tool_call, tool, params)
|
||||
try:
|
||||
if tool is not None:
|
||||
result = await tool.execute(**params)
|
||||
@@ -1131,14 +1198,7 @@ class AgentRunner:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[
|
||||
build_file_edit_error_event(file_edit_tracker, str(exc))
|
||||
for file_edit_tracker in file_edit_trackers
|
||||
],
|
||||
)
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -1159,15 +1219,8 @@ class AgentRunner:
|
||||
return payload, event, exc
|
||||
return payload, event, None
|
||||
|
||||
if isinstance(result, str) and result.startswith("Error"):
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[
|
||||
build_file_edit_error_event(file_edit_tracker, result)
|
||||
for file_edit_tracker in file_edit_trackers
|
||||
],
|
||||
)
|
||||
if is_tool_error_result(tool_call.name, result):
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -1186,14 +1239,7 @@ class AgentRunner:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[build_file_edit_end_event(
|
||||
file_edit_tracker,
|
||||
params if isinstance(params, dict) else None,
|
||||
) for file_edit_tracker in file_edit_trackers],
|
||||
)
|
||||
await hook.after_execute_tool(context, tool_call, tool, params, result)
|
||||
|
||||
detail = "" if result is None else str(result)
|
||||
detail = detail.replace("\n", " ").strip()
|
||||
@@ -1325,225 +1371,6 @@ class AgentRunner:
|
||||
return
|
||||
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
|
||||
|
||||
def _normalize_tool_result(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in _TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
# Exempt tools bound their own output; skip generic offload and truncation.
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
spec.workspace,
|
||||
spec.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=spec.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
spec.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
|
||||
return truncate_text(content, spec.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for orphaned tool_use blocks."""
|
||||
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": _BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace old compactable tool results with one-line summaries."""
|
||||
compactable_indices: list[int] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
|
||||
compactable_indices.append(idx)
|
||||
|
||||
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
|
||||
return messages
|
||||
|
||||
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx in stale:
|
||||
msg = messages[idx]
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
name = msg.get("name", "tool")
|
||||
summary = f"[{name} result omitted from context]"
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
|
||||
return updated if updated is not None else messages
|
||||
|
||||
def _apply_tool_result_budget(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self._normalize_tool_result(
|
||||
spec,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def _snip_history(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not spec.context_window_tokens:
|
||||
return messages
|
||||
|
||||
provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
||||
max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = spec.context_block_limit or (
|
||||
spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
|
||||
)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
system_messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
if kept:
|
||||
for i, message in enumerate(kept):
|
||||
if message.get("role") == "user":
|
||||
kept = kept[i:]
|
||||
break
|
||||
else:
|
||||
# Recover nearest user message from outside the kept window;
|
||||
# GLM rejects system→assistant (error 1214). Budget is
|
||||
# intentionally exceeded — oversized beats invalid.
|
||||
for idx in range(len(non_system) - 1, -1, -1):
|
||||
if non_system[idx].get("role") == "user":
|
||||
kept = non_system[idx:]
|
||||
break
|
||||
# If no user exists at all, _enforce_role_alternation
|
||||
# will insert a synthetic one as a safety net.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
if not kept:
|
||||
kept = non_system[-min(len(non_system), 4) :]
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
return system_messages + kept
|
||||
|
||||
def _partition_tool_batches(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
+129
-162
@@ -4,20 +4,26 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.mailbox import MailboxRead, MailboxStore, TaskRequest, TaskResult, TaskSnapshot
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.context import (
|
||||
RequestContext,
|
||||
ToolContext,
|
||||
bind_request_context,
|
||||
reset_request_context,
|
||||
)
|
||||
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
@@ -27,6 +33,7 @@ from nanobot.security.workspace_access import (
|
||||
reset_workspace_scope,
|
||||
workspace_sandbox_status,
|
||||
)
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@@ -77,25 +84,46 @@ class SubagentManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
workspace: Path,
|
||||
bus: MessageBus,
|
||||
max_tool_result_chars: int,
|
||||
provider: LLMProvider | None = None,
|
||||
workspace: Path | None = None,
|
||||
bus: MessageBus | None = None,
|
||||
max_tool_result_chars: int | None = None,
|
||||
model: str | None = None,
|
||||
tools_config: ToolsConfig | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
max_iterations: int | None = None,
|
||||
max_concurrent_subagents: int | None = None,
|
||||
fail_on_tool_error: bool | None = None,
|
||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||
mailbox: MailboxStore | None = None,
|
||||
on_result_ready: Callable[[TaskResult], Awaitable[None]] | None = None,
|
||||
):
|
||||
if workspace is None:
|
||||
raise TypeError("SubagentManager.__init__() missing required argument: 'workspace'")
|
||||
if bus is None:
|
||||
raise TypeError("SubagentManager.__init__() missing required argument: 'bus'")
|
||||
if max_tool_result_chars is None:
|
||||
raise TypeError(
|
||||
"SubagentManager.__init__() missing required argument: 'max_tool_result_chars'"
|
||||
)
|
||||
if model is not None and provider is None:
|
||||
raise TypeError("SubagentManager model compatibility argument requires provider")
|
||||
|
||||
defaults = AgentDefaults()
|
||||
self.provider = provider
|
||||
self._compat_runtime: LLMRuntime | None = None
|
||||
if provider is not None:
|
||||
warnings.warn(
|
||||
"SubagentManager provider/model constructor arguments are deprecated; "
|
||||
"pass runtime=... to spawn() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._compat_runtime = LLMRuntime.capture(
|
||||
provider,
|
||||
model or provider.get_default_model(),
|
||||
context_window_tokens=defaults.context_window_tokens,
|
||||
)
|
||||
self.workspace = workspace
|
||||
self.bus = bus
|
||||
self.model = model or provider.get_default_model()
|
||||
self.tools_config = tools_config or ToolsConfig()
|
||||
self.max_tool_result_chars = max_tool_result_chars
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
@@ -110,14 +138,53 @@ class SubagentManager:
|
||||
if max_concurrent_subagents is not None
|
||||
else defaults.max_concurrent_subagents
|
||||
)
|
||||
self.runner = AgentRunner(provider)
|
||||
self.fail_on_tool_error = (
|
||||
fail_on_tool_error
|
||||
if fail_on_tool_error is not None
|
||||
else defaults.fail_on_tool_error
|
||||
)
|
||||
self.runner = AgentRunner()
|
||||
self._exec_session_manager = ExecSessionManager()
|
||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||
self.mailbox = mailbox or MailboxStore(workspace)
|
||||
self._on_result_ready = on_result_ready
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||
warnings.warn(
|
||||
"SubagentManager.set_provider() is deprecated; pass runtime=... to spawn() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
context_window_tokens = (
|
||||
self._compat_runtime.context_window_tokens
|
||||
if self._compat_runtime is not None
|
||||
else AgentDefaults().context_window_tokens
|
||||
)
|
||||
self._compat_runtime = LLMRuntime.capture(
|
||||
provider,
|
||||
model,
|
||||
context_window_tokens=context_window_tokens,
|
||||
)
|
||||
|
||||
def _compat_spawn_runtime(self) -> LLMRuntime:
|
||||
runtime = self._compat_runtime
|
||||
if runtime is None:
|
||||
raise TypeError(
|
||||
"SubagentManager.spawn() missing required keyword-only argument: 'runtime'"
|
||||
)
|
||||
warnings.warn(
|
||||
"SubagentManager.spawn() without runtime is deprecated; pass runtime=... explicitly",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return LLMRuntime.capture(
|
||||
runtime.provider,
|
||||
runtime.model,
|
||||
context_window_tokens=runtime.context_window_tokens,
|
||||
)
|
||||
|
||||
def _subagent_tools_config(self) -> ToolsConfig:
|
||||
"""Build a ToolsConfig scoped for subagent use."""
|
||||
return ToolsConfig(
|
||||
@@ -139,6 +206,7 @@ class SubagentManager:
|
||||
ctx = ToolContext(
|
||||
config=cfg,
|
||||
workspace=str(root.resolve()),
|
||||
exec_session_manager=self._exec_session_manager,
|
||||
file_state_store=FileStates(),
|
||||
workspace_sandbox=workspace_sandbox_status(
|
||||
restrict_to_workspace=cfg.restrict_to_workspace,
|
||||
@@ -148,11 +216,6 @@ class SubagentManager:
|
||||
ToolLoader().load(ctx, registry, scope="subagent")
|
||||
return registry
|
||||
|
||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.runner.provider = provider
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
task: str,
|
||||
@@ -163,11 +226,16 @@ class SubagentManager:
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
workspace_scope: WorkspaceScope | None = None,
|
||||
*,
|
||||
runtime: LLMRuntime | None = None,
|
||||
) -> str:
|
||||
"""Spawn a subagent to execute a task in the background."""
|
||||
if runtime is None:
|
||||
runtime = self._compat_spawn_runtime()
|
||||
if temperature is not None:
|
||||
runtime = runtime.with_generation_overrides(temperature=temperature)
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
mailbox_session_key = session_key or f"{origin_channel}:{origin_chat_id}"
|
||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
|
||||
|
||||
status = SubagentStatus(
|
||||
@@ -177,18 +245,6 @@ class SubagentManager:
|
||||
started_at=time.monotonic(),
|
||||
)
|
||||
self._task_statuses[task_id] = status
|
||||
await self.mailbox.dispatch(TaskRequest(
|
||||
task_id=task_id,
|
||||
session_key=mailbox_session_key,
|
||||
label=display_label,
|
||||
task=task,
|
||||
origin={
|
||||
"channel": origin_channel,
|
||||
"chat_id": origin_chat_id,
|
||||
"session_key": session_key,
|
||||
"origin_message_id": origin_message_id,
|
||||
},
|
||||
))
|
||||
|
||||
bg_task = asyncio.create_task(
|
||||
self._run_subagent(
|
||||
@@ -197,8 +253,8 @@ class SubagentManager:
|
||||
display_label,
|
||||
origin,
|
||||
status,
|
||||
runtime,
|
||||
origin_message_id,
|
||||
temperature,
|
||||
workspace_scope,
|
||||
)
|
||||
)
|
||||
@@ -217,20 +273,17 @@ class SubagentManager:
|
||||
bg_task.add_done_callback(_cleanup)
|
||||
|
||||
logger.info("Spawned subagent [{}]: {}", task_id, display_label)
|
||||
return (
|
||||
f"Subagent [{display_label}] started (id: {task_id}). "
|
||||
f"Use poll_subagents or wait_subagents with id {task_id} to get the result."
|
||||
)
|
||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
||||
|
||||
async def _run_subagent(
|
||||
self,
|
||||
task_id: str,
|
||||
task: str,
|
||||
label: str,
|
||||
origin: dict[str, Any],
|
||||
origin: dict[str, str],
|
||||
status: SubagentStatus,
|
||||
runtime: LLMRuntime,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
workspace_scope: WorkspaceScope | None = None,
|
||||
) -> None:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
@@ -259,20 +312,26 @@ class SubagentManager:
|
||||
if self._llm_wall_timeout_for_session
|
||||
else None
|
||||
)
|
||||
request_token = bind_request_context(RequestContext(
|
||||
channel=origin["channel"],
|
||||
chat_id=origin["chat_id"],
|
||||
message_id=origin_message_id,
|
||||
session_key=sess_key,
|
||||
runtime=runtime,
|
||||
))
|
||||
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
|
||||
try:
|
||||
result = await self.runner.run(AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model=self.model,
|
||||
temperature=temperature,
|
||||
runtime=runtime,
|
||||
max_iterations=self.max_iterations,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=_SubagentHook(task_id, status),
|
||||
max_iterations_message="Task completed but no final response was generated.",
|
||||
finalize_on_max_iterations=False,
|
||||
error_message=None,
|
||||
fail_on_tool_error=True,
|
||||
fail_on_tool_error=self.fail_on_tool_error,
|
||||
checkpoint_callback=_on_checkpoint,
|
||||
session_key=sess_key,
|
||||
workspace=root,
|
||||
@@ -281,6 +340,7 @@ class SubagentManager:
|
||||
finally:
|
||||
if token is not None:
|
||||
reset_workspace_scope(token)
|
||||
reset_request_context(request_token)
|
||||
status.phase = "done"
|
||||
status.stop_reason = result.stop_reason
|
||||
|
||||
@@ -302,12 +362,6 @@ class SubagentManager:
|
||||
logger.info("Subagent [{}] completed successfully", task_id)
|
||||
await self._announce_result(task_id, label, task, final_result, origin, "ok", origin_message_id)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status.phase = "cancelled"
|
||||
status.stop_reason = "cancelled"
|
||||
await self.mailbox.mark_cancelled(task_id, reason="Cancelled.")
|
||||
logger.info("Subagent [{}] cancelled", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
status.phase = "error"
|
||||
status.error = str(e)
|
||||
@@ -320,45 +374,44 @@ class SubagentManager:
|
||||
label: str,
|
||||
task: str,
|
||||
result: str,
|
||||
origin: dict[str, Any],
|
||||
origin: dict[str, str],
|
||||
status: str,
|
||||
origin_message_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record the subagent result in the mailbox for explicit manager polling."""
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
status_text = "completed successfully" if status == "ok" else "failed"
|
||||
|
||||
announce_content = render_template(
|
||||
"agent/subagent_announce.md",
|
||||
label=label,
|
||||
status_text=status_text,
|
||||
task=task,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Inject as system message to trigger main agent.
|
||||
# Use session_key_override to align with the main agent's effective
|
||||
# session key (which accounts for unified sessions) so the result is
|
||||
# routed to the correct pending queue (mid-turn injection) instead of
|
||||
# being dispatched as a competing independent task.
|
||||
override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}"
|
||||
metadata: dict[str, Any] = {
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": task_id,
|
||||
"origin_channel": origin.get("channel"),
|
||||
"origin_chat_id": origin.get("chat_id"),
|
||||
}
|
||||
if origin_message_id:
|
||||
metadata["origin_message_id"] = origin_message_id
|
||||
|
||||
task_result = TaskResult(
|
||||
task_id=task_id,
|
||||
session_key=override,
|
||||
label=label,
|
||||
task=task,
|
||||
status=status,
|
||||
content=result,
|
||||
dedupe_key=task_id,
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
session_key_override=override,
|
||||
metadata=metadata,
|
||||
)
|
||||
written = await self.mailbox.record_result(task_result)
|
||||
|
||||
if written:
|
||||
logger.debug(
|
||||
"Subagent [{}] wrote result to mailbox for session {}",
|
||||
task_id,
|
||||
override,
|
||||
)
|
||||
if self._on_result_ready is not None:
|
||||
try:
|
||||
await self._on_result_ready(task_result)
|
||||
except Exception:
|
||||
logger.exception("Subagent result-ready callback failed")
|
||||
else:
|
||||
logger.debug("Subagent [{}] result already recorded", task_id)
|
||||
await self.bus.publish_inbound(msg)
|
||||
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
||||
|
||||
@staticmethod
|
||||
def _format_partial_progress(result) -> str:
|
||||
@@ -383,10 +436,8 @@ class SubagentManager:
|
||||
|
||||
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
|
||||
"""Build a focused system prompt for the subagent."""
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
|
||||
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
||||
root = workspace or self.workspace
|
||||
skills_summary = SkillsLoader(
|
||||
root,
|
||||
@@ -394,7 +445,6 @@ class SubagentManager:
|
||||
).build_skills_summary()
|
||||
return render_template(
|
||||
"agent/subagent_system.md",
|
||||
time_ctx=time_ctx,
|
||||
workspace=str(root),
|
||||
skills_summary=skills_summary or "",
|
||||
)
|
||||
@@ -403,95 +453,12 @@ class SubagentManager:
|
||||
"""Cancel all subagents for the given session. Returns count cancelled."""
|
||||
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
|
||||
if tid in self._running_tasks and not self._running_tasks[tid].done()]
|
||||
for tid in list(self._session_tasks.get(session_key, [])):
|
||||
if tid in self._running_tasks and not self._running_tasks[tid].done():
|
||||
await self.mailbox.mark_cancelled(
|
||||
tid,
|
||||
session_key=session_key,
|
||||
reason="Cancelled by /stop.",
|
||||
)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return len(tasks)
|
||||
|
||||
async def cancel_task(self, task_id: str, session_key: str | None = None) -> str:
|
||||
"""Cancel one running subagent task and record a cancelled mailbox state."""
|
||||
snapshots = await self.mailbox.poll(session_key, task_id=task_id) if session_key else []
|
||||
if session_key and not snapshots:
|
||||
return "not_found"
|
||||
task = self._running_tasks.get(task_id)
|
||||
if task is None or task.done():
|
||||
if snapshots:
|
||||
return snapshots[0].state
|
||||
return "not_found"
|
||||
await self.mailbox.mark_cancelled(
|
||||
task_id,
|
||||
session_key=session_key,
|
||||
reason="Cancelled by manager.",
|
||||
)
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
return "cancelled"
|
||||
|
||||
async def poll(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None = None,
|
||||
) -> list[TaskSnapshot]:
|
||||
"""Return mailbox task status snapshots for a session."""
|
||||
return await self.mailbox.poll(session_key, task_id=task_id)
|
||||
|
||||
async def wait_for_result(
|
||||
self,
|
||||
session_key: str,
|
||||
task_id: str | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> MailboxRead:
|
||||
"""Wait for and consume a mailbox result for a session."""
|
||||
return await self.mailbox.wait_for_result(
|
||||
session_key,
|
||||
task_id=task_id,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
def runtime_status_lines(self, session_key: str, *, limit: int = 8) -> list[str]:
|
||||
"""Return compact model-visible task status lines for runtime context."""
|
||||
snapshots = self.mailbox.snapshot_sync(session_key)
|
||||
if not snapshots:
|
||||
return []
|
||||
|
||||
now = time.time()
|
||||
ordered = sorted(
|
||||
snapshots,
|
||||
key=lambda item: (
|
||||
item.consumed_at is not None,
|
||||
item.completed_at is None,
|
||||
item.created_at,
|
||||
item.task_id,
|
||||
),
|
||||
)
|
||||
lines = ["Subagent tasks:"]
|
||||
for snapshot in ordered[: max(0, limit)]:
|
||||
state = snapshot.state
|
||||
if snapshot.result_status and snapshot.consumed_at is None:
|
||||
state = f"{state}, result ready"
|
||||
elif snapshot.consumed_at is not None:
|
||||
state = f"{state}, result consumed"
|
||||
elapsed = max(0, int((snapshot.completed_at or now) - snapshot.created_at))
|
||||
label = " ".join(snapshot.label.split())
|
||||
if len(label) > 48:
|
||||
label = label[:45] + "..."
|
||||
lines.append(
|
||||
f"- {snapshot.task_id}: {state}, label=\"{label}\", elapsed={elapsed}s"
|
||||
)
|
||||
remaining = len(ordered) - limit
|
||||
if remaining > 0:
|
||||
lines.append(f"- ... {remaining} more subagent task(s)")
|
||||
return lines
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Runtime delivery helpers for completed subagent task results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session import turn_continuation
|
||||
|
||||
_FORWARDED_METADATA_KEYS = frozenset({
|
||||
"message_id",
|
||||
"origin_message_id",
|
||||
"_wants_stream",
|
||||
"webui",
|
||||
"slack",
|
||||
})
|
||||
|
||||
|
||||
def build_subagent_result_continuation(result: Any) -> InboundMessage:
|
||||
"""Build an internal inbound wake-up for a ready subagent result."""
|
||||
metadata = dict(result.metadata or {})
|
||||
channel = str(metadata.get("origin_channel") or "")
|
||||
chat_id = str(metadata.get("origin_chat_id") or "")
|
||||
if not channel or not chat_id:
|
||||
channel, chat_id = _channel_chat_from_session_key(result.session_key)
|
||||
|
||||
wake_meta = turn_continuation.subagent_result_continuation_metadata(
|
||||
{key: value for key, value in metadata.items() if key in _FORWARDED_METADATA_KEYS},
|
||||
task_id=result.task_id,
|
||||
)
|
||||
return InboundMessage(
|
||||
channel=channel,
|
||||
sender_id="system:continuation",
|
||||
chat_id=chat_id,
|
||||
content=(
|
||||
"A subagent task result is ready. The runtime will attach the "
|
||||
"result to this continuation turn."
|
||||
),
|
||||
metadata=wake_meta,
|
||||
session_key_override=result.session_key,
|
||||
)
|
||||
|
||||
|
||||
async def materialize_subagent_result_continuation(
|
||||
msg: InboundMessage,
|
||||
*,
|
||||
session_key: str,
|
||||
subagents: Any,
|
||||
) -> InboundMessage:
|
||||
"""Replace a subagent-result continuation placeholder with the mailbox result."""
|
||||
task_id = turn_continuation.subagent_result_continuation_task_id(msg.metadata)
|
||||
if not task_id:
|
||||
return msg
|
||||
read = await subagents.wait_for_result(
|
||||
session_key,
|
||||
task_id=task_id,
|
||||
timeout_seconds=0,
|
||||
)
|
||||
return dataclasses.replace(msg, content=_subagent_result_continuation_content(read, task_id))
|
||||
|
||||
|
||||
def _channel_chat_from_session_key(session_key: str) -> tuple[str, str]:
|
||||
channel, _, chat_id = session_key.partition(":")
|
||||
return channel or "cli", chat_id or "direct"
|
||||
|
||||
|
||||
def _subagent_result_continuation_content(read: Any, requested_task_id: str) -> str:
|
||||
if read.state == "ready" and read.result is not None:
|
||||
status_text = {
|
||||
"ok": "completed",
|
||||
"error": "failed",
|
||||
"cancelled": "cancelled",
|
||||
}.get(read.result.status, read.result.status)
|
||||
return (
|
||||
"A subagent result was delivered by the runtime. Use this result "
|
||||
"as authoritative context for the next answer; do not mention the "
|
||||
"internal continuation boundary.\n\n"
|
||||
f"Subagent [{read.result.label}] "
|
||||
f"(id: {read.result.task_id}, status: {status_text})\n\n"
|
||||
f"Task:\n{read.result.task}\n\n"
|
||||
f"Result:\n{read.result.content}"
|
||||
)
|
||||
if read.state == "consumed":
|
||||
return (
|
||||
f"Subagent task {requested_task_id} already has a consumed result. "
|
||||
"Check poll_subagents if you need its current status."
|
||||
)
|
||||
if read.state == "running":
|
||||
return (
|
||||
f"Subagent task {requested_task_id} is still running. "
|
||||
"Use poll_subagents or wait_subagents if you need to block."
|
||||
)
|
||||
return f"Subagent task {requested_task_id} result is not available ({read.state})."
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Agent tools module."""
|
||||
|
||||
from nanobot.agent.tools.base import Schema, Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
@@ -25,6 +25,7 @@ __all__ = [
|
||||
"Tool",
|
||||
"ToolContext",
|
||||
"ToolLoader",
|
||||
"ToolResult",
|
||||
"ToolRegistry",
|
||||
"tool_parameters",
|
||||
"tool_parameters_schema",
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import tool_parameters
|
||||
from nanobot.agent.tools.base import ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.filesystem import _FsTool
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
@@ -31,19 +30,12 @@ class _PatchError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _validate_relative_path(path: str) -> str:
|
||||
def _validate_patch_path(path: str) -> str:
|
||||
normalized = path.strip()
|
||||
if not normalized:
|
||||
raise _PatchError("patch path cannot be empty")
|
||||
if "\0" in normalized:
|
||||
raise _PatchError(f"patch path contains a null byte: {path!r}")
|
||||
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
|
||||
raise _PatchError(f"patch path must be relative: {path}")
|
||||
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
|
||||
raise _PatchError(f"patch path must not contain '..': {path}")
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -98,7 +90,10 @@ def _format_summary(summary: _PatchSummary) -> str:
|
||||
tool_parameters_schema(
|
||||
edits=ArraySchema(
|
||||
items=ObjectSchema(
|
||||
path=StringSchema("Relative path to the file to edit."),
|
||||
path=StringSchema(
|
||||
"Path to the file to edit. Relative paths resolve against the "
|
||||
"workspace; absolute paths and '..' obey the workspace access policy."
|
||||
),
|
||||
action=StringSchema(
|
||||
"Operation type: replace or add.",
|
||||
enum=["replace", "add"],
|
||||
@@ -138,7 +133,8 @@ class ApplyPatchTool(_FsTool):
|
||||
"Default tool for code edits. Supports multi-file changes in a single call. "
|
||||
"Provide a list of structured edits, each specifying a file path, action "
|
||||
"(replace/add), and the exact text to change. "
|
||||
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
|
||||
"Paths are resolved by the current workspace access policy. "
|
||||
"Set dry_run=true to validate and preview without writing files. "
|
||||
"Use edit_file only for small exact replacements on a single file."
|
||||
)
|
||||
|
||||
@@ -161,11 +157,11 @@ class ApplyPatchTool(_FsTool):
|
||||
raw_path = edit.get("path")
|
||||
if not isinstance(raw_path, str):
|
||||
raise _PatchError("path required for edit")
|
||||
path = _validate_relative_path(raw_path)
|
||||
path = _validate_patch_path(raw_path)
|
||||
action = edit.get("action")
|
||||
if not isinstance(action, str):
|
||||
raise _PatchError(f"action required for edit: {path}")
|
||||
source = self._resolve(path)
|
||||
source = self._resolve_write(path)
|
||||
|
||||
if action == "add":
|
||||
new_text = edit.get("new_text")
|
||||
@@ -293,8 +289,8 @@ class ApplyPatchTool(_FsTool):
|
||||
_format_summary(summary) for summary in summaries
|
||||
)
|
||||
except PermissionError as exc:
|
||||
return f"Error: {exc}"
|
||||
return ToolResult.error(f"Error: {exc}")
|
||||
except _PatchError as exc:
|
||||
return f"Error applying patch: {exc}"
|
||||
return ToolResult.error(f"Error applying patch: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error applying patch: {exc}"
|
||||
return ToolResult.error(f"Error applying patch: {exc}")
|
||||
|
||||
@@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.runtime_context import RuntimeContextProvider
|
||||
|
||||
_ToolT = TypeVar("_ToolT", bound="Tool")
|
||||
|
||||
@@ -84,9 +85,16 @@ class Schema(ABC):
|
||||
for k in schema.get("required", []):
|
||||
if k not in val:
|
||||
errors.append(f"missing required {Schema.subpath(path, k)}")
|
||||
additional = schema.get("additionalProperties", True)
|
||||
for k, v in val.items():
|
||||
if k in props:
|
||||
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
|
||||
elif additional is False:
|
||||
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
|
||||
elif isinstance(additional, dict):
|
||||
errors.extend(
|
||||
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k))
|
||||
)
|
||||
if t == "array":
|
||||
if "minItems" in schema and len(val) < schema["minItems"]:
|
||||
errors.append(f"{label} must have at least {schema['minItems']} items")
|
||||
@@ -121,6 +129,21 @@ class Schema(ABC):
|
||||
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
|
||||
|
||||
|
||||
class ToolResult(str):
|
||||
"""String-compatible tool output with structured status."""
|
||||
|
||||
is_error: bool
|
||||
|
||||
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
|
||||
obj = str.__new__(cls, content)
|
||||
obj.is_error = is_error
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def error(cls, content: str) -> ToolResult:
|
||||
return cls(content, is_error=True)
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
"""Agent capability: read files, run commands, etc."""
|
||||
|
||||
@@ -184,16 +207,33 @@ class Tool(ABC):
|
||||
def create(cls, ctx: ToolContext) -> Tool:
|
||||
return cls()
|
||||
|
||||
def runtime_context_provider(self) -> RuntimeContextProvider | None:
|
||||
"""Return optional per-turn prompt context owned by this tool."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
"""Run the tool; returns a string or list of content blocks."""
|
||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def error(content: str) -> ToolResult:
|
||||
return ToolResult.error(content)
|
||||
|
||||
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(obj, dict):
|
||||
return obj
|
||||
props = schema.get("properties", {})
|
||||
return {k: self._cast_value(v, props[k]) if k in props else v for k, v in obj.items()}
|
||||
additional = schema.get("additionalProperties")
|
||||
casted: dict[str, Any] = {}
|
||||
for k, v in obj.items():
|
||||
if k in props:
|
||||
casted[k] = self._cast_value(v, props[k])
|
||||
elif isinstance(additional, dict):
|
||||
casted[k] = self._cast_value(v, additional)
|
||||
else:
|
||||
casted[k] = v
|
||||
return casted
|
||||
|
||||
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Apply safe schema-driven casts before validation."""
|
||||
|
||||
@@ -7,11 +7,20 @@ from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
|
||||
from nanobot.security.workspace_access import current_tool_workspace
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.schema import (
|
||||
ArraySchema,
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.apps.cli.utils import runtime_lines_for_request
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||
from nanobot.security.workspace_access import current_tool_workspace
|
||||
|
||||
|
||||
class CliAppsToolConfig(Base):
|
||||
@@ -106,6 +115,23 @@ class CliAppsTool(Tool):
|
||||
+ installed_note
|
||||
)
|
||||
|
||||
def runtime_context_provider(self):
|
||||
return self._provide_runtime_context
|
||||
|
||||
async def _provide_runtime_context(
|
||||
self,
|
||||
request: RequestContext,
|
||||
) -> RuntimeContextBlock | None:
|
||||
lines = runtime_lines_for_request(
|
||||
request.original_user_text or "",
|
||||
request.metadata,
|
||||
request.workspace or self.workspace,
|
||||
)
|
||||
content = wrap_runtime_context_lines(lines)
|
||||
if not content:
|
||||
return None
|
||||
return RuntimeContextBlock(source="cli_apps", content=content)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
name: str,
|
||||
@@ -130,4 +156,4 @@ class CliAppsTool(Tool):
|
||||
restrict_to_workspace=access.restrict_to_workspace,
|
||||
)
|
||||
except CliAppError as exc:
|
||||
return f"Error: {exc.message}"
|
||||
return ToolResult.error(f"Error: {exc.message}")
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Runtime context for tool construction."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Protocol, runtime_checkable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
|
||||
"nanobot_tool_request_context",
|
||||
@@ -18,7 +23,12 @@ class RequestContext:
|
||||
chat_id: str
|
||||
message_id: str | None = None
|
||||
session_key: str | None = None
|
||||
original_user_text: str | None = None
|
||||
runtime: LLMRuntime | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
sender_id: str | None = None
|
||||
turn_id: str | None = None
|
||||
workspace: Path | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -35,6 +45,16 @@ def reset_request_context(token: Token[RequestContext | None]) -> None:
|
||||
_CURRENT_REQUEST_CONTEXT.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def request_context(ctx: RequestContext):
|
||||
"""Bind one immutable request snapshot and restore the previous value."""
|
||||
token = bind_request_context(ctx)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
reset_request_context(token)
|
||||
|
||||
|
||||
def current_request_context() -> RequestContext | None:
|
||||
return _CURRENT_REQUEST_CONTEXT.get()
|
||||
|
||||
@@ -51,6 +71,7 @@ class ToolContext:
|
||||
bus: Any | None = None
|
||||
subagent_manager: Any | None = None
|
||||
cron_service: Any | None = None
|
||||
exec_session_manager: Any | None = None
|
||||
sessions: Any | None = None
|
||||
file_state_store: Any = field(default=None)
|
||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
||||
|
||||
+22
-29
@@ -6,8 +6,8 @@ from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_context
|
||||
from nanobot.agent.tools.schema import (
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
@@ -51,19 +51,12 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
||||
|
||||
|
||||
@tool_parameters(_CRON_PARAMETERS)
|
||||
class CronTool(Tool, ContextAware):
|
||||
class CronTool(Tool):
|
||||
"""Tool to schedule reminders and recurring tasks."""
|
||||
|
||||
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
|
||||
self._cron = cron_service
|
||||
self._default_timezone = default_timezone
|
||||
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
|
||||
self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="")
|
||||
self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="")
|
||||
self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
|
||||
"cron_origin_metadata",
|
||||
default=None,
|
||||
)
|
||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||
|
||||
@classmethod
|
||||
@@ -74,15 +67,17 @@ class CronTool(Tool, ContextAware):
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
|
||||
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
"""Set the current session context for scheduled cron job ownership."""
|
||||
@staticmethod
|
||||
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
|
||||
"""Return routing from the authoritative request snapshot."""
|
||||
ctx = current_request_context()
|
||||
if ctx is None:
|
||||
return "", "", "", {}
|
||||
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
|
||||
self._session_key.set(
|
||||
session_key = (
|
||||
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
|
||||
)
|
||||
self._origin_channel.set(ctx.channel or "")
|
||||
self._origin_chat_id.set(ctx.chat_id or "")
|
||||
self._origin_metadata.set(dict(ctx.metadata or {}))
|
||||
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
|
||||
|
||||
def set_cron_context(self, active: bool):
|
||||
"""Mark whether the tool is executing inside a cron job callback."""
|
||||
@@ -99,7 +94,7 @@ class CronTool(Tool, ContextAware):
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (KeyError, Exception):
|
||||
return f"Error: unknown timezone '{tz}'"
|
||||
return ToolResult.error(f"Error: unknown timezone '{tz}'")
|
||||
return None
|
||||
|
||||
def _display_timezone(self, schedule: CronSchedule) -> str:
|
||||
@@ -148,7 +143,7 @@ class CronTool(Tool, ContextAware):
|
||||
) -> str:
|
||||
if action == "add":
|
||||
if self._in_cron_context.get():
|
||||
return "Error: cannot schedule new jobs from within a cron job execution"
|
||||
return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
|
||||
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
@@ -166,20 +161,18 @@ class CronTool(Tool, ContextAware):
|
||||
at: str | None,
|
||||
) -> str:
|
||||
if not message:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
"Error: cron action='add' requires a non-empty 'message' parameter "
|
||||
"describing what to do when the job triggers "
|
||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||
)
|
||||
session_key = self._session_key.get()
|
||||
session_key, origin_channel, origin_chat_id, origin_metadata = self._request_route()
|
||||
if not session_key:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
origin_channel = self._origin_channel.get()
|
||||
origin_chat_id = self._origin_chat_id.get()
|
||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||
if not origin_channel or not origin_chat_id:
|
||||
return "Error: scheduled cron jobs must be created from a chat session"
|
||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
return ToolResult.error("Error: tz can only be used with cron_expr")
|
||||
if tz:
|
||||
if err := self._validate_timezone(tz):
|
||||
return err
|
||||
@@ -199,7 +192,7 @@ class CronTool(Tool, ContextAware):
|
||||
try:
|
||||
dt = datetime.fromisoformat(at)
|
||||
except ValueError:
|
||||
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
|
||||
return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
|
||||
if dt.tzinfo is None:
|
||||
if err := self._validate_timezone(self._default_timezone):
|
||||
return err
|
||||
@@ -208,7 +201,7 @@ class CronTool(Tool, ContextAware):
|
||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
||||
delete_after = True
|
||||
else:
|
||||
return "Error: either every_seconds, cron_expr, or at is required"
|
||||
return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
|
||||
|
||||
job = self._cron.add_job(
|
||||
name=name or message[:30],
|
||||
@@ -218,7 +211,7 @@ class CronTool(Tool, ContextAware):
|
||||
session_key=session_key,
|
||||
origin_channel=origin_channel,
|
||||
origin_chat_id=origin_chat_id,
|
||||
origin_metadata=dict(self._origin_metadata.get() or {}),
|
||||
origin_metadata=origin_metadata,
|
||||
)
|
||||
return f"Created job '{job.name}' (id: {job.id})"
|
||||
|
||||
@@ -279,7 +272,7 @@ class CronTool(Tool, ContextAware):
|
||||
|
||||
def _remove_job(self, job_id: str | None) -> str:
|
||||
if not job_id:
|
||||
return "Error: job_id is required for remove"
|
||||
return ToolResult.error("Error: job_id is required for remove")
|
||||
result = self._cron.remove_job(job_id)
|
||||
if result == "removed":
|
||||
return f"Removed job {job_id}"
|
||||
|
||||
@@ -9,7 +9,7 @@ from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.context import current_request_session_key
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
@@ -128,7 +128,15 @@ class _ExecSession:
|
||||
) -> _SessionPoll:
|
||||
self.last_access = time.monotonic()
|
||||
if yield_time_ms > 0 and self.process.returncode is None:
|
||||
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
|
||||
wait_s = min(yield_time_ms, MAX_YIELD_MS) / 1000
|
||||
remaining_s = self.deadline - time.monotonic()
|
||||
if remaining_s <= 0:
|
||||
wait_s = 0
|
||||
else:
|
||||
wait_s = min(wait_s, remaining_s)
|
||||
if wait_s > 0:
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.process.wait(), timeout=wait_s)
|
||||
|
||||
if self.process.returncode is None and time.monotonic() >= self.deadline:
|
||||
self._timed_out = True
|
||||
@@ -140,6 +148,9 @@ class _ExecSession:
|
||||
asyncio.gather(self._stdout_task, self._stderr_task),
|
||||
timeout=2.0,
|
||||
)
|
||||
# Safety-net reap after normal exit.
|
||||
from nanobot.agent.tools.shell import _reap_pid
|
||||
_reap_pid(self.process.pid)
|
||||
elif yield_time_ms > 0:
|
||||
await self._wait_for_buffered_output()
|
||||
|
||||
@@ -163,8 +174,14 @@ class _ExecSession:
|
||||
if self.process.returncode is not None:
|
||||
return
|
||||
self.process.kill()
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||
try:
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
||||
finally:
|
||||
# Safety-net waitpid — prevent zombie if asyncio's child watcher
|
||||
# did not reap the process (common in containers).
|
||||
from nanobot.agent.tools.shell import _reap_pid
|
||||
_reap_pid(self.process.pid)
|
||||
|
||||
async def _wait_for_buffered_output(self) -> None:
|
||||
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
||||
@@ -233,11 +250,7 @@ class ExecSessionManager:
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError(session_id)
|
||||
if (
|
||||
owner_session_key
|
||||
and session.owner_session_key
|
||||
and session.owner_session_key != owner_session_key
|
||||
):
|
||||
if session.owner_session_key and session.owner_session_key != owner_session_key:
|
||||
raise KeyError(session_id)
|
||||
|
||||
if chars:
|
||||
@@ -279,9 +292,7 @@ class ExecSessionManager:
|
||||
owner_session_key=session.owner_session_key,
|
||||
)
|
||||
for session_id, session in sorted(self._sessions.items())
|
||||
if not owner_session_key
|
||||
or not session.owner_session_key
|
||||
or session.owner_session_key == owner_session_key
|
||||
if session.owner_session_key == owner_session_key
|
||||
]
|
||||
|
||||
async def _cleanup_locked(self) -> None:
|
||||
@@ -425,7 +436,7 @@ class WriteStdinTool(Tool):
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls()
|
||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
@@ -492,11 +503,12 @@ class WriteStdinTool(Tool):
|
||||
max_output_chars=output_limit,
|
||||
owner_session_key=current_request_session_key(),
|
||||
)
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
except KeyError:
|
||||
return f"Error: exec session not found: {session_id}"
|
||||
return ToolResult.error(f"Error: exec session not found: {session_id!r}")
|
||||
except Exception as exc:
|
||||
return f"Error writing to exec session: {exc}"
|
||||
return ToolResult.error(f"Error writing to exec session: {exc}")
|
||||
|
||||
async def _wait_for_output(
|
||||
self,
|
||||
@@ -532,13 +544,14 @@ class WriteStdinTool(Tool):
|
||||
joined = "".join(aggregate)
|
||||
if wait_for in joined:
|
||||
poll.output = joined
|
||||
return format_session_poll(session_id, poll)
|
||||
result = format_session_poll(session_id, poll)
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
if poll.done or remaining_ms <= 0:
|
||||
poll.output = "".join(aggregate)
|
||||
result = format_session_poll(session_id, poll)
|
||||
if wait_for not in poll.output:
|
||||
result += f"\nWait target not observed: {wait_for!r}"
|
||||
return result
|
||||
return ToolResult.error(result) if poll.timed_out else result
|
||||
|
||||
|
||||
@tool_parameters(tool_parameters_schema())
|
||||
@@ -567,7 +580,7 @@ class ListExecSessionsTool(Tool):
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx: Any) -> Tool:
|
||||
return cls()
|
||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -606,4 +619,4 @@ class ListExecSessionsTool(Tool):
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
return f"Error listing exec sessions: {exc}"
|
||||
return ToolResult.error(f"Error listing exec sessions: {exc}")
|
||||
|
||||
+179
-122
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||
from nanobot.agent.tools.schema import (
|
||||
@@ -45,13 +45,23 @@ class _FsTool(Tool):
|
||||
workspace: Path | None = None,
|
||||
allowed_dir: Path | None = None,
|
||||
extra_allowed_dirs: list[Path] | None = None,
|
||||
extra_read_allowed_dirs: list[Path] | None = None,
|
||||
extra_write_allowed_dirs: list[Path] | None = None,
|
||||
extra_write_allowed_files: list[Path] | None = None,
|
||||
file_states: FileStates | None = None,
|
||||
restrict_to_workspace: bool | None = None,
|
||||
sandbox_restricts_workspace: bool = False,
|
||||
):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
self._extra_allowed_dirs = extra_allowed_dirs
|
||||
# Legacy alias: extra_allowed_dirs is read-only. Write-capable tools
|
||||
# must opt in via extra_write_allowed_dirs.
|
||||
self._extra_read_allowed_dirs = [
|
||||
*(extra_allowed_dirs or []),
|
||||
*(extra_read_allowed_dirs or []),
|
||||
]
|
||||
self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or [])
|
||||
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
||||
self._restrict_to_workspace = (
|
||||
bool(restrict_to_workspace)
|
||||
if restrict_to_workspace is not None
|
||||
@@ -78,7 +88,7 @@ class _FsTool(Tool):
|
||||
return cls(
|
||||
workspace=Path(ctx.workspace),
|
||||
allowed_dir=allowed_dir,
|
||||
extra_allowed_dirs=extra_read,
|
||||
extra_read_allowed_dirs=extra_read,
|
||||
file_states=ctx.file_state_store,
|
||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||
sandbox_restricts_workspace=sandbox_restricts,
|
||||
@@ -90,7 +100,26 @@ class _FsTool(Tool):
|
||||
return self._explicit_file_states
|
||||
return current_file_states(self._fallback_file_states)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
def _effective_allowed_root(self, access_allowed_root: Path | None) -> Path | None:
|
||||
if self._allowed_dir is None or self._workspace is None:
|
||||
return access_allowed_root
|
||||
try:
|
||||
allowed_dir = Path(self._allowed_dir).expanduser().resolve(strict=False)
|
||||
workspace = Path(self._workspace).expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError, TypeError, ValueError):
|
||||
return access_allowed_root if access_allowed_root is not None else self._allowed_dir
|
||||
if allowed_dir == workspace:
|
||||
return access_allowed_root
|
||||
return allowed_dir
|
||||
|
||||
def _resolve_with_extra(
|
||||
self,
|
||||
path: str,
|
||||
extra_allowed_dirs: list[Path] | None,
|
||||
extra_allowed_files: list[Path] | None,
|
||||
*,
|
||||
include_media_dir: bool,
|
||||
) -> Path:
|
||||
access = current_tool_workspace(
|
||||
self._workspace,
|
||||
restrict_to_workspace=self._restrict_to_workspace,
|
||||
@@ -99,10 +128,31 @@ class _FsTool(Tool):
|
||||
return resolve_workspace_path(
|
||||
path,
|
||||
access.project_path,
|
||||
access.allowed_root,
|
||||
self._extra_allowed_dirs,
|
||||
self._effective_allowed_root(access.allowed_root),
|
||||
extra_allowed_dirs,
|
||||
extra_allowed_files,
|
||||
include_media_dir=include_media_dir,
|
||||
)
|
||||
|
||||
def _resolve_read(self, path: str) -> Path:
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_read_allowed_dirs,
|
||||
None,
|
||||
include_media_dir=True,
|
||||
)
|
||||
|
||||
def _resolve_write(self, path: str) -> Path:
|
||||
return self._resolve_with_extra(
|
||||
path,
|
||||
self._extra_write_allowed_dirs,
|
||||
self._extra_write_allowed_files,
|
||||
include_media_dir=False,
|
||||
)
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
return self._resolve_read(path)
|
||||
|
||||
def _display_workspace(self) -> Path | None:
|
||||
return current_tool_workspace(self._workspace).project_path
|
||||
|
||||
@@ -144,15 +194,21 @@ def _is_blocked_device(path: str | Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
|
||||
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
|
||||
parts = pages.strip().split("-")
|
||||
if len(parts) == 1:
|
||||
p = int(parts[0])
|
||||
return max(0, p - 1), min(p - 1, total - 1)
|
||||
start = int(parts[0])
|
||||
end = int(parts[1])
|
||||
return max(0, start - 1), min(end - 1, total - 1)
|
||||
def _builtin_skill_read_path(path: str) -> Path | None:
|
||||
"""Map workspace-relative skills/<name>/... reads onto bundled skills."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
|
||||
requested = Path(path)
|
||||
if requested.is_absolute():
|
||||
return None
|
||||
parts = requested.parts
|
||||
if len(parts) < 2 or parts[0] != "skills":
|
||||
return None
|
||||
root = BUILTIN_SKILLS_DIR.resolve()
|
||||
candidate = (root / Path(*parts[1:])).resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
return None
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
@@ -218,19 +274,21 @@ class ReadFileTool(_FsTool):
|
||||
) -> Any:
|
||||
try:
|
||||
if not path:
|
||||
return "Error reading file: Unknown path"
|
||||
return ToolResult.error("Error reading file: Unknown path")
|
||||
|
||||
# Device path blacklist
|
||||
if _is_blocked_device(path):
|
||||
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
||||
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
|
||||
|
||||
fp = self._resolve(path)
|
||||
if _is_blocked_device(fp):
|
||||
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
||||
fp = self._resolve_read(path)
|
||||
if not fp.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
fp = _builtin_skill_read_path(path) or fp
|
||||
if _is_blocked_device(fp):
|
||||
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
|
||||
if not fp.exists():
|
||||
return ToolResult.error(f"Error: File not found: {path}")
|
||||
if not fp.is_file():
|
||||
return f"Error: Not a file: {path}"
|
||||
return ToolResult.error(f"Error: Not a file: {path}")
|
||||
|
||||
# PDF support
|
||||
if fp.suffix.lower() == ".pdf":
|
||||
@@ -293,7 +351,7 @@ class ReadFileTool(_FsTool):
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
|
||||
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
|
||||
|
||||
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
||||
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
||||
@@ -307,7 +365,7 @@ class ReadFileTool(_FsTool):
|
||||
if offset < 1:
|
||||
offset = 1
|
||||
if offset > total:
|
||||
return f"Error: offset {offset} is beyond end of file ({total} lines)"
|
||||
return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
|
||||
|
||||
start = offset - 1
|
||||
end = min(start + (limit or self._DEFAULT_LIMIT), total)
|
||||
@@ -331,54 +389,38 @@ class ReadFileTool(_FsTool):
|
||||
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error reading file: {e}"
|
||||
return ToolResult.error(f"Error reading file: {e}")
|
||||
|
||||
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
except ImportError:
|
||||
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
|
||||
from nanobot.utils.document import PdfPageRangeError, PdfSafetyError, extract_pdf_pages
|
||||
|
||||
try:
|
||||
doc = fitz.open(str(fp))
|
||||
extraction = extract_pdf_pages(
|
||||
fp,
|
||||
pages=pages,
|
||||
max_pages=self._MAX_PDF_PAGES,
|
||||
max_chars=self._MAX_CHARS,
|
||||
)
|
||||
except PdfPageRangeError:
|
||||
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
||||
except PdfSafetyError as e:
|
||||
return ToolResult.error(f"Error reading PDF: {e}")
|
||||
except Exception as e:
|
||||
return f"Error reading PDF: {e}"
|
||||
return ToolResult.error(f"Error reading PDF: {e}")
|
||||
|
||||
total_pages = len(doc)
|
||||
if pages:
|
||||
try:
|
||||
start, end = _parse_page_range(pages, total_pages)
|
||||
except (ValueError, IndexError):
|
||||
doc.close()
|
||||
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
|
||||
if start > end or start >= total_pages:
|
||||
doc.close()
|
||||
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
|
||||
else:
|
||||
start = 0
|
||||
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
||||
|
||||
if end - start + 1 > self._MAX_PDF_PAGES:
|
||||
end = start + self._MAX_PDF_PAGES - 1
|
||||
|
||||
parts: list[str] = []
|
||||
for i in range(start, end + 1):
|
||||
page = doc[i]
|
||||
text = page.get_text().strip()
|
||||
if text:
|
||||
parts.append(f"--- Page {i + 1} ---\n{text}")
|
||||
doc.close()
|
||||
|
||||
if not parts:
|
||||
if not extraction.text:
|
||||
return f"(PDF has no extractable text: {fp})"
|
||||
|
||||
result = "\n\n".join(parts)
|
||||
if end < total_pages - 1:
|
||||
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
|
||||
if len(result) > self._MAX_CHARS:
|
||||
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
|
||||
result = extraction.text
|
||||
if extraction.end_page < extraction.total_pages - 1:
|
||||
next_start = extraction.end_page + 2
|
||||
next_end = min(extraction.end_page + 1 + self._MAX_PDF_PAGES, extraction.total_pages)
|
||||
result += (
|
||||
f"\n\n(Showing pages {extraction.start_page + 1}-{extraction.end_page + 1} "
|
||||
f"of {extraction.total_pages}. Use pages='{next_start}-{next_end}' to continue.)"
|
||||
)
|
||||
return result
|
||||
|
||||
def _read_office_doc(self, fp: Path) -> str:
|
||||
@@ -387,10 +429,10 @@ class ReadFileTool(_FsTool):
|
||||
result = extract_text(fp)
|
||||
|
||||
if result is None:
|
||||
return f"Error: Unsupported file format: {fp.suffix}"
|
||||
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
|
||||
|
||||
if result.startswith("[error:"):
|
||||
return f"Error reading {fp.suffix.upper()} file: {result}"
|
||||
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
|
||||
|
||||
if not result:
|
||||
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
||||
@@ -436,15 +478,15 @@ class WriteFileTool(_FsTool):
|
||||
raise ValueError("Unknown path")
|
||||
if content is None:
|
||||
raise ValueError("Unknown content")
|
||||
fp = self._resolve(path)
|
||||
fp = self._resolve_write(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content, encoding="utf-8")
|
||||
self._file_states.record_write(fp)
|
||||
return f"Successfully wrote {len(content)} characters to {fp}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error writing file: {e}"
|
||||
return ToolResult.error(f"Error writing file: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -552,6 +594,15 @@ class _MatchSpan:
|
||||
line: int
|
||||
|
||||
|
||||
def _match_end_line(match: _MatchSpan) -> int:
|
||||
comparable = match.text[:-1] if match.text.endswith("\n") else match.text
|
||||
return match.line + comparable.count("\n")
|
||||
|
||||
|
||||
def _match_covers_line(match: _MatchSpan, line: int) -> bool:
|
||||
return match.line <= line <= _match_end_line(match)
|
||||
|
||||
|
||||
def _find_exact_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
||||
matches: list[_MatchSpan] = []
|
||||
start = 0
|
||||
@@ -725,7 +776,10 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
||||
),
|
||||
line_hint=IntegerSchema(
|
||||
1,
|
||||
description="Optional 1-based line hint used to choose the nearest match.",
|
||||
description=(
|
||||
"Optional exact 1-based target line copied from read_file. "
|
||||
"The selected old_text match must cover this line."
|
||||
),
|
||||
minimum=1,
|
||||
nullable=True,
|
||||
),
|
||||
@@ -757,8 +811,9 @@ class EditFileTool(_FsTool):
|
||||
"with old_text copied from read_file. For multi-file, structural, "
|
||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||
"multiple times, provide more context or set occurrence, line_hint, "
|
||||
"replace_all, and expected_replacements. Shows closest-match "
|
||||
"diagnostics on failure."
|
||||
"replace_all, and expected_replacements. When editing from numbered "
|
||||
"read_file output, set line_hint to the exact target line. "
|
||||
"Shows closest-match diagnostics on failure."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -780,13 +835,13 @@ class EditFileTool(_FsTool):
|
||||
if new_text is None:
|
||||
raise ValueError("Unknown new_text")
|
||||
if occurrence is not None and occurrence < 1:
|
||||
return "Error: occurrence must be >= 1."
|
||||
return ToolResult.error("Error: occurrence must be >= 1.")
|
||||
if line_hint is not None and line_hint < 1:
|
||||
return "Error: line_hint must be >= 1."
|
||||
return ToolResult.error("Error: line_hint must be >= 1.")
|
||||
if expected_replacements is not None and expected_replacements < 1:
|
||||
return "Error: expected_replacements must be >= 1."
|
||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||
|
||||
fp = self._resolve(path)
|
||||
fp = self._resolve_write(path)
|
||||
|
||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||
if not fp.exists():
|
||||
@@ -803,14 +858,14 @@ class EditFileTool(_FsTool):
|
||||
except OSError:
|
||||
fsize = 0
|
||||
if fsize > self._MAX_EDIT_FILE_SIZE:
|
||||
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
|
||||
return ToolResult.error(f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB.")
|
||||
|
||||
# Create-file: old_text='' but file exists and not empty → reject
|
||||
if old_text == "":
|
||||
raw = fp.read_bytes()
|
||||
content = raw.decode("utf-8")
|
||||
if content.strip():
|
||||
return f"Error: Cannot create file — {path} already exists and is not empty."
|
||||
return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
|
||||
fp.write_text(new_text, encoding="utf-8")
|
||||
self._file_states.record_write(fp)
|
||||
return f"Successfully edited {fp}"
|
||||
@@ -828,41 +883,26 @@ class EditFileTool(_FsTool):
|
||||
return self._not_found_msg(old_text, content, path)
|
||||
count = len(matches)
|
||||
if replace_all and occurrence is not None:
|
||||
return "Error: occurrence cannot be used with replace_all=true."
|
||||
return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
|
||||
if replace_all and line_hint is not None:
|
||||
return "Error: line_hint cannot be used with replace_all=true."
|
||||
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
|
||||
if occurrence is not None and line_hint is not None:
|
||||
return "Error: line_hint cannot be used with occurrence."
|
||||
if count > 1 and not replace_all:
|
||||
if occurrence is not None:
|
||||
if occurrence > count:
|
||||
return (
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} times."
|
||||
)
|
||||
elif line_hint is not None:
|
||||
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
||||
distance = abs(nearest.line - line_hint)
|
||||
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
||||
return (
|
||||
f"Error: line_hint {line_hint} is ambiguous; "
|
||||
f"old_text appears {count} times."
|
||||
)
|
||||
else:
|
||||
line_numbers = [match.line for match in matches]
|
||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
||||
if len(line_numbers) > 3:
|
||||
preview += ", ..."
|
||||
location_hint = f" at {preview}" if preview else ""
|
||||
return (
|
||||
f"Warning: old_text appears {count} times{location_hint}. "
|
||||
"Provide more context, set occurrence to choose one match, "
|
||||
"or set replace_all=true."
|
||||
)
|
||||
elif occurrence is not None and occurrence > count:
|
||||
return (
|
||||
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
|
||||
if occurrence is not None and occurrence > count:
|
||||
return ToolResult.error(
|
||||
f"Error: occurrence {occurrence} is out of range; "
|
||||
f"old_text appears {count} time."
|
||||
f"old_text appears {count} time(s)."
|
||||
)
|
||||
if count > 1 and not replace_all and occurrence is None and line_hint is None:
|
||||
line_numbers = [match.line for match in matches]
|
||||
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
|
||||
if len(line_numbers) > 3:
|
||||
preview += ", ..."
|
||||
location_hint = f" at {preview}" if preview else ""
|
||||
return (
|
||||
f"Warning: old_text appears {count} times{location_hint}. "
|
||||
"Provide more context, set occurrence to choose one match, "
|
||||
"or set replace_all=true."
|
||||
)
|
||||
|
||||
norm_new = new_text.replace("\r\n", "\n")
|
||||
@@ -873,12 +913,29 @@ class EditFileTool(_FsTool):
|
||||
|
||||
if replace_all:
|
||||
selected = matches
|
||||
elif occurrence is not None:
|
||||
selected = [matches[occurrence - 1]]
|
||||
elif line_hint is not None:
|
||||
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
|
||||
candidates = [match for match in matches if _match_covers_line(match, line_hint)]
|
||||
if not candidates:
|
||||
locations = ", ".join(f"line {match.line}" for match in matches[:3])
|
||||
if len(matches) > 3:
|
||||
locations += ", ..."
|
||||
return ToolResult.error(
|
||||
f"Error: line_hint {line_hint} does not match the old_text location. "
|
||||
f"old_text appears at {locations}. Re-read the intended region and "
|
||||
"copy old_text that covers the target line."
|
||||
)
|
||||
if len(candidates) > 1:
|
||||
return ToolResult.error(
|
||||
f"Error: line_hint {line_hint} is ambiguous; "
|
||||
f"old_text appears {len(candidates)} times on that line."
|
||||
)
|
||||
selected = candidates
|
||||
else:
|
||||
selected = [matches[occurrence - 1 if occurrence else 0]]
|
||||
selected = [matches[0]]
|
||||
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: expected {expected_replacements} replacements but "
|
||||
f"would make {len(selected)}."
|
||||
)
|
||||
@@ -904,9 +961,9 @@ class EditFileTool(_FsTool):
|
||||
msg = f"{warning}\n{msg}"
|
||||
return msg
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error editing file: {e}"
|
||||
return ToolResult.error(f"Error editing file: {e}")
|
||||
|
||||
def _file_not_found_msg(self, path: str, fp: Path) -> str:
|
||||
"""Build an error message with 'Did you mean ...?' suggestions."""
|
||||
@@ -919,7 +976,7 @@ class EditFileTool(_FsTool):
|
||||
parts = [f"Error: File not found: {path}"]
|
||||
if suggestions:
|
||||
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
|
||||
return "\n".join(parts)
|
||||
return ToolResult.error("\n".join(parts))
|
||||
|
||||
@staticmethod
|
||||
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
||||
@@ -935,18 +992,18 @@ class EditFileTool(_FsTool):
|
||||
hint_text = ""
|
||||
if hints:
|
||||
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: old_text not found in {path}."
|
||||
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||
)
|
||||
|
||||
if hints:
|
||||
return (
|
||||
return ToolResult.error(
|
||||
f"Error: old_text not found in {path}. "
|
||||
f"Possible cause: {', '.join(hints)}. "
|
||||
"Copy the exact text from read_file and try again."
|
||||
)
|
||||
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||
return ToolResult.error(f"Error: old_text not found in {path}. No similar text found. Verify the file content.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1001,9 +1058,9 @@ class ListDirTool(_FsTool):
|
||||
raise ValueError("Unknown path")
|
||||
dp = self._resolve(path)
|
||||
if not dp.exists():
|
||||
return f"Error: Directory not found: {path}"
|
||||
return ToolResult.error(f"Error: Directory not found: {path}")
|
||||
if not dp.is_dir():
|
||||
return f"Error: Not a directory: {path}"
|
||||
return ToolResult.error(f"Error: Not a directory: {path}")
|
||||
|
||||
cap = max_entries or self._DEFAULT_MAX
|
||||
items: list[str] = []
|
||||
@@ -1034,6 +1091,6 @@ class ListDirTool(_FsTool):
|
||||
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
|
||||
return result
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
return ToolResult.error(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return f"Error listing directory: {e}"
|
||||
return ToolResult.error(f"Error listing directory: {e}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user