Compare commits
724
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5569c99b8e | ||
|
|
d90c3b4a24 | ||
|
|
ee0b25e29a | ||
|
|
a3fe901886 | ||
|
|
153b08f872 | ||
|
|
1b920d7299 | ||
|
|
4b3c42ad5c | ||
|
|
0de186071b | ||
|
|
7bcd6c5349 | ||
|
|
08b399a450 | ||
|
|
97d5bd3c4d | ||
|
|
a8f408b3b0 | ||
|
|
0bdb762832 | ||
|
|
d49e009b12 | ||
|
|
7dc400c05c | ||
|
|
65aca4d260 | ||
|
|
34584c3a2e | ||
|
|
53e09b924c | ||
|
|
1b302ab4bf | ||
|
|
3c681f1639 | ||
|
|
1ff3356d1b | ||
|
|
5193e34803 | ||
|
|
8f8fc81135 | ||
|
|
d4abb3d06f | ||
|
|
b2570f1a62 | ||
|
|
f19b5f5929 | ||
|
|
8e829396b2 | ||
|
|
e8e8ca6700 | ||
|
|
f1cbd4d730 | ||
|
|
f7cebfe7f3 | ||
|
|
b854d9a888 | ||
|
|
83d2acf07f | ||
|
|
eee9c38953 | ||
|
|
e782318338 | ||
|
|
dc94aa76cc | ||
|
|
5cf019c21e | ||
|
|
790bdd6b8a | ||
|
|
b25c09f5ed | ||
|
|
9e8c910ab1 | ||
|
|
cc10e20a47 | ||
|
|
34ed4345fc | ||
|
|
1a85333e4c | ||
|
|
3c587c788a | ||
|
|
303d123527 | ||
|
|
61c2cb4ac4 | ||
|
|
3126b99fdb | ||
|
|
b28b647ce3 | ||
|
|
d736f1cf46 | ||
|
|
e4402f2f83 | ||
|
|
dc5d8edfec | ||
|
|
bdc3be650b | ||
|
|
119de1f347 | ||
|
|
88f6ecea5a | ||
|
|
a0eb6e9dcf | ||
|
|
c987976f82 | ||
|
|
8116848670 | ||
|
|
f6412b8349 | ||
|
|
f1023d9573 | ||
|
|
39560524f7 | ||
|
|
aec8510d49 | ||
|
|
9dd9c0a4be | ||
|
|
53391762be | ||
|
|
d2487ec6a3 | ||
|
|
8e7c6db4b5 | ||
|
|
a16020c4a2 | ||
|
|
83d6e3cd65 | ||
|
|
80e56294f6 | ||
|
|
a1823004aa | ||
|
|
54255c89c4 | ||
|
|
9a7596193f | ||
|
|
31a889f9fd | ||
|
|
6c0d68cfbb | ||
|
|
5234e76c40 | ||
|
|
f048e8cbec | ||
|
|
9e8d2d4a09 | ||
|
|
aa4393b2eb | ||
|
|
9fcb1dc4c0 | ||
|
|
c5f78bf17e | ||
|
|
6003777eda | ||
|
|
bbfb4a0c2c | ||
|
|
b44c94c9cf | ||
|
|
0e16f1bc3d | ||
|
|
83fc393359 | ||
|
|
2e63a09150 | ||
|
|
16791c9717 | ||
|
|
a770d8b0f9 | ||
|
|
f148ffd7a4 | ||
|
|
03d3e3a4da | ||
|
|
6c13a7e722 | ||
|
|
6adefbf190 | ||
|
|
45a377030f | ||
|
|
6165f523c3 | ||
|
|
3c760be0b2 | ||
|
|
b322cd66f2 | ||
|
|
9bb9cd0d55 | ||
|
|
ae1dc44705 | ||
|
|
548179ed3b | ||
|
|
88828112b5 | ||
|
|
e9a221bdd9 | ||
|
|
003c8dbf59 | ||
|
|
51b5e02948 | ||
|
|
97ad118615 | ||
|
|
5d7d526ca0 | ||
|
|
d987d04606 | ||
|
|
e99a47608d | ||
|
|
d584c11b6f | ||
|
|
5de628434c | ||
|
|
fd8365992d | ||
|
|
0fb9504920 | ||
|
|
6e868cb712 | ||
|
|
0ca40f1929 | ||
|
|
195483c65f | ||
|
|
e140b850b4 | ||
|
|
b4c6c4e5ef | ||
|
|
b0e2033ded | ||
|
|
d095bd3cb8 | ||
|
|
5ef45c4345 | ||
|
|
215637a93c | ||
|
|
c6b68f0b6b | ||
|
|
5317bf869b | ||
|
|
0ea1af4ebf | ||
|
|
ca56d15dc9 | ||
|
|
51f38af9eb | ||
|
|
dbd4786b49 | ||
|
|
a2788023a1 | ||
|
|
ba5863f34c | ||
|
|
513582720a | ||
|
|
8e7e94e424 | ||
|
|
31eae748f6 | ||
|
|
ad2d5d2e8f | ||
|
|
a27220dbd0 | ||
|
|
326f18f8a8 | ||
|
|
6f2ff279ae | ||
|
|
41a3366f3e | ||
|
|
5eba972737 | ||
|
|
dafaa3bab4 | ||
|
|
2059acb3a4 | ||
|
|
a8a075600e | ||
|
|
471fd08fba | ||
|
|
1d30c3f6ce | ||
|
|
f959185bca | ||
|
|
1381735e3b | ||
|
|
6612576f8f | ||
|
|
727ffa2943 | ||
|
|
8dc66c713a | ||
|
|
ca8376c4a6 | ||
|
|
e1987c7fa5 | ||
|
|
a267110ce3 | ||
|
|
c00979a3b8 | ||
|
|
171c18eb5a | ||
|
|
5924017c39 | ||
|
|
c34dd1c90f | ||
|
|
891b85403d | ||
|
|
ecf1029b08 | ||
|
|
45294c9cc6 | ||
|
|
c0d7107c07 | ||
|
|
4bec87c1e7 | ||
|
|
422cccf091 | ||
|
|
1579b6e052 | ||
|
|
9e3f4ad5cc | ||
|
|
001cec88c6 | ||
|
|
9854edf113 | ||
|
|
3b08980b9d | ||
|
|
75e840e831 | ||
|
|
95982b62ef | ||
|
|
6bf81753cc | ||
|
|
18482c72e4 | ||
|
|
19a81e1b05 | ||
|
|
73539ef1b8 | ||
|
|
47954fe260 | ||
|
|
38a1c7f838 | ||
|
|
bd03c151da | ||
|
|
8bbe412848 | ||
|
|
d26582c5dc | ||
|
|
b62de7799c | ||
|
|
82ca557b47 | ||
|
|
7fd1017a53 | ||
|
|
c4e78f4d80 | ||
|
|
05f4464935 | ||
|
|
ca9da38a92 | ||
|
|
a541817054 | ||
|
|
37e478a3e2 | ||
|
|
4e94e0a422 | ||
|
|
2d8a5de7b9 | ||
|
|
d6aaff511e | ||
|
|
fe74da53e2 | ||
|
|
dfb81b45a7 | ||
|
|
22629634c9 | ||
|
|
b507630f3b | ||
|
|
68a9b7ad7d | ||
|
|
536ede12de | ||
|
|
6fed0e5e2d | ||
|
|
283a1fbefd | ||
|
|
5b1bc3a47d | ||
|
|
aaf4de23cc | ||
|
|
f639364d7f | ||
|
|
4b0fb7bdbe | ||
|
|
5111c69ff9 | ||
|
|
12e1470506 | ||
|
|
8cb5dd28e6 | ||
|
|
44dc549f75 | ||
|
|
ee2bf70f42 | ||
|
|
a4d95fd064 | ||
|
|
1fe94898f6 | ||
|
|
ef09add825 | ||
|
|
db4185c8b7 | ||
|
|
e86cfcde22 | ||
|
|
fdd2c25aed | ||
|
|
bc558d0592 | ||
|
|
6bdb590028 | ||
|
|
a6aa5fbd7c | ||
|
|
12f3365103 | ||
|
|
2d33371366 | ||
|
|
858a62dd9b | ||
|
|
21e9644944 | ||
|
|
d5808bf586 | ||
|
|
e260219ce6 | ||
|
|
b7561848e1 | ||
|
|
969b15dbce | ||
|
|
32ecfd32f3 | ||
|
|
aa2987be3e | ||
|
|
568a54ae3e | ||
|
|
a3e0543eae | ||
|
|
aa774733ea | ||
|
|
6641bad337 | ||
|
|
cab901b2fb | ||
|
|
b24df8afeb | ||
|
|
ec8dee802c | ||
|
|
cb999ae826 | ||
|
|
c3a0c7c9eb | ||
|
|
29e6709e26 | ||
|
|
ac1c40db91 | ||
|
|
7a3788fee9 | ||
|
|
286e67ddef | ||
|
|
45ae410f05 | ||
|
|
cc425102ac | ||
|
|
a1e930d942 | ||
|
|
988a85d8de | ||
|
|
84f2f3c316 | ||
|
|
a77add9d8c | ||
|
|
a1440cf4cb | ||
|
|
0a9bb1d8df | ||
|
|
4eb44cfb5c | ||
|
|
3902e31165 | ||
|
|
23b9880478 | ||
|
|
7e1a08d33c | ||
|
|
cffba8d0be | ||
|
|
65477e4bf3 | ||
|
|
39ab89cbd1 | ||
|
|
cdbede2fa8 | ||
|
|
fafd8d4eb8 | ||
|
|
149f26af32 | ||
|
|
becb0a4b87 | ||
|
|
d55a850357 | ||
|
|
b19c729eee | ||
|
|
3f41e39c8d | ||
|
|
9eca7f339e | ||
|
|
e1a2ef4f29 | ||
|
|
19a5efa89e | ||
|
|
f2e0847d64 | ||
|
|
6aed4265b7 | ||
|
|
4768b9a09d | ||
|
|
2466b8b843 | ||
|
|
3c12efa728 | ||
|
|
a50a2c6868 | ||
|
|
e959b13926 | ||
|
|
9e806d7159 | ||
|
|
8fffee124b | ||
|
|
87a2084ee2 | ||
|
|
a3963bfba3 | ||
|
|
637c200dee | ||
|
|
17de3699ab | ||
|
|
abc7b0aeb2 | ||
|
|
96e1730af5 | ||
|
|
a3f7cce416 | ||
|
|
f223a4c5a3 | ||
|
|
f294e9d065 | ||
|
|
56b9b33c6d | ||
|
|
a818fff8fa | ||
|
|
a54b0853f0 | ||
|
|
4b9ffea3fc | ||
|
|
81b669b36e | ||
|
|
8686f060d9 | ||
|
|
07ae82583b | ||
|
|
0e4dba8d19 | ||
|
|
e080902d61 | ||
|
|
7be278517e | ||
|
|
f828a1d5d1 | ||
|
|
e4888d39f7 | ||
|
|
c6b933df4a | ||
|
|
f514ba02e9 | ||
|
|
04218276ab | ||
|
|
cd5a8ac03d | ||
|
|
d546cbac6e | ||
|
|
b9eb9d4963 | ||
|
|
abd35b1295 | ||
|
|
cda3a02f68 | ||
|
|
fdf24e8fd2 | ||
|
|
8d1eec114a | ||
|
|
ec55f77912 | ||
|
|
ef57225974 | ||
|
|
4f8033627e | ||
|
|
abcce1e1db | ||
|
|
91e13d91ac | ||
|
|
8de2f8d588 | ||
|
|
eeaad6e0c2 | ||
|
|
30361c9307 | ||
|
|
f8dc6fafa9 | ||
|
|
3eeac4e8f8 | ||
|
|
2f573e591b | ||
|
|
35e3f7ed26 | ||
|
|
c76a8d2e83 | ||
|
|
3c2cc3a71c | ||
|
|
f4b3bbd87c | ||
|
|
eae6059889 | ||
|
|
6f4d1c2cdc | ||
|
|
54e350a496 | ||
|
|
7671239902 | ||
|
|
2c09f23c02 | ||
|
|
2b983c708d | ||
|
|
0be70b05b1 | ||
|
|
ea1c4ef025 | ||
|
|
1f7a81e5ee | ||
|
|
b2a1d1208e | ||
|
|
d9462284e1 | ||
|
|
491739223d | ||
|
|
e69ff8ac0e | ||
|
|
577b3d104a | ||
|
|
f8e8cbee6a | ||
|
|
e4376896ed | ||
|
|
0fdbd5a037 | ||
|
|
df2c837e25 | ||
|
|
c20b867497 | ||
|
|
bfdae1b177 | ||
|
|
bc32e85c25 | ||
|
|
9025c7088f | ||
|
|
31a873ca59 | ||
|
|
0c412b3728 | ||
|
|
4303026e0d | ||
|
|
25f0a236fd | ||
|
|
c6f670809c | ||
|
|
b653183bb0 | ||
|
|
2f7835a301 | ||
|
|
6913d541c8 | ||
|
|
efe89c9091 | ||
|
|
3d55c9cd03 | ||
|
|
4f0930f517 | ||
|
|
c8881c5d49 | ||
|
|
e46edf2806 | ||
|
|
437ebf4e6e | ||
|
|
51f6247aed | ||
|
|
14ba50c172 | ||
|
|
1aa06ea03d | ||
|
|
12af652d5a | ||
|
|
e322f82f9c | ||
|
|
b53c3d39ed | ||
|
|
9efe95970e | ||
|
|
b13d7f853e | ||
|
|
d5e820df98 | ||
|
|
1cfcc647b7 | ||
|
|
60751909cb | ||
|
|
4e8c8cc227 | ||
|
|
d82c292c99 | ||
|
|
598f7dafd1 | ||
|
|
71de1899e6 | ||
|
|
b8a06f8d19 | ||
|
|
b161628ad7 | ||
|
|
b93b77a485 | ||
|
|
c53deecdb1 | ||
|
|
ef64739736 | ||
|
|
e0743d6345 | ||
|
|
0d3a2963d0 | ||
|
|
973061b01e | ||
|
|
fff6207c6b | ||
|
|
1532f11b45 | ||
|
|
b323087631 | ||
|
|
3e40600483 | ||
|
|
494fa8966a | ||
|
|
de5104ab2a | ||
|
|
8c55b40b9f | ||
|
|
ba66c64750 | ||
|
|
54a0f3d038 | ||
|
|
ef96619039 | ||
|
|
5c9cb3a208 | ||
|
|
de63c31d43 | ||
|
|
0040c62b74 | ||
|
|
13d768cd93 | ||
|
|
6a9152f0c4 | ||
|
|
9b4273f6a4 | ||
|
|
deae84482d | ||
|
|
6b7d7e2eb8 | ||
|
|
edc671a8a3 | ||
|
|
83ccdf6186 | ||
|
|
01c835aac2 | ||
|
|
88ca2e0530 | ||
|
|
af71ccf051 | ||
|
|
b3acd19c7b | ||
|
|
9c61e1389c | ||
|
|
ec4bdb651f | ||
|
|
f89f8a972c | ||
|
|
0b30f514b4 | ||
|
|
012a5e78e5 | ||
|
|
4dca2872bf | ||
|
|
ab026c5131 | ||
|
|
668dd6e2f5 | ||
|
|
8c15454379 | ||
|
|
6076f98527 | ||
|
|
aeb07d3450 | ||
|
|
a0820eceee | ||
|
|
8bb849470b | ||
|
|
c4bee640b8 | ||
|
|
900604e9ca | ||
|
|
4f5cb7d1e4 | ||
|
|
09a45f8993 | ||
|
|
e0edb904bd | ||
|
|
7bc77c1b41 | ||
|
|
6f266f1a8a | ||
|
|
8125d9b6bc | ||
|
|
b9c3f8a5a3 | ||
|
|
98ef57e370 | ||
|
|
33396a522a | ||
|
|
2502f68fd8 | ||
|
|
fcece3ec62 | ||
|
|
13561772ad | ||
|
|
dd61a9143a | ||
|
|
52d086d46a | ||
|
|
e8a4671565 | ||
|
|
36d650e475 | ||
|
|
334078e242 | ||
|
|
705d5738e3 | ||
|
|
6a40665753 | ||
|
|
d4d87bb4e5 | ||
|
|
a28ae51ce9 | ||
|
|
97cb85ee0b | ||
|
|
bfd2018095 | ||
|
|
10de3bf329 | ||
|
|
1103f000fc | ||
|
|
9b06f682c3 | ||
|
|
566ad1dfc7 | ||
|
|
085a311d4b | ||
|
|
8b3171ca2b | ||
|
|
ca66ddb0bf | ||
|
|
a482a89df6 | ||
|
|
7b2adf9d9d | ||
|
|
6be7368a38 | ||
|
|
9b14869cb1 | ||
|
|
cc5cfe6847 | ||
|
|
fa2049fc60 | ||
|
|
3200135f4b | ||
|
|
e716c9caac | ||
|
|
840ef7363f | ||
|
|
45267b0730 | ||
|
|
ffac42f9e5 | ||
|
|
b294a682a8 | ||
|
|
b721f9f37d | ||
|
|
9d85393226 | ||
|
|
7c33d3cbe2 | ||
|
|
9a31571b6d | ||
|
|
988b75624c | ||
|
|
c926569033 | ||
|
|
d3ddeb3067 | ||
|
|
21dd9e4112 | ||
|
|
7279ff0167 | ||
|
|
f8ffff98a5 | ||
|
|
80b5e6cea0 | ||
|
|
5ba3ee97a4 | ||
|
|
132807a3fb | ||
|
|
c8682512c9 | ||
|
|
b6610721f9 | ||
|
|
d9cc144575 | ||
|
|
40867bff86 | ||
|
|
5110b070dd | ||
|
|
b853222c87 | ||
|
|
9643b477da | ||
|
|
44c2de2283 | ||
|
|
a33cb3e2dc | ||
|
|
ff0003de3f | ||
|
|
6bcfbd9610 | ||
|
|
fe089abe5b | ||
|
|
1d41dcd99a | ||
|
|
cc04bc4dd1 | ||
|
|
b286457c85 | ||
|
|
4cbd857250 | ||
|
|
f19baa8fc4 | ||
|
|
426ef71ce7 | ||
|
|
73530d51ac | ||
|
|
4c75e1673f | ||
|
|
37222f9c0a | ||
|
|
45f33853cf | ||
|
|
df022febaf | ||
|
|
c1b5e8c8d2 | ||
|
|
8cc54b188d | ||
|
|
44f44b305a | ||
|
|
4eb07c44b9 | ||
|
|
ddae3e9d5f | ||
|
|
9ada8e6854 | ||
|
|
5f9eca4664 | ||
|
|
755e424127 | ||
|
|
c8089021a5 | ||
|
|
5cc019bf1a | ||
|
|
0c2fea6d33 | ||
|
|
ddf7f92275 | ||
|
|
8db91f59e2 | ||
|
|
b73e847e89 | ||
|
|
cd0a5affd5 | ||
|
|
e1854c4373 | ||
|
|
e39bbaa9be | ||
|
|
792f80ce0c | ||
|
|
b97b1a5e91 | ||
|
|
0b34a43779 | ||
|
|
698b09b4e7 | ||
|
|
44eb1bdca2 | ||
|
|
9f0928fde6 | ||
|
|
f5fe74f578 | ||
|
|
bbd76e8f5b | ||
|
|
d609eba7d6 | ||
|
|
25efd1bc54 | ||
|
|
82a318759f | ||
|
|
72a622aea1 | ||
|
|
2f315ec567 | ||
|
|
7957f84e3d | ||
|
|
7d7c1e3edf | ||
|
|
686471bd8d | ||
|
|
ef0eef9f74 | ||
|
|
2383dcb3a8 | ||
|
|
0660d614f6 | ||
|
|
5855d92619 | ||
|
|
9ffae47c13 | ||
|
|
afa0513243 | ||
|
|
72f449e868 | ||
|
|
002de466d7 | ||
|
|
a9bffdc06f | ||
|
|
a79e56a44d | ||
|
|
2b8c082428 | ||
|
|
5d7a27ebf2 | ||
|
|
e17342ddfc | ||
|
|
55ac4b729e | ||
|
|
ae0347042b | ||
|
|
73fdd0dd45 | ||
|
|
4c2f64db14 | ||
|
|
37252a4226 | ||
|
|
0bde1d89fa | ||
|
|
0e6683ad4b | ||
|
|
b26a2e1af1 | ||
|
|
0d3dc57a65 | ||
|
|
0001f286b5 | ||
|
|
f3c7337356 | ||
|
|
afca0278ad | ||
|
|
53b83a38e2 | ||
|
|
d22929305f | ||
|
|
fbfb030a6e | ||
|
|
1c51fbeeee | ||
|
|
c1296746e3 | ||
|
|
fe7b0b64c1 | ||
|
|
125524f5c2 | ||
|
|
b11f0ce6a9 | ||
|
|
d78368bb2f | ||
|
|
9a00a274e5 | ||
|
|
3890f1a7dd | ||
|
|
eea4942025 | ||
|
|
d748e6eca3 | ||
|
|
3b4763b3f9 | ||
|
|
c86dbc9f45 | ||
|
|
1b49bf9602 | ||
|
|
d08c022255 | ||
|
|
9789307dd6 | ||
|
|
523b2982f4 | ||
|
|
124c611426 | ||
|
|
a2379a08ac | ||
|
|
c7b5dd9350 | ||
|
|
464352c664 | ||
|
|
33d760d312 | ||
|
|
107a380e61 | ||
|
|
4367038a95 | ||
|
|
536ed60a05 | ||
|
|
3ac5513004 | ||
|
|
c865b293a9 | ||
|
|
1663517998 | ||
|
|
4a85cd9a11 | ||
|
|
c5b4331e69 | ||
|
|
8de36d398f | ||
|
|
1f1f5b2d27 | ||
|
|
b14d4711c0 | ||
|
|
92d279924f | ||
|
|
715b2db24b | ||
|
|
e44f14379a | ||
|
|
ce4f00529e | ||
|
|
27a131830f | ||
|
|
5c61f30546 | ||
|
|
4c577761e2 | ||
|
|
80a5a8c983 | ||
|
|
df09ba1232 | ||
|
|
7f8a3dfc0f | ||
|
|
d54831a35f | ||
|
|
8f6dd8708f | ||
|
|
74bec26698 | ||
|
|
e5e5f02e73 | ||
|
|
43590145ee | ||
|
|
95fead24e0 | ||
|
|
e2a0d63909 | ||
|
|
16127d49f9 | ||
|
|
b161fa4f9a | ||
|
|
72db01db63 | ||
|
|
831eb07945 | ||
|
|
05d06b1eb8 | ||
|
|
ed2aa7fe67 | ||
|
|
aad1df5b9b | ||
|
|
fae573573f | ||
|
|
090b8fb768 | ||
|
|
7d7d6bcadc | ||
|
|
711d03e8ac | ||
|
|
941c3d9826 | ||
|
|
4d4d629928 | ||
|
|
c03f2b670b | ||
|
|
8053193a36 | ||
|
|
5ad9c837df | ||
|
|
c81cc07032 | ||
|
|
79d15e6023 | ||
|
|
1db05c881d | ||
|
|
80d1ff69ad | ||
|
|
d89736a484 | ||
|
|
f5c5b13ff0 | ||
|
|
12e59ecaae | ||
|
|
d405dcb5a8 | ||
|
|
6bae6a617f | ||
|
|
2c3a568e46 | ||
|
|
cf4dce5df0 | ||
|
|
8509a81120 | ||
|
|
23726cb802 | ||
|
|
5735f9bdce | ||
|
|
56bc8b5677 | ||
|
|
778a93370a | ||
|
|
ae903e983c | ||
|
|
0a2a9a77b7 | ||
|
|
23b7e1ef5e | ||
|
|
96f63aee06 | ||
|
|
5033ac1759 | ||
|
|
a219a91bc5 | ||
|
|
1207b89adb | ||
|
|
b0871497e0 | ||
|
|
c9926153b2 | ||
|
|
ed5593bbe0 | ||
|
|
c28e6771a9 | ||
|
|
db0e8aa61b | ||
|
|
8f49b52079 | ||
|
|
48a14edbda | ||
|
|
3cdb8a0db2 | ||
|
|
ffbb264a5d | ||
|
|
ba923c0205 | ||
|
|
e8e7215d3e | ||
|
|
3706903978 | ||
|
|
1ce586e9f5 | ||
|
|
9e5f7348fe | ||
|
|
fe0341da5b | ||
|
|
5d683da38f | ||
|
|
90be900448 | ||
|
|
51d22b7ef4 | ||
|
|
40f4834f30 | ||
|
|
9bfc86af41 | ||
|
|
203aa154d4 | ||
|
|
a5265c263d | ||
|
|
7e2d801ffc | ||
|
|
82074a7715 | ||
|
|
69f80ec634 | ||
|
|
49fec3684a | ||
|
|
728874179c | ||
|
|
52cf1da30a | ||
|
|
54d5f637e7 | ||
|
|
e2ef1f9d48 | ||
|
|
fd480bb6f5 | ||
|
|
fbbbdc727d | ||
|
|
b523b277b0 | ||
|
|
3411035447 | ||
|
|
6e3f86714c | ||
|
|
66cd21e6ec | ||
|
|
5e082690d8 | ||
|
|
4e4eb21d23 | ||
|
|
d3f6c95ceb | ||
|
|
153c83e340 | ||
|
|
f821e95d3c | ||
|
|
155fc48b29 | ||
|
|
59d5e3cc4f | ||
|
|
2f2c55f921 | ||
|
|
9a83301ea6 | ||
|
|
d6d73c8167 | ||
|
|
3b580fd6c8 | ||
|
|
12540ba8cb | ||
|
|
835a10e1a9 | ||
|
|
10e9e0cdc9 | ||
|
|
bc045fae1f | ||
|
|
b76cf05c3a | ||
|
|
a3f4bb74ff | ||
|
|
bd55bf5278 | ||
|
|
a9d911c80d | ||
|
|
8a11490798 | ||
|
|
442136a313 | ||
|
|
1ae47058d9 | ||
|
|
09c7e7aded | ||
|
|
afc8d50659 | ||
|
|
98a762452a | ||
|
|
740294fd74 | ||
|
|
3e9f6d0b6b | ||
|
|
f016025f63 | ||
|
|
a3599b97b9 | ||
|
|
d30523f460 | ||
|
|
61e9f7f58a | ||
|
|
16af3dd1cb | ||
|
|
7052387f07 | ||
|
|
e89afe61f1 | ||
|
|
cb5964c201 | ||
|
|
c6915d27e9 | ||
|
|
51f97efcb8 | ||
|
|
fc67d11da9 | ||
|
|
ae908e0dcd | ||
|
|
08efe6ad3f | ||
|
|
c1dc8d3f55 | ||
|
|
6bca38b89d | ||
|
|
5bcfb550d5 | ||
|
|
42c2d83d70 | ||
|
|
b639192e46 | ||
|
|
f20afc8d2f | ||
|
|
01420f4dd6 | ||
|
|
d4e65319ee | ||
|
|
5bff24096c |
+2
-1
@@ -15,8 +15,9 @@ docs/
|
||||
*.pyzz
|
||||
.venv/
|
||||
venv/
|
||||
.worktrees/
|
||||
__pycache__/
|
||||
poetry.lock
|
||||
.pytest_cache/
|
||||
tests/
|
||||
botpy.log
|
||||
tests/
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ ENV PATH="/root/.local/bin:${PATH}"
|
||||
|
||||
COPY pyproject.toml README.md LICENSE /app/
|
||||
COPY nanobot/ /app/nanobot/
|
||||
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
|
||||
RUN uv pip install --system --no-cache --reinstall /app[mem0] psycopg2-binary
|
||||
|
||||
ENTRYPOINT ["nanobot"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -16,22 +16,40 @@
|
||||
|
||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
||||
|
||||
📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **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](#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-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-11** ✨ Enhanced CLI experience and added MiniMax support!
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- **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](#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-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-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>
|
||||
|
||||
## Key Features of nanobot:
|
||||
|
||||
🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot.
|
||||
@@ -107,17 +125,26 @@ nanobot onboard
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
|
||||
For OpenRouter - recommended for global users:
|
||||
Add or merge these **two parts** into your config (other options have defaults).
|
||||
|
||||
*Set your API key* (e.g. OpenRouter, recommended for global users):
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Set your model* (optionally pin a provider — defaults to auto-detection):
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"provider": "openrouter"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,63 +153,26 @@ For OpenRouter - recommended for global users:
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
nanobot agent -m "What is 2+2?"
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
That's it! You have a working AI assistant in 2 minutes.
|
||||
|
||||
## 🖥️ Local Models (vLLM)
|
||||
|
||||
Run nanobot with your own local models using vLLM or any OpenAI-compatible server.
|
||||
|
||||
**1. Start your vLLM server**
|
||||
|
||||
```bash
|
||||
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
|
||||
```
|
||||
|
||||
**2. Configure** (`~/.nanobot/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"apiKey": "dummy",
|
||||
"apiBase": "http://localhost:8000/v1"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat**
|
||||
|
||||
```bash
|
||||
nanobot agent -m "Hello from my local LLM!"
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> The `apiKey` can be any non-empty string for local servers that don't require authentication.
|
||||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
|
||||
Connect nanobot to your favorite chat platform.
|
||||
|
||||
| Channel | Setup |
|
||||
|---------|-------|
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **WhatsApp** | Medium (scan QR) |
|
||||
| **Feishu** | Medium (app credentials) |
|
||||
| **Mochat** | Medium (claw token + websocket) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **Slack** | Medium (bot + app tokens) |
|
||||
| **Email** | Medium (IMAP/SMTP credentials) |
|
||||
| **QQ** | Easy (app credentials) |
|
||||
| Channel | What you need |
|
||||
|---------|---------------|
|
||||
| **Telegram** | Bot token from @BotFather |
|
||||
| **Discord** | Bot token + Message Content intent |
|
||||
| **WhatsApp** | QR code scan |
|
||||
| **Feishu** | App ID + App Secret |
|
||||
| **Mochat** | Claw token (auto-setup available) |
|
||||
| **DingTalk** | App Key + App Secret |
|
||||
| **Slack** | Bot token + App-Level token |
|
||||
| **Email** | IMAP/SMTP credentials |
|
||||
| **QQ** | App ID + App Secret |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
@@ -319,6 +309,72 @@ nanobot gateway
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Matrix (Element)</b></summary>
|
||||
|
||||
Install Matrix dependencies first:
|
||||
|
||||
```bash
|
||||
pip install nanobot-ai[matrix]
|
||||
```
|
||||
|
||||
**1. Create/choose a Matrix account**
|
||||
|
||||
- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
|
||||
- Confirm you can log in with Element.
|
||||
|
||||
**2. Get credentials**
|
||||
|
||||
- You need:
|
||||
- `userId` (example: `@nanobot:matrix.org`)
|
||||
- `accessToken`
|
||||
- `deviceId` (recommended so sync tokens can be restored across restarts)
|
||||
- You can obtain these from your homeserver login API (`/_matrix/client/v3/login`) or from your client's advanced session settings.
|
||||
|
||||
**3. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"userId": "@nanobot:matrix.org",
|
||||
"accessToken": "syt_xxx",
|
||||
"deviceId": "NANOBOT01",
|
||||
"e2eeEnabled": true,
|
||||
"allowFrom": [],
|
||||
"groupPolicy": "open",
|
||||
"groupAllowFrom": [],
|
||||
"allowRoomMentions": false,
|
||||
"maxMediaBytes": 20971520
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Keep a persistent `matrix-store` and stable `deviceId` — encrypted session state is lost if these change across restarts.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `allowFrom` | User IDs allowed to interact. Empty = all senders. |
|
||||
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
|
||||
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
|
||||
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
|
||||
| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
|
||||
| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
|
||||
|
||||
|
||||
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WhatsApp</b></summary>
|
||||
|
||||
@@ -596,21 +652,119 @@ Config file: `~/.nanobot/config.json`
|
||||
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||
> - **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.
|
||||
> - **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.
|
||||
> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
|
||||
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
|
||||
| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
|
||||
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
|
||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||
| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
|
||||
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
|
||||
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
|
||||
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
```
|
||||
|
||||
> Docker users: use `docker run -it` for interactive OAuth login.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Custom Provider (Any OpenAI-compatible API)</b></summary>
|
||||
|
||||
Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "your-api-key",
|
||||
"apiBase": "https://api.your-provider.com/v1"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>vLLM (local / OpenAI-compatible)</b></summary>
|
||||
|
||||
Run your own model with vLLM or any OpenAI-compatible server, then add to config:
|
||||
|
||||
**1. Start the server** (example):
|
||||
```bash
|
||||
vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
|
||||
```
|
||||
|
||||
**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
|
||||
|
||||
*Provider (key can be any non-empty string for local):*
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"apiKey": "dummy",
|
||||
"apiBase": "http://localhost:8000/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Model:*
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Adding a New Provider (Developer Guide)</b></summary>
|
||||
@@ -657,13 +811,70 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
|
||||
</details>
|
||||
|
||||
|
||||
### MCP (Model Context Protocol)
|
||||
|
||||
> [!TIP]
|
||||
> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
|
||||
|
||||
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
|
||||
|
||||
Add MCP servers to your `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
|
||||
},
|
||||
"my-remote-mcp": {
|
||||
"url": "https://example.com/mcp/",
|
||||
"headers": {
|
||||
"Authorization": "Bearer xxxxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two transport modes are supported:
|
||||
|
||||
| Mode | Config | Example |
|
||||
|------|--------|---------|
|
||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
||||
|
||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"mcpServers": {
|
||||
"my-slow-server": {
|
||||
"url": "https://example.com/mcp/",
|
||||
"toolTimeout": 120
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
|
||||
|
||||
|
||||
### Security
|
||||
|
||||
> [!TIP]
|
||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
||||
|
||||
| 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.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
|
||||
|
||||
|
||||
@@ -678,6 +889,7 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
|
||||
| `nanobot agent --logs` | Show runtime logs during chat |
|
||||
| `nanobot gateway` | Start the gateway |
|
||||
| `nanobot status` | Show status |
|
||||
| `nanobot provider login openai-codex` | OAuth login for providers |
|
||||
| `nanobot channels login` | Link WhatsApp (scan QR) |
|
||||
| `nanobot channels status` | Show channel status |
|
||||
|
||||
@@ -700,12 +912,46 @@ nanobot cron remove <job_id>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||
|
||||
The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
|
||||
|
||||
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
|
||||
|
||||
```markdown
|
||||
## Periodic Tasks
|
||||
|
||||
- [ ] Check weather forecast and send a summary
|
||||
- [ ] Scan inbox for urgent emails
|
||||
```
|
||||
|
||||
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
|
||||
|
||||
> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
|
||||
|
||||
</details>
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
> [!TIP]
|
||||
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
|
||||
|
||||
Build and run nanobot in a container:
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||
vim ~/.nanobot/config.json # add API keys
|
||||
docker compose up -d nanobot-gateway # start gateway
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
|
||||
docker compose logs -f nanobot-gateway # view logs
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
@@ -725,6 +971,59 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m "Hello!"
|
||||
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot status
|
||||
```
|
||||
|
||||
## 🐧 Linux Service
|
||||
|
||||
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
|
||||
|
||||
**1. Find the nanobot binary path:**
|
||||
|
||||
```bash
|
||||
which nanobot # e.g. /home/user/.local/bin/nanobot
|
||||
```
|
||||
|
||||
**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:**
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now nanobot-gateway
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
|
||||
|
||||
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
|
||||
>
|
||||
> ```bash
|
||||
> loginctl enable-linger $USER
|
||||
> ```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
@@ -753,7 +1052,6 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
|
||||
|
||||
- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13)
|
||||
- [ ] **Multi-modal** — See and hear (images, voice, video)
|
||||
- [ ] **Long-term memory** — Never forget important context
|
||||
- [ ] **Better reasoning** — Multi-step planning and reflection
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
If you discover a security vulnerability in nanobot, please report it by:
|
||||
|
||||
1. **DO NOT** open a public GitHub issue
|
||||
2. Create a private security advisory on GitHub or contact the repository maintainers
|
||||
2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
|
||||
3. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
x-common-config: &common-config
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ~/.nanobot:/root/.nanobot
|
||||
|
||||
services:
|
||||
nanobot-gateway:
|
||||
container_name: nanobot-gateway
|
||||
<<: *common-config
|
||||
command: ["gateway"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 18790:18790
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
nanobot-cli:
|
||||
<<: *common-config
|
||||
profiles:
|
||||
- cli
|
||||
command: ["status"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -0,0 +1,265 @@
|
||||
# Design: Native Anthropic Tools Integration
|
||||
|
||||
**Goal**: Integrate Anthropic's native trained tools (bash_20250124, text_editor_20250728, computer_20251124) into nanobot to leverage model's trained behaviors instead of custom function tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Anthropic's native tools are version-coupled to model training. Unlike custom function tools (which the model learns via instruction-following at inference time), native tools have their behaviors baked into model weights during training. This provides more reliable tool execution.
|
||||
|
||||
**Key Insight**: The Anthropic API accepts BOTH tool formats in the same request:
|
||||
- Function tools: `{type: "function", function: {name, description, input_schema}}`
|
||||
- Native tools: `{type: "bash_20250124", name: "bash"}` (schema-less)
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Tool Addition Strategy
|
||||
|
||||
Add three native tool implementations from anthropic-quickstarts reference:
|
||||
- **BashTool20250124** - persistent bash session (replaces ExecTool)
|
||||
- **EditTool20250728** - file operations with view/create/str_replace/insert (replaces EditTool, possibly ReadFileTool/WriteFileTool)
|
||||
- **ComputerTool20251124** - VNC desktop control (new capability)
|
||||
|
||||
Location: `nanobot/agent/tools/anthropic/` (new subpackage)
|
||||
|
||||
Port from reference:
|
||||
- Base classes: `BaseAnthropicTool`, `ToolResult`, `CLIResult`, `ToolError`
|
||||
- Tool implementations with trained behaviors intact
|
||||
- Session management (_BashSession for bash tool)
|
||||
|
||||
### 2. Registry Changes
|
||||
|
||||
Make `ToolRegistry` format-agnostic via duck typing:
|
||||
|
||||
**Current**: Only calls `tool.to_schema()`, expects function format
|
||||
|
||||
**New**: Support both interfaces
|
||||
```python
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
definitions = []
|
||||
for tool in self._tools.values():
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
definitions.append(tool.to_params())
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
definitions.append(tool.to_schema())
|
||||
else:
|
||||
raise ValueError(f"Tool {tool.name} has no schema method")
|
||||
return definitions
|
||||
```
|
||||
|
||||
**Execution**: No changes needed - `execute()` already looks up by name and calls the tool. Native tools implement `__call__(**kwargs)` which works with existing dispatch.
|
||||
|
||||
**Result**: Registry becomes thin coordination layer, doesn't enforce specific base class.
|
||||
|
||||
### 3. Tool Implementations
|
||||
|
||||
#### BashTool20250124
|
||||
- Maintains persistent bash session via `_BashSession` class
|
||||
- Sentinel-based output reading for reliable command capture
|
||||
- Timeout handling (120s default)
|
||||
- Restart capability
|
||||
- Returns: `ToolResult(output=..., error=...)`
|
||||
|
||||
#### EditTool20250728
|
||||
- Commands: `view`, `create`, `str_replace`, `insert`
|
||||
- Path validation (absolute paths required)
|
||||
- `str_replace`: uniqueness checking before replacement
|
||||
- `insert`: line number validation
|
||||
- File history tracking for potential undo
|
||||
- Returns: `CLIResult(output=...)` with formatted snippets
|
||||
|
||||
#### ComputerTool20251124
|
||||
- VNC desktop interaction (keyboard, mouse, screenshots)
|
||||
- Actions: `key`, `type`, `mouse_move`, `left_click`, `right_click`, `double_click`, `screenshot`, etc.
|
||||
- Screenshot returns `ToolResult(base64_image=...)`
|
||||
- Coordinate scaling support
|
||||
- Connects to VNC at 172.17.0.1:5900 (Windows VM from code-server)
|
||||
|
||||
### 4. API Integration
|
||||
|
||||
Update `anthropic_oauth.py._convert_tools_to_anthropic()` to pass through both formats:
|
||||
|
||||
**Current**: Only converts `type: "function"` tools
|
||||
```python
|
||||
if tool.get("type") == "function":
|
||||
# convert to Anthropic format
|
||||
```
|
||||
|
||||
**New**: Pass through ALL formats
|
||||
```python
|
||||
def _convert_tools_to_anthropic(self, tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
# Convert function tool format
|
||||
func = tool["function"]
|
||||
anthropic_tools.append({
|
||||
"name": func["name"],
|
||||
"description": func.get("description", ""),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||
})
|
||||
else:
|
||||
# Pass through native tool format as-is
|
||||
# (bash_20250124, text_editor_20250728, computer_20251124)
|
||||
anthropic_tools.append(tool)
|
||||
|
||||
return anthropic_tools if anthropic_tools else None
|
||||
```
|
||||
|
||||
**Distinction**: Based on `type` field
|
||||
- `type == "function"` → function tool, needs conversion
|
||||
- `type == "bash_20250124"` (or other native type) → pass through as-is
|
||||
|
||||
### 5. Tool Result Handling
|
||||
|
||||
**Current**: Tools return plain strings
|
||||
|
||||
**New**: Native tools return `ToolResult` objects
|
||||
```python
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
```
|
||||
|
||||
**Agent loop changes** (`loop.py`): Handle both return types
|
||||
```python
|
||||
result = await self.tools.execute(tool_name, tool_input)
|
||||
|
||||
if isinstance(result, ToolResult):
|
||||
# Native tool result - build structured content
|
||||
tool_result_content = []
|
||||
if result.output:
|
||||
tool_result_content.append({"type": "text", "text": result.output})
|
||||
if result.error:
|
||||
tool_result_content.append({"type": "text", "text": f"Error: {result.error}"})
|
||||
if result.base64_image:
|
||||
# Image handling (see Section 6)
|
||||
pass
|
||||
if result.system:
|
||||
# System messages for next turn
|
||||
pass
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_result_content = [{"type": "text", "text": str(result)}]
|
||||
```
|
||||
|
||||
### 6. Image Handling Flow
|
||||
|
||||
**Goal**: Both model and user see screenshots from computer tool
|
||||
|
||||
**Implementation**: Track media across tool iteration loop
|
||||
|
||||
```python
|
||||
# At start of agent turn
|
||||
media_paths_for_turn: list[str] = []
|
||||
|
||||
# During tool execution
|
||||
if isinstance(result, ToolResult) and result.base64_image:
|
||||
# 1. Save to disk for user
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = media_dir / f"screenshot_{int(time.time())}.png"
|
||||
screenshot_path.write_bytes(base64.b64decode(result.base64_image))
|
||||
media_paths_for_turn.append(str(screenshot_path))
|
||||
|
||||
# 2. Include in tool_result for model to see
|
||||
tool_result_content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": result.base64_image
|
||||
}
|
||||
})
|
||||
|
||||
# After final LLM response
|
||||
await self.bus.publish(OutboundMessage(
|
||||
channel=inbound.channel,
|
||||
chat_id=inbound.chat_id,
|
||||
content=final_response,
|
||||
media=media_paths_for_turn # Include all screenshots
|
||||
))
|
||||
```
|
||||
|
||||
**Result**:
|
||||
- Model sees base64 in tool_result → analyzes and reasons about it
|
||||
- User receives file via Telegram's media sending (`_send_with_media()`)
|
||||
|
||||
### 7. Version Management & Beta Flags
|
||||
|
||||
**Problem**: Each native tool version requires specific API beta flag
|
||||
|
||||
**Solution**: Add beta flag tracking to native tools
|
||||
|
||||
Each native tool class specifies its required beta flag:
|
||||
```python
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
api_type = "bash_20250124"
|
||||
name = "bash"
|
||||
beta_flag = "computer-use-2025-11-24" # Required for API
|
||||
```
|
||||
|
||||
In `anthropic_oauth.py._make_request()`, collect beta flags:
|
||||
```python
|
||||
# Collect unique beta flags from native tools
|
||||
beta_flags = set()
|
||||
for tool in tools or []:
|
||||
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||
beta_flags.add(tool.beta_flag)
|
||||
|
||||
# Add to API request headers
|
||||
if beta_flags:
|
||||
headers["anthropic-beta"] = ",".join(sorted(beta_flags))
|
||||
```
|
||||
|
||||
**Note**: All three tools (bash, text_editor, computer) currently use the same beta flag: `"computer-use-2025-11-24"` as of the 2025-11-24 tool version.
|
||||
|
||||
### 8. Removing Overlapping Tools
|
||||
|
||||
Once native tools are implemented and tested, remove overlapping custom tools:
|
||||
|
||||
**To Remove**:
|
||||
- `ExecTool` → replaced by `BashTool20250124` (persistent session, better output)
|
||||
- `EditFileTool` → replaced by `EditTool20250728` (str_replace command)
|
||||
- Possibly `ReadFileTool`, `WriteFileTool` → `EditTool20250728` has `view` and `create` commands
|
||||
|
||||
**To Keep**:
|
||||
- `ListDirTool` → no native equivalent
|
||||
- `WebSearchTool`, `WebFetchTool` → no native equivalent
|
||||
- `MessageTool`, `SpawnTool`, `WaitForSubagentsTool` → nanobot-specific
|
||||
- `CronTool` → nanobot-specific
|
||||
|
||||
**Migration Notes**:
|
||||
- `EditTool20250728` only supports absolute paths (enforced in validation)
|
||||
- `BashTool20250124` maintains session state across calls (different from ExecTool's one-shot)
|
||||
- Test native tools thoroughly before removing custom ones
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Trained Behaviors**: Model knows how to use these tools from training, not instruction-following
|
||||
2. **Better Reliability**: Persistent bash sessions, validated file operations
|
||||
3. **New Capabilities**: Desktop interaction via computer tool
|
||||
4. **Future-Proof**: Easy to add more native tools as Anthropic releases them (just port implementation)
|
||||
5. **Unified System**: Both function tools and native tools work together in same request
|
||||
|
||||
## Trade-offs
|
||||
|
||||
1. **Code Duplication**: Porting reference implementations means maintaining separate codebase
|
||||
- Mitigation: Keep close to reference implementation for easier updates
|
||||
2. **Version Management**: Need to track tool versions and beta flags
|
||||
- Mitigation: Simple beta_flag attribute on tool classes
|
||||
3. **Testing Complexity**: Need to test both tool systems
|
||||
- Mitigation: Gradual rollout, keep custom tools until native tools proven
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. All three native tools execute successfully
|
||||
2. Model can use bash, edit, and computer tools in same conversation
|
||||
3. Screenshots from computer tool visible to both model and user
|
||||
4. No regression in existing functionality (other tools still work)
|
||||
5. Performance comparable to custom tools
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
nanobot - A lightweight AI agent framework
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.4.post2"
|
||||
__logo__ = "🐈"
|
||||
|
||||
+55
-21
@@ -6,8 +6,12 @@ import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.visibility import compute_signature
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
@@ -19,10 +23,21 @@ class ContextBuilder:
|
||||
"""
|
||||
|
||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
|
||||
def __init__(self, workspace: Path, mem0_config: dict[str, Any] | None = None):
|
||||
self.workspace = workspace
|
||||
self.memory = MemoryStore(workspace)
|
||||
|
||||
# Choose memory backend based on config
|
||||
if mem0_config and mem0_config.get("enabled") and HAS_MEM0:
|
||||
self.memory = Mem0MemoryStore(workspace, config=mem0_config)
|
||||
self.use_mem0 = True
|
||||
logger.info("ContextBuilder using mem0 for semantic memory")
|
||||
else:
|
||||
if mem0_config and mem0_config.get("enabled"):
|
||||
logger.warning("mem0 enabled but not installed, falling back to MEMORY.md")
|
||||
self.memory = MemoryStore(workspace)
|
||||
self.use_mem0 = False
|
||||
|
||||
self.skills = SkillsLoader(workspace)
|
||||
|
||||
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
|
||||
@@ -45,10 +60,14 @@ class ContextBuilder:
|
||||
if bootstrap:
|
||||
parts.append(bootstrap)
|
||||
|
||||
# Memory context
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory:
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
# Static knowledge context (KNOWLEDGE.md — manually curated, stable for caching)
|
||||
# MEMORY.md is excluded from system prompt as it changes frequently (consolidator),
|
||||
# but the agent can still read/grep it via tools.
|
||||
knowledge_file = self.memory.memory_dir / "KNOWLEDGE.md"
|
||||
if knowledge_file.exists():
|
||||
knowledge = knowledge_file.read_text(encoding="utf-8").strip()
|
||||
if knowledge:
|
||||
parts.append(f"# Knowledge\n\n{knowledge}")
|
||||
|
||||
# Skills - progressive loading
|
||||
# 1. Always-loaded skills: include full content
|
||||
@@ -72,10 +91,6 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
|
||||
def _get_identity(self) -> str:
|
||||
"""Get the core identity section with runtime context."""
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
workspace_path = str(self.workspace.expanduser().resolve())
|
||||
system = platform.system()
|
||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||
@@ -87,9 +102,6 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
- Send messages to users on chat channels
|
||||
- Spawn subagents for complex background tasks
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
## Runtime
|
||||
{runtime}
|
||||
|
||||
@@ -105,7 +117,14 @@ For normal conversation, just respond with text - do not call the message tool.
|
||||
|
||||
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
|
||||
When remembering something important, write to {workspace_path}/memory/MEMORY.md
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
To recall past events, grep {workspace_path}/memory/HISTORY.md
|
||||
|
||||
## Visibility Markers
|
||||
|
||||
Messages marked with [HIDDEN:{{signature}}] were not sent to the user. These markers
|
||||
are cryptographically signed by the system to track internal reasoning and background
|
||||
tasks. Do NOT generate [HIDDEN:*] patterns yourself - outputs containing forged
|
||||
visibility markers will be rejected."""
|
||||
|
||||
def _load_bootstrap_files(self) -> str:
|
||||
"""Load all bootstrap files from workspace."""
|
||||
@@ -148,6 +167,18 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
system_prompt = self.build_system_prompt(skill_names)
|
||||
if channel and chat_id:
|
||||
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
|
||||
|
||||
# Add mem0 semantic memory context (if enabled)
|
||||
if self.use_mem0 and channel and chat_id:
|
||||
user_id = f"{channel}_{chat_id}"
|
||||
memory_context = self.memory.get_memory_context(
|
||||
query=current_message,
|
||||
user_id=user_id,
|
||||
limit=5
|
||||
)
|
||||
if memory_context:
|
||||
system_prompt += f"\n\n{memory_context}"
|
||||
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# History
|
||||
@@ -196,12 +227,14 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
Returns:
|
||||
Updated message list.
|
||||
"""
|
||||
messages.append({
|
||||
msg: dict[str, Any] = {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"content": result
|
||||
})
|
||||
"content": result,
|
||||
"_hidden_sig": compute_signature(result if isinstance(result, str) else ""),
|
||||
}
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
def add_assistant_message(
|
||||
@@ -224,13 +257,14 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
|
||||
Updated message list.
|
||||
"""
|
||||
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
|
||||
|
||||
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = tool_calls
|
||||
|
||||
msg["_hidden_sig"] = compute_signature(content or "")
|
||||
|
||||
# Thinking models reject history without this
|
||||
if reasoning_content:
|
||||
msg["reasoning_content"] = reasoning_content
|
||||
|
||||
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
+547
-79
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -18,16 +19,19 @@ from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.visibility import sign_content, has_forged_marker, strip_all_hidden_markers
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
class AgentLoop:
|
||||
"""
|
||||
The agent loop is the core processing engine.
|
||||
|
||||
|
||||
It:
|
||||
1. Receives messages from the bus
|
||||
2. Builds context with history, memory, skills
|
||||
@@ -35,6 +39,26 @@ class AgentLoop:
|
||||
4. Executes tool calls
|
||||
5. Sends responses back
|
||||
"""
|
||||
|
||||
# Server-side context management: Anthropic preserves all thinking blocks
|
||||
# and clears old tool results only when approaching the 200k context limit.
|
||||
CONTEXT_MANAGEMENT = {
|
||||
"edits": [
|
||||
{
|
||||
"type": "clear_thinking_20251015",
|
||||
"keep": "all", # Preserve all thinking blocks for cache reuse
|
||||
},
|
||||
{
|
||||
"type": "clear_tool_uses_20250919",
|
||||
# Raised from 80k to 195k to avoid premature cache invalidation.
|
||||
# For conversations with few tool uses (e.g., 18 uses over 182k tokens),
|
||||
# cache stability (saves 169k/turn) >> clearing benefit (13-26k one-time).
|
||||
# Leaves 5k headroom before hitting 200k standard context limit.
|
||||
"trigger": {"type": "input_tokens", "value": 195000},
|
||||
"keep": {"type": "tool_uses", "value": 5},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -48,6 +72,8 @@ class AgentLoop:
|
||||
exec_config: "ExecToolConfig | None" = None,
|
||||
cron_service: "CronService | None" = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
enable_memory_tool: bool = True,
|
||||
mem0_config: dict[str, Any] | None = None,
|
||||
session_manager: SessionManager | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
@@ -62,15 +88,16 @@ class AgentLoop:
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
self.cron_service = cron_service
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
|
||||
self.context = ContextBuilder(workspace)
|
||||
self.enable_memory_tool = enable_memory_tool
|
||||
self.mem0_config = mem0_config
|
||||
|
||||
self.context = ContextBuilder(workspace, mem0_config=mem0_config)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
self.tools = ToolRegistry()
|
||||
self.subagents = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
model=self.model,
|
||||
brave_api_key=brave_api_key,
|
||||
exec_config=self.exec_config,
|
||||
restrict_to_workspace=restrict_to_workspace,
|
||||
@@ -83,35 +110,73 @@ class AgentLoop:
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""Register the default set of tools."""
|
||||
# Import native tools
|
||||
from nanobot.agent.tools.anthropic import (
|
||||
BashTool20250124,
|
||||
EditTool20250728,
|
||||
ComputerTool20251124,
|
||||
)
|
||||
|
||||
# File tools (restrict to workspace if configured)
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||
# Removed: replaced by EditTool20250728
|
||||
# self.tools.register(EditFileTool(allowed_dir=allowed_dir))
|
||||
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
|
||||
|
||||
# Shell tool
|
||||
self.tools.register(ExecTool(
|
||||
working_dir=str(self.workspace),
|
||||
timeout=self.exec_config.timeout,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
))
|
||||
|
||||
|
||||
# Removed: replaced by BashTool20250124
|
||||
# self.tools.register(ExecTool(
|
||||
# working_dir=str(self.workspace),
|
||||
# timeout=self.exec_config.timeout,
|
||||
# restrict_to_workspace=self.restrict_to_workspace,
|
||||
# ))
|
||||
|
||||
# Web tools
|
||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
self.tools.register(WebFetchTool())
|
||||
|
||||
|
||||
# Message tool
|
||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
|
||||
message_tool = MessageTool(send_callback=self.bus.publish_outbound, sessions=self.sessions)
|
||||
self.tools.register(message_tool)
|
||||
|
||||
|
||||
# Spawn tool (for subagents)
|
||||
spawn_tool = SpawnTool(manager=self.subagents)
|
||||
self.tools.register(spawn_tool)
|
||||
|
||||
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
|
||||
|
||||
# Cron tool (for scheduling)
|
||||
if self.cron_service:
|
||||
self.tools.register(CronTool(self.cron_service))
|
||||
if self.enable_memory_tool:
|
||||
from nanobot.agent.tools.anthropic import MemoryTool20250818
|
||||
self.tools.register(MemoryTool20250818(workspace=self.workspace))
|
||||
|
||||
# Register native Anthropic tools
|
||||
self.tools.register(BashTool20250124())
|
||||
self.tools.register(EditTool20250728())
|
||||
# self.tools.register(ComputerTool20251124()) # Disabled - VM unavailable
|
||||
|
||||
logger.info("Registered native Anthropic tools: bash, text_editor")
|
||||
|
||||
# Register mem0 memory tools (if enabled)
|
||||
from nanobot.agent.memory_mem0 import HAS_MEM0
|
||||
if self.mem0_config and self.mem0_config.get("enabled") and HAS_MEM0:
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore
|
||||
from nanobot.agent.tools.memory_tools import (
|
||||
Mem0ToolContext, MemorySearchTool, MemoryListTool,
|
||||
MemoryAddTool, MemoryUpdateTool, MemoryDeleteTool,
|
||||
MemoryConsolidateTool,
|
||||
)
|
||||
store = Mem0MemoryStore(self.workspace, config=self.mem0_config)
|
||||
self._mem0_ctx = Mem0ToolContext(store, self._consolidate_memory)
|
||||
self.tools.register(MemorySearchTool(self._mem0_ctx))
|
||||
self.tools.register(MemoryListTool(self._mem0_ctx))
|
||||
self.tools.register(MemoryAddTool(self._mem0_ctx))
|
||||
self.tools.register(MemoryUpdateTool(self._mem0_ctx))
|
||||
self.tools.register(MemoryDeleteTool(self._mem0_ctx))
|
||||
self.tools.register(MemoryConsolidateTool(self._mem0_ctx))
|
||||
logger.info("Registered mem0 memory tools")
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the agent loop, processing messages from the bus."""
|
||||
@@ -137,7 +202,8 @@ class AgentLoop:
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=f"Sorry, I encountered an error: {str(e)}"
|
||||
content=f"Sorry, I encountered an error: {str(e)}",
|
||||
metadata=msg.metadata or {},
|
||||
))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
@@ -265,39 +331,78 @@ class AgentLoop:
|
||||
session.clear()
|
||||
self.sessions.save(session)
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="🐈 New session started. Memory consolidated.")
|
||||
content="🐈 New session started. Memory consolidated.",
|
||||
metadata=msg.metadata or {})
|
||||
if cmd == "/help":
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
|
||||
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status",
|
||||
metadata=msg.metadata or {})
|
||||
if cmd == "/quota":
|
||||
status = self._get_quota_status()
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status,
|
||||
metadata=msg.metadata or {})
|
||||
|
||||
# Consolidate memory before processing if session is too large
|
||||
if len(session.messages) > self.memory_window:
|
||||
await self._consolidate_memory(session)
|
||||
|
||||
# Update tool contexts
|
||||
message_tool = self.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
message_tool.start_turn()
|
||||
|
||||
spawn_tool = self.tools.get("spawn")
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
spawn_tool.set_context(msg.channel, msg.chat_id, msg.metadata)
|
||||
|
||||
cron_tool = self.tools.get("cron")
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_tool.set_context(msg.channel, msg.chat_id)
|
||||
|
||||
|
||||
if hasattr(self, '_mem0_ctx'):
|
||||
self._mem0_ctx.set_context(msg.channel, msg.chat_id, session)
|
||||
|
||||
# Track media for this turn (screenshots from computer tool)
|
||||
media_paths_for_turn: list[str] = []
|
||||
|
||||
# Prepend current time + optional time-gap notice to every user message
|
||||
now_dt = datetime.now()
|
||||
tz = time.strftime("%Z") or "UTC"
|
||||
time_str = now_dt.strftime("%Y-%m-%d %H:%M (%A)")
|
||||
current_message = f"[Current time: {time_str} {tz}]\n{msg.content}"
|
||||
|
||||
# Prefix hook messages so the agent can identify them
|
||||
hook_source = msg.metadata.get("hook_source") if msg.metadata else None
|
||||
if hook_source:
|
||||
current_message = f'[HOOK MESSAGE from "{hook_source}"]\n{current_message}'
|
||||
|
||||
last_user_ts = None
|
||||
for m in reversed(session.messages):
|
||||
if m.get("role") == "user":
|
||||
last_user_ts = m.get("timestamp")
|
||||
break
|
||||
if last_user_ts:
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_user_ts)
|
||||
elapsed_seconds = (now_dt - last_dt).total_seconds()
|
||||
if elapsed_seconds > 300: # 5 minutes
|
||||
if elapsed_seconds < 3600:
|
||||
gap_str = f"{int(elapsed_seconds // 60)} minutes"
|
||||
elif elapsed_seconds < 86400:
|
||||
gap_str = f"{int(elapsed_seconds // 3600)} hours"
|
||||
else:
|
||||
gap_str = f"{int(elapsed_seconds // 86400)} days"
|
||||
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user. Ask about it if appropriate]\n\n{current_message}"
|
||||
except (ValueError, TypeError):
|
||||
pass # Malformed timestamp — skip silently
|
||||
|
||||
# Build initial messages (use get_history for LLM-formatted messages)
|
||||
messages = self.context.build_messages(
|
||||
history=session.get_history(),
|
||||
current_message=msg.content,
|
||||
current_message=current_message,
|
||||
media=msg.media if msg.media else None,
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
)
|
||||
# Mark where the current turn starts so we can slice the tool chain for storage
|
||||
turn_start = len(messages)
|
||||
|
||||
# Select model based on quota
|
||||
selected_model = self._select_model_based_on_quota()
|
||||
@@ -305,7 +410,7 @@ class AgentLoop:
|
||||
# Agent loop
|
||||
iteration = 0
|
||||
final_content = None
|
||||
tools_used: list[str] = []
|
||||
final_reasoning = None
|
||||
|
||||
while iteration < self.max_iterations:
|
||||
iteration += 1
|
||||
@@ -314,10 +419,11 @@ class AgentLoop:
|
||||
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
|
||||
response = await self.provider.chat(
|
||||
messages=messages,
|
||||
tools=self.tools.get_definitions(),
|
||||
model=selected_model
|
||||
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||
model=selected_model,
|
||||
context_management=self.CONTEXT_MANAGEMENT,
|
||||
)
|
||||
|
||||
|
||||
# Handle tool calls
|
||||
if response.has_tool_calls:
|
||||
# Add assistant message with tool calls
|
||||
@@ -336,44 +442,177 @@ class AgentLoop:
|
||||
messages, response.content, tool_call_dicts,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
|
||||
|
||||
# Execute tools
|
||||
for tool_call in response.tool_calls:
|
||||
tools_used.append(tool_call.name)
|
||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||
|
||||
# Handle different result types
|
||||
if isinstance(result, ToolResult):
|
||||
# Native Anthropic tool result
|
||||
content_parts = []
|
||||
|
||||
# Add text content
|
||||
if result.output:
|
||||
text_content = result.output
|
||||
elif result.error:
|
||||
text_content = f"Error: {result.error}"
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
# If both output and error, combine them
|
||||
if result.output and result.error:
|
||||
text_content = f"{result.output}\n\nError: {result.error}"
|
||||
|
||||
# If there's an image, use multipart content
|
||||
if result.base64_image:
|
||||
# Save screenshot to disk for user
|
||||
import base64
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshot_path = media_dir / f"screenshot_{int(time.time() * 1000)}.png"
|
||||
screenshot_path.write_bytes(base64.b64decode(result.base64_image))
|
||||
media_paths_for_turn.append(str(screenshot_path))
|
||||
logger.info(f"Saved screenshot to {screenshot_path}")
|
||||
|
||||
# Include in tool result for model to see
|
||||
content_parts = [
|
||||
{"type": "text", "text": text_content},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": result.base64_image,
|
||||
}
|
||||
}
|
||||
]
|
||||
tool_content = content_parts
|
||||
else:
|
||||
tool_content = text_content
|
||||
|
||||
elif isinstance(result, CLIResult):
|
||||
# CLI-style tool result (text editor)
|
||||
tool_content = result.output
|
||||
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_content = result
|
||||
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
messages, tool_call.id, tool_call.name, tool_content
|
||||
)
|
||||
# Interleaved CoT: reflect before next action
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||
if not getattr(self.provider, 'thinking_budget', 0):
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
else:
|
||||
# No tool calls, we're done
|
||||
# No tool calls
|
||||
final_content = response.content
|
||||
final_reasoning = response.reasoning_content
|
||||
|
||||
# Check for forged signatures if in suppress mode
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
if suppress_output and has_forged_marker(final_content):
|
||||
# Initialize retry counter if needed
|
||||
if not hasattr(self, '_forge_retry_count'):
|
||||
self._forge_retry_count = 0
|
||||
|
||||
if self._forge_retry_count < 1:
|
||||
# First offense: reject and retry with correction
|
||||
self._forge_retry_count += 1
|
||||
logger.warning("Model attempted to forge visibility marker, rejecting output")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||
})
|
||||
continue # Back to while loop, will retry LLM call
|
||||
else:
|
||||
# Second offense: strip and log error (fallback)
|
||||
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||
final_content = strip_all_hidden_markers(final_content)
|
||||
|
||||
# Reset retry counter on successful completion
|
||||
if hasattr(self, '_forge_retry_count'):
|
||||
self._forge_retry_count = 0
|
||||
|
||||
break
|
||||
|
||||
|
||||
if final_content is None:
|
||||
if iteration >= self.max_iterations:
|
||||
final_content = f"Reached {self.max_iterations} iterations without completion."
|
||||
else:
|
||||
final_content = "I've completed processing but have no response to give."
|
||||
|
||||
|
||||
# Check if message tool already sent to same target (suppress final reply)
|
||||
message_tool = self.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
logger.info(f"Suppressing final reply to {msg.channel}:{msg.chat_id} (message tool already sent)")
|
||||
return None
|
||||
|
||||
# Log response preview
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
|
||||
|
||||
# Save to session (include tool names so consolidation sees what happened)
|
||||
session.add_message("user", msg.content)
|
||||
session.add_message("assistant", final_content,
|
||||
tools_used=tools_used if tools_used else None)
|
||||
|
||||
# Check for suppress mode BEFORE adding to session
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
|
||||
if suppress_output:
|
||||
# Sign content with our secret key (forgery detection happens in loop above)
|
||||
final_content_for_session = sign_content(final_content)
|
||||
# Mark as suppressed for channel handler
|
||||
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||
else:
|
||||
final_content_for_session = final_content
|
||||
outbound_metadata = msg.metadata or {}
|
||||
|
||||
# Append final assistant response to messages so it's captured in the tool chain slice
|
||||
# Use the prefixed version for session storage
|
||||
messages = self.context.add_assistant_message(
|
||||
messages, final_content_for_session, None,
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: mem0 context (if present) + user message + full tool chain
|
||||
# Store current_message (not msg.content) so the time prefix is preserved
|
||||
# Include sender_id to distinguish real user messages from system-generated ones
|
||||
|
||||
# Find and save mem0 injection (appears just before current user message)
|
||||
# build_messages returns: [...history, mem0_user, mem0_asst, current_user]
|
||||
# turn_start = len(messages), so mem0 is at turn_start-3 and turn_start-2
|
||||
# This makes mem0 part of immutable history, stabilizing cache across turns
|
||||
if turn_start >= 3:
|
||||
potential_mem0_user = messages[turn_start - 3]
|
||||
potential_mem0_asst = messages[turn_start - 2]
|
||||
if (potential_mem0_user.get("role") == "user" and
|
||||
potential_mem0_user.get("content") == "[Memory context]" and
|
||||
potential_mem0_asst.get("role") == "assistant"):
|
||||
session.add_raw_message(potential_mem0_user)
|
||||
session.add_raw_message(potential_mem0_asst)
|
||||
|
||||
session.add_message("user", current_message, sender_id=msg.sender_id)
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
# Deferred trim: if memory_consolidate ran mid-turn, it set a checkpoint
|
||||
# marking where to trim. Now that the turn's tool chain is fully saved,
|
||||
# we can safely trim to that checkpoint.
|
||||
checkpoint = getattr(session, '_trim_checkpoint', None)
|
||||
if checkpoint is not None:
|
||||
old_size = len(session.messages)
|
||||
session.messages = session.messages[checkpoint:]
|
||||
session._trim_checkpoint = None
|
||||
self.sessions.save(session)
|
||||
logger.info(f"Deferred trim applied: {old_size} -> {len(session.messages)} messages (checkpoint={checkpoint})")
|
||||
|
||||
return OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=final_content,
|
||||
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
|
||||
content=final_content_for_session,
|
||||
metadata=outbound_metadata,
|
||||
media=media_paths_for_turn if media_paths_for_turn else None,
|
||||
)
|
||||
|
||||
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||
@@ -403,11 +642,12 @@ class AgentLoop:
|
||||
message_tool = self.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_context(origin_channel, origin_chat_id)
|
||||
|
||||
|
||||
|
||||
spawn_tool = self.tools.get("spawn")
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_tool.set_context(origin_channel, origin_chat_id)
|
||||
|
||||
spawn_tool.set_context(origin_channel, origin_chat_id, msg.metadata)
|
||||
|
||||
cron_tool = self.tools.get("cron")
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_tool.set_context(origin_channel, origin_chat_id)
|
||||
@@ -419,10 +659,12 @@ class AgentLoop:
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
)
|
||||
|
||||
turn_start = len(messages)
|
||||
|
||||
# Agent loop (limited for announce handling)
|
||||
iteration = 0
|
||||
final_content = None
|
||||
final_reasoning = None
|
||||
|
||||
# Select model based on quota
|
||||
selected_model = self._select_model_based_on_quota()
|
||||
@@ -432,10 +674,11 @@ class AgentLoop:
|
||||
|
||||
response = await self.provider.chat(
|
||||
messages=messages,
|
||||
tools=self.tools.get_definitions(),
|
||||
model=selected_model
|
||||
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
|
||||
model=selected_model,
|
||||
context_management=self.CONTEXT_MANAGEMENT,
|
||||
)
|
||||
|
||||
|
||||
if response.has_tool_calls:
|
||||
tool_call_dicts = [
|
||||
{
|
||||
@@ -452,56 +695,278 @@ class AgentLoop:
|
||||
messages, response.content, tool_call_dicts,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
|
||||
|
||||
for tool_call in response.tool_calls:
|
||||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||||
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
|
||||
result = await self.tools.execute(tool_call.name, tool_call.arguments)
|
||||
|
||||
# Handle different result types (same logic as main handler)
|
||||
if isinstance(result, ToolResult):
|
||||
# Native Anthropic tool result
|
||||
# Add text content
|
||||
if result.output:
|
||||
text_content = result.output
|
||||
elif result.error:
|
||||
text_content = f"Error: {result.error}"
|
||||
else:
|
||||
text_content = ""
|
||||
|
||||
# If both output and error, combine them
|
||||
if result.output and result.error:
|
||||
text_content = f"{result.output}\n\nError: {result.error}"
|
||||
|
||||
# Note: Image handling for system messages not needed
|
||||
# (system messages don't render images to users)
|
||||
# But we should still log if present
|
||||
if result.base64_image:
|
||||
logger.warning(
|
||||
f"Tool {tool_call.name} returned image in system message context - "
|
||||
"images not supported here"
|
||||
)
|
||||
|
||||
tool_content = text_content
|
||||
|
||||
elif isinstance(result, CLIResult):
|
||||
# CLI-style tool result (text editor)
|
||||
tool_content = result.output
|
||||
|
||||
else:
|
||||
# Legacy string result from function tools
|
||||
tool_content = result
|
||||
|
||||
messages = self.context.add_tool_result(
|
||||
messages, tool_call.id, tool_call.name, result
|
||||
messages, tool_call.id, tool_call.name, tool_content
|
||||
)
|
||||
# Interleaved CoT: reflect before next action
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
# Interleaved CoT: reflect before next action (skip when thinking is active)
|
||||
if not getattr(self.provider, 'thinking_budget', 0):
|
||||
messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
|
||||
else:
|
||||
# No tool calls
|
||||
final_content = response.content
|
||||
final_reasoning = response.reasoning_content
|
||||
|
||||
# Check for forged signatures if in suppress mode
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
if suppress_output and has_forged_marker(final_content):
|
||||
# Initialize retry counter if needed
|
||||
if not hasattr(self, '_forge_retry_count_system'):
|
||||
self._forge_retry_count_system = 0
|
||||
|
||||
if self._forge_retry_count_system < 1:
|
||||
# First offense: reject and retry with correction
|
||||
self._forge_retry_count_system += 1
|
||||
logger.warning("Model attempted to forge visibility marker in system message, rejecting output")
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "[System: Previous response rejected. Do not generate [HIDDEN:*] markers.]"
|
||||
})
|
||||
continue # Back to while loop, will retry LLM call
|
||||
else:
|
||||
# Second offense: strip and log error (fallback)
|
||||
logger.error("Model persisted in forging markers despite correction, stripping")
|
||||
final_content = strip_all_hidden_markers(final_content)
|
||||
|
||||
# Reset retry counter on successful completion
|
||||
if hasattr(self, '_forge_retry_count_system'):
|
||||
self._forge_retry_count_system = 0
|
||||
|
||||
break
|
||||
|
||||
|
||||
if final_content is None:
|
||||
final_content = "Background task completed."
|
||||
|
||||
# Save to session (mark as system message in history)
|
||||
|
||||
# Check for suppress mode BEFORE adding to session
|
||||
suppress_output = msg.metadata.get("suppress_output", False) if msg.metadata else False
|
||||
|
||||
if suppress_output:
|
||||
# Sign content with our secret key (forgery detection happens in loop above)
|
||||
final_content_for_session = sign_content(final_content)
|
||||
# Mark as suppressed for channel handler
|
||||
outbound_metadata = {**(msg.metadata or {}), "suppressed": True}
|
||||
else:
|
||||
final_content_for_session = final_content
|
||||
outbound_metadata = msg.metadata or {}
|
||||
|
||||
# Append final assistant response to messages (use signed version for session)
|
||||
messages = self.context.add_assistant_message(
|
||||
messages, final_content_for_session, None,
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: mem0 (if present) + user message + full tool chain
|
||||
# Find and save mem0 injection for cache stability
|
||||
if turn_start >= 3:
|
||||
potential_mem0_user = messages[turn_start - 3]
|
||||
potential_mem0_asst = messages[turn_start - 2]
|
||||
if (potential_mem0_user.get("role") == "user" and
|
||||
potential_mem0_user.get("content") == "[Memory context]" and
|
||||
potential_mem0_asst.get("role") == "assistant"):
|
||||
session.add_raw_message(potential_mem0_user)
|
||||
session.add_raw_message(potential_mem0_asst)
|
||||
|
||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||
session.add_message("assistant", final_content)
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
self.sessions.save(session)
|
||||
|
||||
|
||||
# Deferred trim: same logic as _process_message
|
||||
# System messages (including subagents) can trigger consolidation
|
||||
checkpoint = getattr(session, '_trim_checkpoint', None)
|
||||
if checkpoint is not None:
|
||||
old_size = len(session.messages)
|
||||
session.messages = session.messages[checkpoint:]
|
||||
session._trim_checkpoint = None
|
||||
self.sessions.save(session)
|
||||
logger.info(f"Deferred trim applied: {old_size} -> {len(session.messages)} messages (checkpoint={checkpoint})")
|
||||
|
||||
# Return original content (not signed) for outbound, but with suppressed metadata
|
||||
return OutboundMessage(
|
||||
channel=origin_channel,
|
||||
chat_id=origin_chat_id,
|
||||
content=final_content
|
||||
content=final_content,
|
||||
metadata=outbound_metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_clean_boundary_before(messages: list[dict], target_pos: int) -> int:
|
||||
"""Find a clean user message boundary at or before target position.
|
||||
|
||||
Returns the index of a user message at or before target_pos,
|
||||
or target_pos if no user message is found.
|
||||
"""
|
||||
if not messages or target_pos <= 0:
|
||||
return 0
|
||||
if target_pos >= len(messages):
|
||||
return len(messages)
|
||||
|
||||
# Walk backward from target to find a user message
|
||||
for i in range(target_pos, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
return i
|
||||
|
||||
# No user message found, return target position
|
||||
return target_pos
|
||||
|
||||
@staticmethod
|
||||
def _trim_to_clean_boundary(messages: list[dict], keep_count: int) -> list[dict]:
|
||||
"""Trim messages to approximately keep_count, starting at a user message boundary.
|
||||
|
||||
Naive slicing (messages[-keep_count:]) can cut into a tool chain, leaving
|
||||
orphaned tool_result messages at the start. This finds the nearest user
|
||||
message (role="user") at or before the cut point and trims there.
|
||||
"""
|
||||
if not messages or keep_count <= 0:
|
||||
return []
|
||||
if keep_count >= len(messages):
|
||||
return messages
|
||||
|
||||
cut = len(messages) - keep_count
|
||||
# Walk forward from cut to find a "user" role message (start of a turn)
|
||||
# that isn't a tool result. Tool results have role="tool", user messages
|
||||
# have role="user" — but after conversion, tool results ARE user messages.
|
||||
# In session storage, they're still role="tool", so we look for role="user".
|
||||
for i in range(cut, len(messages)):
|
||||
if messages[i].get("role") == "user":
|
||||
return messages[i:]
|
||||
# If no user message found after cut, try walking backward
|
||||
for i in range(cut - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
return messages[i:]
|
||||
# Fallback: return everything (shouldn't happen in practice)
|
||||
return messages
|
||||
|
||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
|
||||
"""Consolidate session into MEMORY.md + HISTORY.md.
|
||||
|
||||
Context window management is now handled server-side via context_management.
|
||||
This only runs on /new to write long-term facts and searchable history.
|
||||
"""
|
||||
if not session.messages:
|
||||
return
|
||||
memory = MemoryStore(self.workspace)
|
||||
|
||||
# Choose memory backend
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
|
||||
|
||||
if self.mem0_config and self.mem0_config.get("enabled") and HAS_MEM0:
|
||||
memory = Mem0MemoryStore(self.workspace, config=self.mem0_config)
|
||||
logger.debug("Using mem0 for memory consolidation")
|
||||
# Mem0 has its own consolidation logic (feeds messages to mem0 for extraction)
|
||||
await memory.consolidate(
|
||||
session,
|
||||
self.provider,
|
||||
self.model,
|
||||
archive_all=archive_all,
|
||||
memory_window=self.memory_window,
|
||||
)
|
||||
# archive_all (/new) runs at a turn boundary — safe to trim now.
|
||||
# Mid-turn (memory_consolidate tool) — defer trim to end of turn
|
||||
# to avoid orphaning tool_use IDs in the active tool chain.
|
||||
if archive_all:
|
||||
session.messages = []
|
||||
self.sessions.save(session)
|
||||
logger.info("Mem0 consolidation done, session cleared (archive_all)")
|
||||
else:
|
||||
keep_count = min(10, max(2, self.memory_window // 2))
|
||||
# Set checkpoint at current session size minus keep_count
|
||||
# This preserves the intended trim point regardless of messages added later
|
||||
checkpoint = max(0, len(session.messages) - keep_count)
|
||||
# Find clean boundary at or before checkpoint
|
||||
checkpoint = self._find_clean_boundary_before(session.messages, checkpoint)
|
||||
session._trim_checkpoint = checkpoint
|
||||
logger.info(f"Mem0 consolidation done, trim deferred (checkpoint={checkpoint}, current_size={len(session.messages)})")
|
||||
return
|
||||
else:
|
||||
memory = MemoryStore(self.workspace)
|
||||
logger.debug("Using MemoryStore for memory consolidation")
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
keep_count = 0
|
||||
else:
|
||||
# Only write truly old messages; keep the recent ones
|
||||
keep_count = min(10, max(2, self.memory_window // 2))
|
||||
old_messages = session.messages[:-keep_count]
|
||||
if not old_messages:
|
||||
return
|
||||
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
|
||||
logger.info(f"Memory consolidation: archiving {len(old_messages)} messages, keeping {keep_count}")
|
||||
|
||||
# Format messages for LLM (include tool names when available)
|
||||
# Format messages for LLM — handle full tool chain format
|
||||
lines = []
|
||||
for m in old_messages:
|
||||
if not m.get("content"):
|
||||
role = m.get("role", "?")
|
||||
content = m.get("content")
|
||||
timestamp = m.get("timestamp", "?")[:16]
|
||||
|
||||
if role == "tool":
|
||||
result = str(content or "")[:200]
|
||||
lines.append(f"[{timestamp}] TOOL_RESULT({m.get('name', '?')}): {result}")
|
||||
continue
|
||||
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
|
||||
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
|
||||
|
||||
# Skip internal reflect prompts
|
||||
if role == "user" and content == "Reflect on the results and decide next steps.":
|
||||
continue
|
||||
|
||||
# Extract text from content (may be list of blocks)
|
||||
if isinstance(content, list):
|
||||
text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
||||
content_str = " ".join(text_parts)
|
||||
elif isinstance(content, str):
|
||||
content_str = content
|
||||
else:
|
||||
content_str = ""
|
||||
|
||||
# Get tool names from tool_calls or legacy tools_used
|
||||
tool_names = []
|
||||
if m.get("tool_calls"):
|
||||
tool_names = [tc.get("function", {}).get("name", "?") for tc in m["tool_calls"]]
|
||||
elif m.get("tools_used"):
|
||||
tool_names = m["tools_used"]
|
||||
|
||||
if not content_str and not tool_names:
|
||||
continue
|
||||
|
||||
tools_str = f" [tools: {', '.join(tool_names)}]" if tool_names else ""
|
||||
lines.append(f"[{timestamp}] {role.upper()}{tools_str}: {content_str}")
|
||||
conversation = "\n".join(lines)
|
||||
current_memory = memory.read_long_term()
|
||||
|
||||
@@ -540,7 +1005,7 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
if update != current_memory:
|
||||
memory.write_long_term(update)
|
||||
|
||||
session.messages = session.messages[-keep_count:] if keep_count else []
|
||||
session.messages = self._trim_to_clean_boundary(session.messages, keep_count) if keep_count else []
|
||||
self.sessions.save(session)
|
||||
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
|
||||
except Exception as e:
|
||||
@@ -552,16 +1017,18 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
session_key: str = "cli:direct",
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Process a message directly (for CLI or cron usage).
|
||||
|
||||
|
||||
Args:
|
||||
content: The message content.
|
||||
session_key: Session identifier (overrides channel:chat_id for session lookup).
|
||||
channel: Source channel (for tool context routing).
|
||||
chat_id: Source chat ID (for tool context routing).
|
||||
|
||||
metadata: Optional metadata to pass through (for suppress mode, etc.).
|
||||
|
||||
Returns:
|
||||
The agent's response.
|
||||
"""
|
||||
@@ -569,8 +1036,9 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
channel=channel,
|
||||
sender_id="user",
|
||||
chat_id=chat_id,
|
||||
content=content
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
response = await self._process_message(msg, session_key=session_key)
|
||||
return response.content if response else ""
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
"""Memory system for persistent agent memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
|
||||
_SAVE_MEMORY_TOOL = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "save_memory",
|
||||
"description": "Save the memory consolidation result to persistent storage.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"history_entry": {
|
||||
"type": "string",
|
||||
"description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
|
||||
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
|
||||
},
|
||||
"memory_update": {
|
||||
"type": "string",
|
||||
"description": "Full updated long-term memory as markdown. Include all existing "
|
||||
"facts plus new ones. Return unchanged if nothing new.",
|
||||
},
|
||||
},
|
||||
"required": ["history_entry", "memory_update"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
|
||||
@@ -28,3 +65,81 @@ class MemoryStore:
|
||||
def get_memory_context(self) -> str:
|
||||
long_term = self.read_long_term()
|
||||
return f"## Long-term Memory\n{long_term}" if long_term else ""
|
||||
|
||||
async def consolidate(
|
||||
self,
|
||||
session: Session,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
*,
|
||||
archive_all: bool = False,
|
||||
memory_window: int = 50,
|
||||
) -> bool:
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
|
||||
|
||||
Returns True on success (including no-op), False on failure.
|
||||
"""
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
keep_count = 0
|
||||
logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
|
||||
else:
|
||||
keep_count = memory_window // 2
|
||||
if len(session.messages) <= keep_count:
|
||||
return True
|
||||
old_messages = session.messages[:-keep_count]
|
||||
logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
|
||||
|
||||
lines = []
|
||||
for m in old_messages:
|
||||
if not m.get("content"):
|
||||
continue
|
||||
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
|
||||
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
|
||||
|
||||
current_memory = self.read_long_term()
|
||||
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
|
||||
|
||||
## Current Long-term Memory
|
||||
{current_memory or "(empty)"}
|
||||
|
||||
## Conversation to Process
|
||||
{chr(10).join(lines)}"""
|
||||
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=_SAVE_MEMORY_TOOL,
|
||||
model=model,
|
||||
)
|
||||
|
||||
if not response.has_tool_calls:
|
||||
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
|
||||
return False
|
||||
|
||||
args = response.tool_calls[0].arguments
|
||||
# Some providers return arguments as a JSON string instead of dict
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
if not isinstance(args, dict):
|
||||
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
|
||||
return False
|
||||
|
||||
if entry := args.get("history_entry"):
|
||||
if not isinstance(entry, str):
|
||||
entry = json.dumps(entry, ensure_ascii=False)
|
||||
self.append_history(entry)
|
||||
if update := args.get("memory_update"):
|
||||
if not isinstance(update, str):
|
||||
update = json.dumps(update, ensure_ascii=False)
|
||||
if update != current_memory:
|
||||
self.write_long_term(update)
|
||||
|
||||
logger.info("Memory consolidation done: {} messages total", len(session.messages))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Memory consolidation failed")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Mem0-powered memory system for intelligent semantic retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
try:
|
||||
from mem0 import Memory
|
||||
from mem0.configs.base import MemoryConfig
|
||||
HAS_MEM0 = True
|
||||
except ImportError:
|
||||
HAS_MEM0 = False
|
||||
MemoryConfig = None # type: ignore
|
||||
|
||||
|
||||
class Mem0MemoryStore:
|
||||
"""
|
||||
Enhanced memory store using mem0 for semantic search and automatic extraction.
|
||||
|
||||
Features:
|
||||
- Multi-level memory (user, session, agent)
|
||||
- Semantic search with embeddings
|
||||
- Automatic memory extraction from conversations
|
||||
- 90% token reduction vs full-context
|
||||
- 91% faster responses
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Path, config: dict[str, Any] | None = None):
|
||||
if not HAS_MEM0:
|
||||
raise ImportError(
|
||||
"mem0 not installed. Install with: pip install mem0ai"
|
||||
)
|
||||
|
||||
self.workspace = workspace
|
||||
self.memory_dir = workspace / "memory"
|
||||
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build custom extraction prompt tuned for nanobot conversations
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n"
|
||||
self.custom_prompt = custom_prompt
|
||||
|
||||
# Initialize mem0 with optional config + custom prompt
|
||||
# Extract only MemoryConfig-relevant fields
|
||||
raw_config = config if config else {}
|
||||
logger.debug(f"Mem0MemoryStore received config keys: {list(raw_config.keys())}")
|
||||
mem0_cfg_dict = {}
|
||||
for key in ("vector_store", "llm", "embedder", "graph_store", "version"):
|
||||
if key in raw_config:
|
||||
mem0_cfg_dict[key] = raw_config[key]
|
||||
logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}")
|
||||
mem0_config = MemoryConfig(**mem0_cfg_dict)
|
||||
logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}")
|
||||
self.memory = Memory(config=mem0_config)
|
||||
|
||||
logger.info("Mem0 memory system initialized with custom nanobot prompt")
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5,
|
||||
session_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Search for relevant memories using semantic search.
|
||||
|
||||
Args:
|
||||
query: Search query (user's current message)
|
||||
user_id: User identifier (e.g., "telegram_12345")
|
||||
limit: Max number of memories to return
|
||||
session_id: Optional session-specific memories
|
||||
|
||||
Returns:
|
||||
List of memory dicts with 'memory' and 'score' keys
|
||||
"""
|
||||
try:
|
||||
# Search user-level memories
|
||||
user_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
results = []
|
||||
if user_memories and "results" in user_memories:
|
||||
results.extend(user_memories["results"])
|
||||
|
||||
# Optionally search session-level memories
|
||||
if session_id:
|
||||
session_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
metadata={"session_id": session_id},
|
||||
limit=limit // 2 # Reserve half for session context
|
||||
)
|
||||
if session_memories and "results" in session_memories:
|
||||
results.extend(session_memories["results"])
|
||||
|
||||
logger.debug(
|
||||
f"Mem0 search: query='{query[:50]}...', found {len(results)} memories"
|
||||
)
|
||||
return results[:limit] # Limit total results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 search failed: {e}")
|
||||
return []
|
||||
|
||||
def add_conversation(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Add conversation messages to memory for automatic extraction.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
user_id: User identifier
|
||||
session_id: Optional session identifier for session-level memories
|
||||
"""
|
||||
try:
|
||||
metadata = {}
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
# mem0 automatically extracts and stores relevant facts
|
||||
result = self.memory.add(
|
||||
messages,
|
||||
user_id=user_id,
|
||||
metadata=metadata if metadata else None
|
||||
)
|
||||
|
||||
facts_count = len(result.get("results", [])) if result else 0
|
||||
logger.debug(
|
||||
f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 add failed: {e}")
|
||||
|
||||
async def extract_facts(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
provider: Any,
|
||||
model: str,
|
||||
) -> list[str]:
|
||||
"""Extract facts from conversation using the main agent's LLM provider."""
|
||||
import json as _json
|
||||
|
||||
conv_text = ""
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content_val = msg.get("content", "")
|
||||
if isinstance(content_val, str) and content_val.strip():
|
||||
conv_text += f"{role}: {content_val}\n\n"
|
||||
|
||||
if not conv_text.strip():
|
||||
return []
|
||||
|
||||
extraction_messages = [
|
||||
{"role": "user", "content": self.custom_prompt + conv_text}
|
||||
]
|
||||
|
||||
try:
|
||||
response = await provider.chat(
|
||||
messages=extraction_messages,
|
||||
model=model,
|
||||
max_tokens=2000,
|
||||
temperature=0.3,
|
||||
)
|
||||
text = (response.content or "").strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("```")[1]
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
text = text.strip()
|
||||
data = _json.loads(text)
|
||||
facts = data.get("facts", [])
|
||||
if not isinstance(facts, list):
|
||||
logger.warning(f"LLM returned non-list facts: {type(facts)}")
|
||||
return []
|
||||
logger.debug(f"Extracted {len(facts)} facts using {model}")
|
||||
return facts
|
||||
except Exception as e:
|
||||
logger.error(f"Fact extraction failed: {e}")
|
||||
return []
|
||||
|
||||
def store_facts(
|
||||
self,
|
||||
facts: list[str],
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""Store pre-extracted facts in mem0 with infer=False."""
|
||||
if not facts:
|
||||
return
|
||||
|
||||
metadata = {}
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
stored = 0
|
||||
for fact in facts:
|
||||
try:
|
||||
self.memory.add(
|
||||
fact,
|
||||
user_id=user_id,
|
||||
infer=False,
|
||||
metadata=metadata if metadata else None,
|
||||
)
|
||||
stored += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store fact '{fact[:50]}...': {e}")
|
||||
|
||||
logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}")
|
||||
|
||||
def get_memory_context(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Get formatted memory context for inclusion in system prompt.
|
||||
|
||||
Args:
|
||||
query: Current user query
|
||||
user_id: User identifier
|
||||
limit: Max memories to include
|
||||
|
||||
Returns:
|
||||
Formatted memory context string
|
||||
"""
|
||||
memories = self.search_memories(query, user_id, limit=limit)
|
||||
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["## Relevant Memories"]
|
||||
for i, mem in enumerate(memories, 1):
|
||||
memory_text = mem.get("memory", "")
|
||||
# Include score if available for debugging
|
||||
score = mem.get("score", "")
|
||||
score_str = f" (relevance: {score:.2f})" if score else ""
|
||||
lines.append(f"{i}. {memory_text}{score_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def update_memory(self, memory_id: str, data: dict[str, Any]) -> None:
|
||||
"""Update a specific memory by ID."""
|
||||
try:
|
||||
self.memory.update(memory_id, data)
|
||||
logger.debug(f"Mem0 update: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 update failed: {e}")
|
||||
|
||||
def delete_memory(self, memory_id: str) -> None:
|
||||
"""Delete a specific memory by ID."""
|
||||
try:
|
||||
self.memory.delete(memory_id)
|
||||
logger.debug(f"Mem0 delete: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 delete failed: {e}")
|
||||
|
||||
def get_all_memories(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all memories for a user."""
|
||||
try:
|
||||
result = self.memory.get_all(user_id=user_id)
|
||||
return result.get("results", []) if result else []
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 get_all failed: {e}")
|
||||
return []
|
||||
|
||||
async def consolidate(
|
||||
self,
|
||||
session: Session,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
*,
|
||||
archive_all: bool = False,
|
||||
memory_window: int = 50,
|
||||
) -> bool:
|
||||
"""
|
||||
Consolidate session messages into mem0 memory.
|
||||
|
||||
Unlike the original MemoryStore, mem0 handles extraction automatically,
|
||||
so this just needs to feed recent messages to mem0.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
try:
|
||||
# Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345")
|
||||
user_id = session.key.replace(":", "_")
|
||||
|
||||
# Determine which messages to consolidate
|
||||
if archive_all:
|
||||
messages_to_add = session.messages
|
||||
logger.info(
|
||||
f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages"
|
||||
)
|
||||
else:
|
||||
keep_count = memory_window // 2
|
||||
if len(session.messages) <= keep_count:
|
||||
return True
|
||||
|
||||
# Consolidate messages except the most recent (kept for context)
|
||||
start_idx = 0
|
||||
end_idx = len(session.messages) - keep_count
|
||||
|
||||
if end_idx <= start_idx:
|
||||
return True
|
||||
|
||||
messages_to_add = session.messages[start_idx:end_idx]
|
||||
|
||||
if not messages_to_add:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Mem0 consolidation: {len(messages_to_add)} to consolidate, "
|
||||
f"{keep_count} keep"
|
||||
)
|
||||
|
||||
# Convert to mem0 format with intelligent filtering
|
||||
mem0_messages = []
|
||||
for msg in messages_to_add:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
# Skip tool results — raw bash output, file contents, and JSON
|
||||
# get misinterpreted by the extraction LLM as user interests
|
||||
if role == "tool":
|
||||
continue
|
||||
|
||||
# Skip system messages — they're boilerplate instructions, not facts
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
# Skip messages with no content
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Normalize assistant message content: extract text from Anthropic list format
|
||||
if role == "assistant" and isinstance(content, list):
|
||||
# Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue # Skip if assistant only called tools with no text explanation
|
||||
|
||||
# Normalize user message content (could also be a list in some formats)
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Skip trivially short messages (commands like "/new")
|
||||
if len(content.strip()) < 10:
|
||||
continue
|
||||
|
||||
mem0_messages.append({
|
||||
"role": role,
|
||||
"content": content
|
||||
})
|
||||
|
||||
if mem0_messages:
|
||||
# Extract facts using the main agent's LLM (already paid for),
|
||||
# then store with infer=False to bypass mem0's GPT-nano
|
||||
facts = await self.extract_facts(mem0_messages, provider, model)
|
||||
self.store_facts(facts, user_id=user_id, session_id=session.key)
|
||||
|
||||
logger.info(
|
||||
f"Mem0 consolidation done: {len(session.messages)} messages total"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Mem0 consolidation failed")
|
||||
return False
|
||||
@@ -167,10 +167,10 @@ class SkillsLoader:
|
||||
return content
|
||||
|
||||
def _parse_nanobot_metadata(self, raw: str) -> dict:
|
||||
"""Parse nanobot metadata JSON from frontmatter."""
|
||||
"""Parse skill metadata JSON from frontmatter (supports nanobot, clawdbot, and openclaw keys)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return (data.get("nanobot") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
|
||||
return (data.get("nanobot") or data.get("clawdbot") or data.get("openclaw") or {}) if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
+77
-29
@@ -15,6 +15,9 @@ from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.subagent_message import SubagentMessageTool
|
||||
from nanobot.agent.tools.wait import WaitForSubagentsTool
|
||||
|
||||
|
||||
class SubagentManager:
|
||||
@@ -40,11 +43,15 @@ class SubagentManager:
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
self.bus = bus
|
||||
self.model = model or provider.get_default_model()
|
||||
# Default to Sonnet, not the provider default (Opus).
|
||||
# Quota switching only affects the main agent's own requests, not SubagentManager.
|
||||
# Explicit model overrides (e.g. Haiku workers) still take precedence.
|
||||
self.model = model or "claude-sonnet-4-6"
|
||||
self.brave_api_key = brave_api_key
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_results: dict[str, str] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
@@ -53,38 +60,41 @@ class SubagentManager:
|
||||
model: str | None = None,
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
origin_metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Spawn a subagent to execute a task in the background.
|
||||
|
||||
|
||||
Args:
|
||||
task: The task description for the subagent.
|
||||
label: Optional human-readable label for the task.
|
||||
origin_channel: The channel to announce results to.
|
||||
origin_chat_id: The chat ID to announce results to.
|
||||
|
||||
origin_metadata: Optional metadata to propagate to announcement (e.g. suppress_output).
|
||||
|
||||
Returns:
|
||||
Status message indicating the subagent was started.
|
||||
Task ID of the spawned subagent.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
|
||||
|
||||
origin = {
|
||||
"channel": origin_channel,
|
||||
"chat_id": origin_chat_id,
|
||||
"metadata": origin_metadata or {},
|
||||
}
|
||||
|
||||
|
||||
# Create background task
|
||||
bg_task = asyncio.create_task(
|
||||
self._run_subagent(task_id, task, display_label, origin, model=model)
|
||||
)
|
||||
self._running_tasks[task_id] = bg_task
|
||||
|
||||
|
||||
# Cleanup when done
|
||||
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
|
||||
|
||||
|
||||
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
|
||||
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
|
||||
return task_id
|
||||
|
||||
async def _run_subagent(
|
||||
self,
|
||||
@@ -98,7 +108,7 @@ class SubagentManager:
|
||||
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
||||
|
||||
try:
|
||||
# Build subagent tools (no message tool, no spawn tool)
|
||||
# Build subagent tools (no message tool)
|
||||
tools = ToolRegistry()
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
tools.register(ReadFileTool(allowed_dir=allowed_dir))
|
||||
@@ -112,7 +122,22 @@ class SubagentManager:
|
||||
))
|
||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
tools.register(WebFetchTool())
|
||||
|
||||
|
||||
# Message tool for communicating with user (via main agent)
|
||||
message_tool = SubagentMessageTool(
|
||||
bus=self.bus,
|
||||
origin_channel=origin["channel"],
|
||||
origin_chat_id=origin["chat_id"],
|
||||
origin_metadata=origin.get("metadata"),
|
||||
)
|
||||
tools.register(message_tool)
|
||||
|
||||
# Spawn tool for creating child subagents
|
||||
spawn_tool = SpawnTool(manager=self)
|
||||
spawn_tool.set_context("subagent", origin["chat_id"], origin.get("metadata"))
|
||||
tools.register(spawn_tool)
|
||||
tools.register(WaitForSubagentsTool(manager=self))
|
||||
|
||||
# Build messages with subagent-specific prompt
|
||||
system_prompt = self._build_subagent_prompt(task)
|
||||
messages: list[dict[str, Any]] = [
|
||||
@@ -190,7 +215,16 @@ class SubagentManager:
|
||||
) -> None:
|
||||
"""Announce the subagent result to the main agent via the message bus."""
|
||||
status_text = "completed successfully" if status == "ok" else "failed"
|
||||
|
||||
|
||||
# ALWAYS store result so wait_for_subagents can find it
|
||||
self._task_results[task_id] = result
|
||||
|
||||
# Child subagents (spawned by other subagents) don't announce - parent waits for them
|
||||
if origin["channel"] == "subagent":
|
||||
logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)")
|
||||
return
|
||||
|
||||
# Top-level subagents announce via bus to trigger main agent
|
||||
announce_content = f"""[Subagent '{label}' {status_text}]
|
||||
|
||||
Task: {task}
|
||||
@@ -199,48 +233,43 @@ Result:
|
||||
{result}
|
||||
|
||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
|
||||
|
||||
|
||||
# Inject as system message to trigger main agent
|
||||
# Propagate metadata from origin (e.g. suppress_output)
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
metadata=origin.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
|
||||
|
||||
def _build_subagent_prompt(self, task: str) -> str:
|
||||
"""Build a focused system prompt for the subagent."""
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
|
||||
return f"""# Subagent
|
||||
|
||||
## Current Time
|
||||
{now} ({tz})
|
||||
|
||||
You are a subagent spawned by the main agent to complete a specific task.
|
||||
|
||||
## Rules
|
||||
1. Stay focused - complete only the assigned task, nothing else
|
||||
2. Your final response will be reported back to the main agent
|
||||
3. Do not initiate conversations or take on side tasks
|
||||
4. Be concise but informative in your findings
|
||||
1. Run `exec date` as your very first action to get the current date and time
|
||||
2. Stay focused - complete only the assigned task, nothing else
|
||||
3. Your final response will be reported back to the main agent
|
||||
4. Do not initiate conversations or take on side tasks
|
||||
5. Be concise but informative in your findings
|
||||
|
||||
## What You Can Do
|
||||
- Read and write files in the workspace
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
- Send messages to the main agent (via the message tool)
|
||||
- Spawn child subagents for parallel tasks
|
||||
- Complete the task thoroughly
|
||||
|
||||
## What You Cannot Do
|
||||
- Send messages directly to users (no message tool available)
|
||||
- Spawn other subagents
|
||||
- Access the main agent's conversation history
|
||||
- Access the main agent's conversation history directly
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {self.workspace}
|
||||
@@ -248,6 +277,25 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
|
||||
|
||||
When you have completed the task, provide a clear summary of your findings or actions."""
|
||||
|
||||
async def wait_for(self, task_ids: list[str]) -> str:
|
||||
"""Wait for specified child subagents to complete and return their results."""
|
||||
tasks_to_wait = [
|
||||
self._running_tasks[tid]
|
||||
for tid in task_ids
|
||||
if tid in self._running_tasks
|
||||
]
|
||||
if tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
|
||||
results = []
|
||||
for tid in task_ids:
|
||||
result = self._task_results.get(tid)
|
||||
if result is not None:
|
||||
results.append(f"[{tid}]:\n{result}")
|
||||
else:
|
||||
results.append(f"[{tid}]: No result found (invalid ID or task failed before storing)")
|
||||
return "\n\n---\n\n".join(results)
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Anthropic native tools implementation."""
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import (
|
||||
BaseAnthropicTool,
|
||||
ToolResult,
|
||||
CLIResult,
|
||||
ToolError,
|
||||
)
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
|
||||
from nanobot.agent.tools.anthropic.memory import MemoryTool20250818
|
||||
|
||||
__all__ = [
|
||||
"BaseAnthropicTool",
|
||||
"ToolResult",
|
||||
"CLIResult",
|
||||
"ToolError",
|
||||
"BashTool20250124",
|
||||
"EditTool20250728",
|
||||
"ComputerTool20251124",
|
||||
"MemoryTool20250818",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
"""Base classes for Anthropic native tools.
|
||||
|
||||
Ported from anthropic-quickstarts/computer-use-demo.
|
||||
"""
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ToolResult:
|
||||
"""Result from tool execution.
|
||||
|
||||
Structured result that can contain text output, errors, images, and system messages.
|
||||
"""
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
base64_image: str | None = None
|
||||
system: str | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class CLIResult:
|
||||
"""Result from CLI-style tools (like text editor).
|
||||
|
||||
Similar to ToolResult but simpler for text-only tools.
|
||||
"""
|
||||
exit_code: int
|
||||
output: str
|
||||
error: str
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Exception raised by tool execution."""
|
||||
pass
|
||||
|
||||
|
||||
class BaseAnthropicTool(metaclass=ABCMeta):
|
||||
"""Base class for Anthropic native tools.
|
||||
|
||||
Native tools are version-coupled to model training and don't require schemas.
|
||||
"""
|
||||
|
||||
api_type: str # e.g., "bash_20250124"
|
||||
name: str # e.g., "bash"
|
||||
beta_flag: str | None = None # e.g., "computer-use-2025-11-24"
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(self, **kwargs: Any) -> ToolResult | CLIResult:
|
||||
"""Execute the tool.
|
||||
|
||||
Args:
|
||||
**kwargs: Tool-specific parameters
|
||||
|
||||
Returns:
|
||||
ToolResult or CLIResult with execution output
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Return tool definition for API.
|
||||
|
||||
Returns:
|
||||
Dict with type and name (no schema for native tools)
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,164 @@
|
||||
"""BashTool20250124 - Persistent bash session with async buffer polling.
|
||||
|
||||
Based on Anthropic's reference implementation from anthropic-quickstarts.
|
||||
Uses asyncio.create_subprocess_shell + direct buffer reads instead of
|
||||
threaded readline, which avoids exhausting the default ThreadPoolExecutor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult, ToolError
|
||||
|
||||
|
||||
class _BashSession:
|
||||
"""A session of a bash shell.
|
||||
|
||||
Uses asyncio subprocess with direct buffer polling — no threads.
|
||||
Based on anthropics/anthropic-quickstarts computer-use-demo.
|
||||
"""
|
||||
|
||||
command: str = "/bin/bash"
|
||||
_output_delay: float = 0.2 # seconds between buffer polls
|
||||
_timeout: float = 120.0 # seconds
|
||||
_sentinel: str = "<<exit>>"
|
||||
|
||||
def __init__(self):
|
||||
self._started = False
|
||||
self._timed_out = False
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
|
||||
async def start(self):
|
||||
if self._started:
|
||||
return
|
||||
|
||||
self._process = await asyncio.create_subprocess_shell(
|
||||
self.command,
|
||||
preexec_fn=os.setsid,
|
||||
shell=True,
|
||||
bufsize=0,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._started = True
|
||||
|
||||
def stop(self):
|
||||
"""Terminate the bash shell."""
|
||||
if not self._started:
|
||||
return
|
||||
if self._process and self._process.returncode is None:
|
||||
self._process.terminate()
|
||||
|
||||
async def run(self, command: str) -> ToolResult:
|
||||
"""Execute a command in the bash shell."""
|
||||
if not self._started:
|
||||
raise ToolError("Session has not started.")
|
||||
if self._process is None or self._process.returncode is not None:
|
||||
return ToolResult(
|
||||
system="tool must be restarted",
|
||||
error=f"bash has exited with returncode "
|
||||
f"{self._process.returncode if self._process else 'unknown'}",
|
||||
)
|
||||
if self._timed_out:
|
||||
raise ToolError(
|
||||
f"timed out: bash has not returned in {self._timeout} seconds "
|
||||
"and must be restarted",
|
||||
)
|
||||
|
||||
assert self._process.stdin
|
||||
assert self._process.stdout
|
||||
assert self._process.stderr
|
||||
|
||||
# Send command + sentinel on its own line so heredoc terminators
|
||||
# aren't corrupted (EOF; echo '...' ≠ EOF)
|
||||
self._process.stdin.write(
|
||||
command.encode() + f"\necho '{self._sentinel}'\n".encode()
|
||||
)
|
||||
await self._process.stdin.drain()
|
||||
|
||||
# Poll stdout buffer until sentinel appears — no threads involved
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout):
|
||||
while True:
|
||||
await asyncio.sleep(self._output_delay)
|
||||
output = self._process.stdout._buffer.decode()
|
||||
if self._sentinel in output:
|
||||
output = output[: output.index(self._sentinel)]
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
self._timed_out = True
|
||||
raise ToolError(
|
||||
f"timed out: bash has not returned in {self._timeout} seconds "
|
||||
"and must be restarted",
|
||||
) from None
|
||||
|
||||
if output.endswith("\n"):
|
||||
output = output[:-1]
|
||||
|
||||
error = self._process.stderr._buffer.decode()
|
||||
if error.endswith("\n"):
|
||||
error = error[:-1]
|
||||
|
||||
# Clear buffers for next command
|
||||
self._process.stdout._buffer.clear()
|
||||
self._process.stderr._buffer.clear()
|
||||
|
||||
# Return as ToolResult (our loop handles this type)
|
||||
if error and output:
|
||||
return ToolResult(output=f"{output}\n\nstderr: {error}")
|
||||
elif error:
|
||||
return ToolResult(output=error)
|
||||
else:
|
||||
return ToolResult(output=output if output else "(no output)")
|
||||
|
||||
|
||||
class BashTool20250124(BaseAnthropicTool):
|
||||
"""Anthropic's native bash_20250124 tool with persistent session.
|
||||
|
||||
Executes bash commands in a long-running shell session. Environment
|
||||
variables and working directory persist across commands.
|
||||
|
||||
Parameters:
|
||||
command (str, optional): Bash command to execute
|
||||
restart (bool, optional): Restart the bash session (clears state)
|
||||
"""
|
||||
|
||||
api_type: Literal["bash_20250124"] = "bash_20250124"
|
||||
name: Literal["bash"] = "bash"
|
||||
beta_flag: str | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._session: _BashSession | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: str | None = None,
|
||||
restart: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> ToolResult:
|
||||
if restart:
|
||||
if self._session:
|
||||
self._session.stop()
|
||||
self._session = _BashSession()
|
||||
await self._session.start()
|
||||
return ToolResult(system="tool has been restarted.")
|
||||
|
||||
if self._session is None:
|
||||
self._session = _BashSession()
|
||||
await self._session.start()
|
||||
|
||||
if command is not None:
|
||||
try:
|
||||
return await self._session.run(command)
|
||||
except ToolError as e:
|
||||
return ToolResult(error=str(e))
|
||||
|
||||
return ToolResult(error="Either 'command' or 'restart=True' must be provided.")
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Computer control tool for VNC desktop interaction.
|
||||
|
||||
VNC-based implementation of Anthropic's computer_20251124 native tool.
|
||||
|
||||
CRITICAL vncdotool syntax:
|
||||
- Use :: (double colon) for port numbers: '172.17.0.1::5900'
|
||||
- Single colon means display number (port = display + 5900)
|
||||
- vncdotool API is synchronous, wrapped in asyncio.to_thread()
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Literal, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
from vncdotool import api as vnc_api
|
||||
except ImportError:
|
||||
vnc_api = None
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, ToolResult
|
||||
|
||||
|
||||
class ComputerTool20251124(BaseAnthropicTool):
|
||||
"""Computer control via VNC for desktop interaction.
|
||||
|
||||
Supports keyboard input, mouse control, and screenshots.
|
||||
"""
|
||||
|
||||
api_type: Literal["computer_20251124"] = "computer_20251124"
|
||||
name: Literal["computer"] = "computer"
|
||||
beta_flag: str = "computer-use-2025-11-24"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vnc_host: str = "172.17.0.1",
|
||||
vnc_port: int = 5900,
|
||||
vnc_username: str = "deckedmoth",
|
||||
vnc_password: str = "123",
|
||||
display_width_px: int = 1024,
|
||||
display_height_px: int = 768,
|
||||
):
|
||||
"""Initialize computer tool.
|
||||
|
||||
Args:
|
||||
vnc_host: VNC server hostname/IP
|
||||
vnc_port: VNC server port
|
||||
vnc_username: VNC username (if required)
|
||||
vnc_password: VNC password (if required)
|
||||
display_width_px: Display width for screenshots
|
||||
display_height_px: Display height for screenshots
|
||||
"""
|
||||
if vnc_api is None:
|
||||
raise ImportError(
|
||||
"vncdotool is required for computer tool. "
|
||||
"Install with: pip install vncdotool"
|
||||
)
|
||||
|
||||
self.vnc_host = vnc_host
|
||||
self.vnc_port = vnc_port
|
||||
self.vnc_username = vnc_username
|
||||
self.vnc_password = vnc_password
|
||||
self.display_width_px = display_width_px
|
||||
self.display_height_px = display_height_px
|
||||
|
||||
def to_params(self):
|
||||
"""Return tool definition for API.
|
||||
|
||||
NOTE: display_width_px, display_height_px, and enable_zoom are NOT
|
||||
valid parameters for computer_20251124 and cause API hangs if sent.
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
action: Literal[
|
||||
# Basic actions
|
||||
"key", "type", "mouse_move", "screenshot", "cursor_position",
|
||||
# Click actions
|
||||
"left_click", "right_click", "middle_click", "double_click", "triple_click",
|
||||
# Advanced mouse
|
||||
"left_mouse_down", "left_mouse_up", "left_click_drag",
|
||||
# Scroll
|
||||
"scroll",
|
||||
# Advanced keyboard
|
||||
"hold_key", "paste", # paste bypasses keyboard layout issues
|
||||
# Utility
|
||||
"wait",
|
||||
# Zoom (computer_20251124)
|
||||
"zoom"
|
||||
] | None = None,
|
||||
coordinate: list[int] | None = None,
|
||||
text: str | None = None,
|
||||
# Additional parameters for specific actions
|
||||
start_coordinate: list[int] | None = None, # For left_click_drag
|
||||
scroll_direction: Literal["up", "down", "left", "right"] | None = None, # For scroll
|
||||
scroll_amount: int | None = None, # For scroll
|
||||
duration: float | None = None, # For hold_key, wait
|
||||
region: list[int] | None = None, # For zoom [x1, y1, x2, y2]
|
||||
key: str | None = None, # Modifier key for clicks/scroll
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
"""Execute computer control action.
|
||||
|
||||
Args:
|
||||
action: Action to perform
|
||||
coordinate: [x, y] coordinates for mouse actions
|
||||
text: Text to type or key name to press
|
||||
|
||||
Returns:
|
||||
ToolResult with action result or screenshot
|
||||
"""
|
||||
if not action:
|
||||
return ToolResult(error="No action provided")
|
||||
|
||||
try:
|
||||
# Connect with correct syntax: double colon (::) for port number
|
||||
result = await asyncio.to_thread(
|
||||
self._execute_vnc_action,
|
||||
action,
|
||||
coordinate,
|
||||
text,
|
||||
start_coordinate,
|
||||
scroll_direction,
|
||||
scroll_amount,
|
||||
duration,
|
||||
region,
|
||||
key
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Computer tool error: {e}")
|
||||
return ToolResult(error=str(e))
|
||||
|
||||
def _execute_vnc_action(
|
||||
self,
|
||||
action: str,
|
||||
coordinate: list[int] | None,
|
||||
text: str | None,
|
||||
start_coordinate: list[int] | None,
|
||||
scroll_direction: str | None,
|
||||
scroll_amount: int | None,
|
||||
duration: float | None,
|
||||
region: list[int] | None,
|
||||
modifier_key: str | None
|
||||
) -> ToolResult:
|
||||
"""Execute VNC action in thread (vncdotool is synchronous).
|
||||
|
||||
CRITICAL: vncdotool syntax requires :: (double colon) for port numbers!
|
||||
Single colon means display number: 172.17.0.1:5900 = display 5900 (port 11800)
|
||||
Double colon means port number: 172.17.0.1::5900 = port 5900
|
||||
"""
|
||||
# Connect with DOUBLE colon for port
|
||||
server = f"{self.vnc_host}::{self.vnc_port}"
|
||||
client = vnc_api.connect(server, username=self.vnc_username, password=self.vnc_password)
|
||||
|
||||
try:
|
||||
# Basic actions
|
||||
if action == "screenshot":
|
||||
return self._screenshot(client)
|
||||
elif action == "key":
|
||||
return self._key(client, text or "")
|
||||
elif action == "type":
|
||||
return self._type(client, text or "")
|
||||
elif action == "mouse_move":
|
||||
return self._mouse_move(client, coordinate or [0, 0])
|
||||
elif action == "cursor_position":
|
||||
return ToolResult(output="Cursor position tracking not implemented")
|
||||
|
||||
# Click actions
|
||||
elif action == "left_click":
|
||||
return self._left_click(client, coordinate, modifier_key)
|
||||
elif action == "right_click":
|
||||
return self._right_click(client, coordinate, modifier_key)
|
||||
elif action == "middle_click":
|
||||
return self._middle_click(client, coordinate, modifier_key)
|
||||
elif action == "double_click":
|
||||
return self._double_click(client, coordinate, modifier_key)
|
||||
elif action == "triple_click":
|
||||
return self._triple_click(client, coordinate, modifier_key)
|
||||
|
||||
# Advanced mouse
|
||||
elif action == "left_mouse_down":
|
||||
return self._left_mouse_down(client)
|
||||
elif action == "left_mouse_up":
|
||||
return self._left_mouse_up(client)
|
||||
elif action == "left_click_drag":
|
||||
return self._left_click_drag(client, start_coordinate, coordinate)
|
||||
|
||||
# Scroll
|
||||
elif action == "scroll":
|
||||
return self._scroll(client, coordinate, scroll_direction, scroll_amount, modifier_key)
|
||||
|
||||
# Advanced keyboard
|
||||
elif action == "hold_key":
|
||||
return self._hold_key(client, text, duration)
|
||||
elif action == "paste":
|
||||
return self._paste(client, text)
|
||||
|
||||
# Utility
|
||||
elif action == "wait":
|
||||
return self._wait(duration)
|
||||
|
||||
# Zoom
|
||||
elif action == "zoom":
|
||||
return self._zoom(client, region)
|
||||
|
||||
else:
|
||||
return ToolResult(error=f"Unknown action: {action}")
|
||||
finally:
|
||||
client.disconnect()
|
||||
|
||||
def _screenshot(self, client) -> ToolResult:
|
||||
"""Capture screenshot.
|
||||
|
||||
captureScreen() requires a file path, can't use BytesIO without format.
|
||||
Use temp file then read as bytes.
|
||||
|
||||
IMPORTANT: VNC display may be in sleep mode. Wake it up before screenshot.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Wake up display (move mouse + press space to wake screensaver)
|
||||
client.mouseMove(self.display_width_px // 2, self.display_height_px // 2)
|
||||
time.sleep(0.1)
|
||||
client.keyPress('space')
|
||||
time.sleep(0.5) # Wait for display to wake
|
||||
|
||||
# Request framebuffer update
|
||||
client.refreshScreen()
|
||||
time.sleep(0.5) # Wait for framebuffer refresh
|
||||
|
||||
# Capture screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
client.captureScreen(tmp_path)
|
||||
png_data = Path(tmp_path).read_bytes()
|
||||
Path(tmp_path).unlink() # Clean up
|
||||
|
||||
base64_data = base64.b64encode(png_data).decode()
|
||||
return ToolResult(base64_image=base64_data)
|
||||
|
||||
def _key(self, client, text: str) -> ToolResult:
|
||||
"""Press a key.
|
||||
|
||||
Use lowercase names from KEYMAP: 'esc', 'return', 'tab', etc.
|
||||
Single characters work directly: 'a', 'b', '1', etc.
|
||||
"""
|
||||
client.keyPress(text.lower())
|
||||
return ToolResult(output=f"Pressed key: {text}")
|
||||
|
||||
def _type(self, client, text: str) -> ToolResult:
|
||||
"""Type text character by character."""
|
||||
for char in text:
|
||||
client.keyPress(char)
|
||||
return ToolResult(output=f"Typed: {text}")
|
||||
|
||||
def _mouse_move(self, client, coordinate: list[int]) -> ToolResult:
|
||||
"""Move mouse to coordinate."""
|
||||
x, y = coordinate[0], coordinate[1]
|
||||
client.mouseMove(x, y)
|
||||
return ToolResult(output=f"Moved mouse to ({x}, {y})")
|
||||
|
||||
def _left_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Left click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(1) # 1 = left button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Left clicked")
|
||||
|
||||
def _right_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Right click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(3) # 3 = right button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Right clicked")
|
||||
|
||||
def _middle_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Middle click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(2) # 2 = middle button
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Middle clicked")
|
||||
|
||||
def _double_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Double click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
client.mousePress(1)
|
||||
import time
|
||||
time.sleep(0.01) # 10ms delay between clicks
|
||||
client.mousePress(1)
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Double clicked")
|
||||
|
||||
def _triple_click(self, client, coordinate: list[int] | None = None, modifier_key: str | None = None) -> ToolResult:
|
||||
"""Triple click at coordinate (or current position)."""
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
import time
|
||||
for _ in range(3):
|
||||
client.mousePress(1)
|
||||
time.sleep(0.01) # 10ms delay between clicks
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
return ToolResult(output="Triple clicked")
|
||||
|
||||
def _left_mouse_down(self, client) -> ToolResult:
|
||||
"""Press and hold left mouse button."""
|
||||
client.mouseDown(1)
|
||||
return ToolResult(output="Left mouse button down")
|
||||
|
||||
def _left_mouse_up(self, client) -> ToolResult:
|
||||
"""Release left mouse button."""
|
||||
client.mouseUp(1)
|
||||
return ToolResult(output="Left mouse button up")
|
||||
|
||||
def _left_click_drag(self, client, start_coordinate: list[int] | None, end_coordinate: list[int] | None) -> ToolResult:
|
||||
"""Drag from start to end coordinate."""
|
||||
if not start_coordinate or not end_coordinate:
|
||||
return ToolResult(error="Both start_coordinate and coordinate required for left_click_drag")
|
||||
|
||||
start_x, start_y = start_coordinate[0], start_coordinate[1]
|
||||
end_x, end_y = end_coordinate[0], end_coordinate[1]
|
||||
|
||||
client.mouseMove(start_x, start_y)
|
||||
client.mouseDown(1)
|
||||
client.mouseDrag(end_x, end_y) # vncdotool's mouseDrag method
|
||||
client.mouseUp(1)
|
||||
return ToolResult(output=f"Dragged from ({start_x}, {start_y}) to ({end_x}, {end_y})")
|
||||
|
||||
def _scroll(
|
||||
self,
|
||||
client,
|
||||
coordinate: list[int] | None,
|
||||
scroll_direction: str | None,
|
||||
scroll_amount: int | None,
|
||||
modifier_key: str | None
|
||||
) -> ToolResult:
|
||||
"""Scroll in specified direction."""
|
||||
if not scroll_direction or scroll_direction not in ("up", "down", "left", "right"):
|
||||
return ToolResult(error=f"scroll_direction must be 'up', 'down', 'left', or 'right'")
|
||||
|
||||
amount = scroll_amount or 5 # Default scroll amount
|
||||
|
||||
# Move to coordinate if specified
|
||||
if coordinate:
|
||||
client.mouseMove(coordinate[0], coordinate[1])
|
||||
|
||||
# VNC scroll buttons: 4=up, 5=down, 6=left, 7=right
|
||||
scroll_button = {"up": 4, "down": 5, "left": 6, "right": 7}[scroll_direction]
|
||||
|
||||
# Hold modifier key if specified
|
||||
if modifier_key:
|
||||
client.keyDown(modifier_key.lower())
|
||||
|
||||
# Scroll by pressing scroll button multiple times
|
||||
import time
|
||||
for _ in range(amount):
|
||||
client.mousePress(scroll_button)
|
||||
time.sleep(0.05) # Small delay between scroll events
|
||||
|
||||
if modifier_key:
|
||||
client.keyUp(modifier_key.lower())
|
||||
|
||||
return ToolResult(output=f"Scrolled {scroll_direction} {amount} times")
|
||||
|
||||
def _hold_key(self, client, text: str | None, duration: float | None) -> ToolResult:
|
||||
"""Hold a key for specified duration."""
|
||||
if not text:
|
||||
return ToolResult(error="text (key name) required for hold_key")
|
||||
|
||||
hold_duration = duration or 1.0 # Default 1 second
|
||||
if hold_duration < 0 or hold_duration > 100:
|
||||
return ToolResult(error="duration must be between 0 and 100 seconds")
|
||||
|
||||
import time
|
||||
client.keyDown(text.lower())
|
||||
time.sleep(hold_duration)
|
||||
client.keyUp(text.lower())
|
||||
|
||||
return ToolResult(output=f"Held key '{text}' for {hold_duration}s")
|
||||
|
||||
def _paste(self, client, text: str | None) -> ToolResult:
|
||||
"""Paste text via clipboard (bypasses keyboard layout issues).
|
||||
|
||||
This uses VNC clipboard to send text, avoiding keyboard layout mismatches
|
||||
where characters like ':' become ';' due to different keyboard mappings.
|
||||
"""
|
||||
if not text:
|
||||
return ToolResult(error="text required for paste")
|
||||
|
||||
# Send text via clipboard and trigger paste
|
||||
client.paste(text)
|
||||
return ToolResult(output=f"Pasted via clipboard: {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||
|
||||
def _wait(self, duration: float | None) -> ToolResult:
|
||||
"""Wait for specified duration."""
|
||||
wait_duration = duration or 1.0
|
||||
if wait_duration < 0 or wait_duration > 100:
|
||||
return ToolResult(error="duration must be between 0 and 100 seconds")
|
||||
|
||||
import time
|
||||
time.sleep(wait_duration)
|
||||
return ToolResult(output=f"Waited {wait_duration}s")
|
||||
|
||||
def _zoom(self, client, region: list[int] | None) -> ToolResult:
|
||||
"""Zoom into specified region and capture screenshot.
|
||||
|
||||
Region format: [x1, y1, x2, y2] - top-left and bottom-right corners.
|
||||
"""
|
||||
if not region or len(region) != 4:
|
||||
return ToolResult(error="region must be [x1, y1, x2, y2]")
|
||||
|
||||
# Take full screenshot first
|
||||
import time
|
||||
from PIL import Image
|
||||
|
||||
# Wake up display
|
||||
client.mouseMove(self.display_width_px // 2, self.display_height_px // 2)
|
||||
time.sleep(0.1)
|
||||
client.keyPress('space')
|
||||
time.sleep(0.5)
|
||||
client.refreshScreen()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Capture screenshot
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
client.captureScreen(tmp_path)
|
||||
|
||||
# Crop to region
|
||||
img = Image.open(tmp_path)
|
||||
x1, y1, x2, y2 = region
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
|
||||
# Save cropped image
|
||||
cropped_path = tmp_path.replace('.png', '_cropped.png')
|
||||
cropped.save(cropped_path)
|
||||
|
||||
# Read and encode
|
||||
png_data = Path(cropped_path).read_bytes()
|
||||
Path(tmp_path).unlink() # Clean up original
|
||||
Path(cropped_path).unlink() # Clean up cropped
|
||||
|
||||
base64_data = base64.b64encode(png_data).decode()
|
||||
return ToolResult(base64_image=base64_data)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
EditTool20250728 - File editor with view/create/str_replace/insert commands.
|
||||
|
||||
Anthropic's native trained tool for file editing operations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from .base import BaseAnthropicTool, CLIResult
|
||||
|
||||
|
||||
class EditTool20250728(BaseAnthropicTool):
|
||||
"""
|
||||
File editor supporting view, create, str_replace, and insert operations.
|
||||
|
||||
Trained by Anthropic, this tool provides comprehensive file editing
|
||||
capabilities with strict safety checks.
|
||||
"""
|
||||
|
||||
api_type: Literal["text_editor_20250728"] = "text_editor_20250728"
|
||||
name: Literal["str_replace_based_edit_tool"] = "str_replace_based_edit_tool"
|
||||
beta_flag: str | None = None
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: Literal["view", "create", "str_replace", "insert"],
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CLIResult:
|
||||
"""
|
||||
Execute a file editing command.
|
||||
|
||||
Args:
|
||||
command: The operation to perform
|
||||
path: Absolute path to the file
|
||||
file_text: Full file content (for create)
|
||||
old_str: String to replace (for str_replace)
|
||||
new_str: Replacement string (for str_replace/insert)
|
||||
insert_line: Line number to insert at (for insert)
|
||||
view_range: [start, end] line range (for view)
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
CLIResult with exit code, output, and error
|
||||
"""
|
||||
# Validate absolute path
|
||||
file_path = Path(path)
|
||||
if not file_path.is_absolute():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: path must be absolute, got: {path}"
|
||||
)
|
||||
|
||||
try:
|
||||
if command == "view":
|
||||
return await self._view(file_path, view_range)
|
||||
elif command == "create":
|
||||
return await self._create(file_path, file_text)
|
||||
elif command == "str_replace":
|
||||
return await self._str_replace(file_path, old_str, new_str)
|
||||
elif command == "insert":
|
||||
return await self._insert(file_path, insert_line, new_str)
|
||||
else:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: unknown command: {command}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {str(e)}"
|
||||
)
|
||||
|
||||
async def _view(self, path: Path, view_range: list[int] | None) -> CLIResult:
|
||||
"""View file contents with line numbers."""
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Apply view range if specified
|
||||
if view_range:
|
||||
start, end = view_range
|
||||
lines = lines[start - 1:end]
|
||||
start_num = start
|
||||
else:
|
||||
start_num = 1
|
||||
|
||||
# Format with line numbers
|
||||
formatted_lines = [
|
||||
f"{start_num + i}|{line.rstrip()}"
|
||||
for i, line in enumerate(lines)
|
||||
]
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="\n".join(formatted_lines),
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _create(self, path: Path, file_text: str | None) -> CLIResult:
|
||||
"""Create a new file with the given content."""
|
||||
if file_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: file_text is required for create command"
|
||||
)
|
||||
|
||||
if path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file already exists: {path}"
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the file
|
||||
path.write_text(file_text)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"File created: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _str_replace(
|
||||
self,
|
||||
path: Path,
|
||||
old_str: str | None,
|
||||
new_str: str | None
|
||||
) -> CLIResult:
|
||||
"""Replace a unique occurrence of old_str with new_str."""
|
||||
if old_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_str is required for str_replace command"
|
||||
)
|
||||
|
||||
if new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: new_str is required for str_replace command"
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
|
||||
# Check for unique match
|
||||
count = content.count(old_str)
|
||||
if count == 0:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: old_str not found in file: {old_str!r}"
|
||||
)
|
||||
elif count > 1:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: old_str must match exactly once, found {count} matches"
|
||||
)
|
||||
|
||||
# Perform replacement
|
||||
new_content = content.replace(old_str, new_str)
|
||||
path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Replaced 1 occurrence in: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _insert(
|
||||
self,
|
||||
path: Path,
|
||||
insert_line: int | None,
|
||||
new_str: str | None
|
||||
) -> CLIResult:
|
||||
"""Insert new_str at the specified line number."""
|
||||
if insert_line is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: insert_line is required for insert command"
|
||||
)
|
||||
|
||||
if new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: new_str is required for insert command"
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: file not found: {path}"
|
||||
)
|
||||
|
||||
content = path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Validate line number
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: insert_line {insert_line} out of range [0, {len(lines)}]"
|
||||
)
|
||||
|
||||
# Insert the new string
|
||||
lines.insert(insert_line, new_str)
|
||||
new_content = "".join(lines)
|
||||
path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Inserted text at line {insert_line} in: {path}",
|
||||
error=""
|
||||
)
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API with text_editor_20250728 type
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
"""MemoryTool20250818 - Anthropic's native memory tool.
|
||||
|
||||
Enables Claude to create, read, update, and delete files in a persistent
|
||||
/memories directory across conversations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from nanobot.agent.tools.anthropic.base import BaseAnthropicTool, CLIResult
|
||||
|
||||
|
||||
class MemoryTool20250818(BaseAnthropicTool):
|
||||
"""Anthropic's native memory_20250818 tool.
|
||||
|
||||
Client-side tool for persistent memory storage across conversations.
|
||||
All operations are restricted to the /memories directory.
|
||||
|
||||
Commands:
|
||||
- view: Show directory contents or file contents with line numbers
|
||||
- create: Create a new file with content
|
||||
- str_replace: Replace unique text occurrence in a file
|
||||
- insert: Insert text at a specific line number
|
||||
- delete: Delete a file or directory
|
||||
- rename: Rename or move a file/directory
|
||||
"""
|
||||
|
||||
api_type: Literal["memory_20250818"] = "memory_20250818"
|
||||
name: Literal["memory"] = "memory"
|
||||
beta_flag: str = "context-management-2025-06-27"
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
"""Initialize Memory tool.
|
||||
|
||||
Args:
|
||||
workspace: Root workspace directory
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.memories_dir = workspace / "memories"
|
||||
self.memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _validate_memory_path(self, path: str) -> Path:
|
||||
"""Validate and resolve path to prevent directory traversal.
|
||||
|
||||
Args:
|
||||
path: Path string starting with /memories
|
||||
|
||||
Returns:
|
||||
Validated absolute Path within memories directory
|
||||
|
||||
Raises:
|
||||
ValueError: If path is invalid or escapes /memories directory
|
||||
"""
|
||||
# Reject paths not starting with /memories
|
||||
if not path.startswith("/memories"):
|
||||
raise ValueError(f"Path must start with /memories, got: {path}")
|
||||
|
||||
# Resolve to absolute path within workspace
|
||||
# lstrip("/") removes leading slash: "/memories/file.txt" -> "memories/file.txt"
|
||||
relative_path = path.lstrip("/")
|
||||
full_path = (self.workspace / relative_path).resolve()
|
||||
|
||||
# Verify resolved path is within memories directory
|
||||
memories_dir_resolved = self.memories_dir.resolve()
|
||||
try:
|
||||
full_path.relative_to(memories_dir_resolved)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path escapes /memories directory: {path}")
|
||||
|
||||
return full_path
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
command: Literal["view", "create", "str_replace", "insert", "delete", "rename"],
|
||||
path: str | None = None,
|
||||
old_path: str | None = None,
|
||||
new_path: str | None = None,
|
||||
file_text: str | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
insert_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CLIResult:
|
||||
"""Execute memory command.
|
||||
|
||||
Args:
|
||||
command: Command to execute
|
||||
path: File/directory path (for view/create/str_replace/insert/delete)
|
||||
old_path: Source path (for rename)
|
||||
new_path: Destination path (for rename)
|
||||
file_text: File content (for create)
|
||||
old_str: Text to find (for str_replace)
|
||||
new_str: Replacement text (for str_replace)
|
||||
insert_line: Line number to insert at (for insert)
|
||||
insert_text: Text to insert (for insert)
|
||||
view_range: [start_line, end_line] for view
|
||||
**kwargs: Additional arguments (ignored)
|
||||
|
||||
Returns:
|
||||
CLIResult with command output or error
|
||||
"""
|
||||
try:
|
||||
if command == "view":
|
||||
return await self._view(path, view_range)
|
||||
elif command == "create":
|
||||
return await self._create(path, file_text)
|
||||
elif command == "str_replace":
|
||||
return await self._str_replace(path, old_str, new_str)
|
||||
elif command == "insert":
|
||||
return await self._insert(path, insert_line, insert_text)
|
||||
elif command == "delete":
|
||||
return await self._delete(path)
|
||||
elif command == "rename":
|
||||
return await self._rename(old_path, new_path)
|
||||
else:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Unknown command: {command}"
|
||||
)
|
||||
except ValueError as e:
|
||||
# Path security error
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: {e}"
|
||||
)
|
||||
|
||||
async def _view(
|
||||
self,
|
||||
path: str | None,
|
||||
view_range: list[int] | None = None,
|
||||
) -> CLIResult:
|
||||
"""View directory listing or file contents.
|
||||
|
||||
Args:
|
||||
path: Path to view
|
||||
view_range: Optional [start_line, end_line] for file viewing (1-indexed)
|
||||
|
||||
Returns:
|
||||
CLIResult with directory listing or file contents
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for view command"
|
||||
)
|
||||
|
||||
path_str = path # Keep original for error messages
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
# Directory listing
|
||||
if validated_path.is_dir():
|
||||
return await self._view_directory(validated_path, path_str)
|
||||
|
||||
# File viewing
|
||||
if not validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Read file
|
||||
try:
|
||||
content = validated_path.read_text()
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error reading file: {e}"
|
||||
)
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Check line limit
|
||||
if len(lines) > 999_999:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"File {path_str} exceeds maximum line limit of 999,999 lines."
|
||||
)
|
||||
|
||||
# Apply view_range if specified
|
||||
if view_range:
|
||||
start, end = view_range
|
||||
# Convert to 0-indexed, clamp to valid range
|
||||
start_idx = max(0, start - 1)
|
||||
end_idx = min(len(lines), end)
|
||||
lines_to_show = lines[start_idx:end_idx]
|
||||
start_num = start
|
||||
else:
|
||||
lines_to_show = lines
|
||||
start_num = 1
|
||||
|
||||
# Format with line numbers (6 chars, right-aligned, tab-separated)
|
||||
formatted_lines = []
|
||||
for i, line in enumerate(lines_to_show):
|
||||
line_num = start_num + i
|
||||
# Remove trailing newline for display
|
||||
line_content = line.rstrip("\n")
|
||||
formatted_lines.append(f"{line_num:6d}\t{line_content}")
|
||||
|
||||
output = f"Here's the content of {path_str} with line numbers:\n"
|
||||
output += "\n".join(formatted_lines)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=output,
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _view_directory(self, path: Path, path_str: str) -> CLIResult:
|
||||
"""View directory listing up to 2 levels deep.
|
||||
|
||||
Args:
|
||||
path: Validated Path object
|
||||
path_str: Original path string for display
|
||||
|
||||
Returns:
|
||||
CLIResult with directory listing
|
||||
"""
|
||||
import os
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Convert bytes to human-readable format."""
|
||||
for unit in ['B', 'K', 'M', 'G', 'T']:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes:.1f}{unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f}P"
|
||||
|
||||
lines = []
|
||||
header = f"Here're the files and directories up to 2 levels deep in {path_str}, excluding hidden items and node_modules:"
|
||||
lines.append(header)
|
||||
|
||||
# Walk directory tree (max depth 2)
|
||||
base_depth = str(path).count(os.sep)
|
||||
|
||||
for root, dirs, files in os.walk(path):
|
||||
# Calculate current depth
|
||||
current_depth = str(root).count(os.sep) - base_depth
|
||||
|
||||
# Filter out hidden items and node_modules at this level
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules']
|
||||
|
||||
# Stop if we've gone too deep
|
||||
if current_depth >= 2:
|
||||
dirs.clear() # Don't recurse further
|
||||
continue
|
||||
|
||||
# Get size and add directory entry
|
||||
root_path = Path(root)
|
||||
try:
|
||||
# Directory size (sum of all files within, or 4K default)
|
||||
dir_size = sum(f.stat().st_size for f in root_path.rglob('*') if f.is_file())
|
||||
if dir_size == 0:
|
||||
dir_size = 4096 # Default directory size
|
||||
size_str = format_size(dir_size)
|
||||
|
||||
# Convert absolute path to /memories/... format
|
||||
relative = root_path.relative_to(self.workspace)
|
||||
display_path = "/" + str(relative).replace(os.sep, "/")
|
||||
|
||||
lines.append(f"{size_str}\t{display_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add file entries at this level
|
||||
for filename in sorted(files):
|
||||
if filename.startswith('.'):
|
||||
continue # Skip hidden files
|
||||
|
||||
file_path = root_path / filename
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
size_str = format_size(file_size)
|
||||
|
||||
# Convert to /memories/... format
|
||||
relative = file_path.relative_to(self.workspace)
|
||||
display_path = "/" + str(relative).replace(os.sep, "/")
|
||||
|
||||
lines.append(f"{size_str}\t{display_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="\n".join(lines),
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _create(
|
||||
self,
|
||||
path: str | None,
|
||||
file_text: str | None,
|
||||
) -> CLIResult:
|
||||
"""Create a new file with content.
|
||||
|
||||
Args:
|
||||
path: File path to create
|
||||
file_text: Content to write
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for create command"
|
||||
)
|
||||
|
||||
if file_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: file_text is required for create command"
|
||||
)
|
||||
|
||||
path_str = path # Keep original for error messages
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
# Check if file already exists
|
||||
if validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: File {path_str} already exists"
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
try:
|
||||
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error creating parent directories: {e}"
|
||||
)
|
||||
|
||||
# Write file
|
||||
try:
|
||||
validated_path.write_text(file_text)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error writing file: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"File created successfully at: {path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _str_replace(
|
||||
self,
|
||||
path: str | None,
|
||||
old_str: str | None,
|
||||
new_str: str | None,
|
||||
) -> CLIResult:
|
||||
"""Replace unique occurrence of old_str with new_str."""
|
||||
if path is None or old_str is None or new_str is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_str and new_str are required for str_replace command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists() or validated_path.is_dir():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
content = validated_path.read_text()
|
||||
count = content.count(old_str)
|
||||
|
||||
if count == 0:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path_str}."
|
||||
)
|
||||
elif count > 1:
|
||||
lines = content.splitlines()
|
||||
line_nums = [i + 1 for i, line in enumerate(lines) if old_str in line]
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines: {line_nums}. Please ensure it is unique"
|
||||
)
|
||||
|
||||
new_content = content.replace(old_str, new_str, 1)
|
||||
validated_path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output="The memory file has been edited.",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _insert(
|
||||
self, path: str | None, insert_line: int | None, insert_text: str | None
|
||||
) -> CLIResult:
|
||||
"""Insert text at a specific line number.
|
||||
|
||||
Args:
|
||||
path: File path to modify
|
||||
insert_line: Line number to insert at (0 = beginning)
|
||||
insert_text: Text to insert
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None or insert_line is None or insert_text is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path, insert_line, and insert_text are required for insert command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists() or validated_path.is_dir():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Read current content
|
||||
content = validated_path.read_text()
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Validate insert_line
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Invalid `insert_line` parameter: {insert_line}. It should be within 0 to {len(lines)}"
|
||||
)
|
||||
|
||||
# Insert text at specified line
|
||||
lines.insert(insert_line, insert_text)
|
||||
new_content = "".join(lines)
|
||||
validated_path.write_text(new_content)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"The file {path_str} has been edited.",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _delete(self, path: str | None) -> CLIResult:
|
||||
"""Delete a file or directory.
|
||||
|
||||
Args:
|
||||
path: Path to delete
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: path is required for delete command"
|
||||
)
|
||||
|
||||
path_str = path
|
||||
validated_path = self._validate_memory_path(path)
|
||||
|
||||
if not validated_path.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Delete file or directory
|
||||
try:
|
||||
if validated_path.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(validated_path)
|
||||
else:
|
||||
validated_path.unlink()
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error deleting {path_str}: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Successfully deleted {path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
async def _rename(self, old_path: str | None, new_path: str | None) -> CLIResult:
|
||||
"""Rename or move a file or directory.
|
||||
|
||||
Args:
|
||||
old_path: Source path
|
||||
new_path: Destination path
|
||||
|
||||
Returns:
|
||||
CLIResult with success message or error
|
||||
"""
|
||||
if old_path is None or new_path is None:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error="Error: old_path and new_path are required for rename command"
|
||||
)
|
||||
|
||||
old_path_str = old_path
|
||||
new_path_str = new_path
|
||||
validated_old = self._validate_memory_path(old_path)
|
||||
validated_new = self._validate_memory_path(new_path)
|
||||
|
||||
# Check if source exists
|
||||
if not validated_old.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The path {old_path_str} does not exist. Please provide a valid path."
|
||||
)
|
||||
|
||||
# Check if destination already exists
|
||||
if validated_new.exists():
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error: The destination {new_path_str} already exists. Please provide a different destination."
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
try:
|
||||
validated_new.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error creating parent directories: {e}"
|
||||
)
|
||||
|
||||
# Rename/move
|
||||
try:
|
||||
validated_old.rename(validated_new)
|
||||
except Exception as e:
|
||||
return CLIResult(
|
||||
exit_code=1,
|
||||
output="",
|
||||
error=f"Error renaming {old_path_str}: {e}"
|
||||
)
|
||||
|
||||
return CLIResult(
|
||||
exit_code=0,
|
||||
output=f"Successfully renamed {old_path_str} to {new_path_str}",
|
||||
error=""
|
||||
)
|
||||
|
||||
def to_params(self) -> dict[str, Any]:
|
||||
"""Convert to Anthropic API tool parameter format.
|
||||
|
||||
Returns:
|
||||
Tool definition for Anthropic API
|
||||
"""
|
||||
return {
|
||||
"type": self.api_type,
|
||||
"name": self.name,
|
||||
}
|
||||
@@ -50,6 +50,10 @@ class CronTool(Tool):
|
||||
"type": "string",
|
||||
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
|
||||
},
|
||||
"tz": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
|
||||
@@ -68,30 +72,46 @@ class CronTool(Tool):
|
||||
message: str = "",
|
||||
every_seconds: int | None = None,
|
||||
cron_expr: str | None = None,
|
||||
tz: str | None = None,
|
||||
at: str | None = None,
|
||||
job_id: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
if action == "add":
|
||||
return self._add_job(message, every_seconds, cron_expr, at)
|
||||
return self._add_job(message, every_seconds, cron_expr, tz, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
elif action == "remove":
|
||||
return self._remove_job(job_id)
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
|
||||
def _add_job(
|
||||
self,
|
||||
message: str,
|
||||
every_seconds: int | None,
|
||||
cron_expr: str | None,
|
||||
tz: str | None,
|
||||
at: str | None,
|
||||
) -> str:
|
||||
if not message:
|
||||
return "Error: message is required for add"
|
||||
if not self._channel or not self._chat_id:
|
||||
return "Error: no session context (channel/chat_id)"
|
||||
if tz and not cron_expr:
|
||||
return "Error: tz can only be used with cron_expr"
|
||||
if tz:
|
||||
from zoneinfo import ZoneInfo
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (KeyError, Exception):
|
||||
return f"Error: unknown timezone '{tz}'"
|
||||
|
||||
# Build schedule
|
||||
delete_after = False
|
||||
if every_seconds:
|
||||
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
|
||||
elif cron_expr:
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||
elif at:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(at)
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
"""File system tools: read, write, edit."""
|
||||
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
|
||||
"""Resolve path and optionally enforce directory restriction."""
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())):
|
||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||
def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
|
||||
"""Resolve path against workspace (if relative) and enforce directory restriction."""
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_absolute() and workspace:
|
||||
p = workspace / p
|
||||
resolved = p.resolve()
|
||||
if allowed_dir:
|
||||
try:
|
||||
resolved.relative_to(allowed_dir.resolve())
|
||||
except ValueError:
|
||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||
return resolved
|
||||
|
||||
|
||||
class ReadFileTool(Tool):
|
||||
"""Tool to read file contents."""
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -43,12 +51,12 @@ class ReadFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
if not file_path.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
if not file_path.is_file():
|
||||
return f"Error: Not a file: {path}"
|
||||
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
return content
|
||||
except PermissionError as e:
|
||||
@@ -59,8 +67,9 @@ class ReadFileTool(Tool):
|
||||
|
||||
class WriteFileTool(Tool):
|
||||
"""Tool to write content to a file."""
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -90,10 +99,10 @@ class WriteFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
return f"Successfully wrote {len(content)} bytes to {path}"
|
||||
return f"Successfully wrote {len(content)} bytes to {file_path}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
@@ -102,8 +111,9 @@ class WriteFileTool(Tool):
|
||||
|
||||
class EditFileTool(Tool):
|
||||
"""Tool to edit a file by replacing text."""
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -137,34 +147,57 @@ class EditFileTool(Tool):
|
||||
|
||||
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
file_path = _resolve_path(path, self._allowed_dir)
|
||||
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
if not file_path.exists():
|
||||
return f"Error: File not found: {path}"
|
||||
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
if old_text not in content:
|
||||
return f"Error: old_text not found in file. Make sure it matches exactly."
|
||||
|
||||
return self._not_found_message(old_text, content, path)
|
||||
|
||||
# Count occurrences
|
||||
count = content.count(old_text)
|
||||
if count > 1:
|
||||
return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
|
||||
|
||||
|
||||
new_content = content.replace(old_text, new_text, 1)
|
||||
file_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
return f"Successfully edited {path}"
|
||||
|
||||
return f"Successfully edited {file_path}"
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
except Exception as e:
|
||||
return f"Error editing file: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _not_found_message(old_text: str, content: str, path: str) -> str:
|
||||
"""Build a helpful error when old_text is not found."""
|
||||
lines = content.splitlines(keepends=True)
|
||||
old_lines = old_text.splitlines(keepends=True)
|
||||
window = len(old_lines)
|
||||
|
||||
best_ratio, best_start = 0.0, 0
|
||||
for i in range(max(1, len(lines) - window + 1)):
|
||||
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio, best_start = ratio, i
|
||||
|
||||
if best_ratio > 0.5:
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
old_lines, lines[best_start : best_start + window],
|
||||
fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
|
||||
lineterm="",
|
||||
))
|
||||
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||
|
||||
|
||||
class ListDirTool(Tool):
|
||||
"""Tool to list directory contents."""
|
||||
|
||||
def __init__(self, allowed_dir: Path | None = None):
|
||||
|
||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||
self._workspace = workspace
|
||||
self._allowed_dir = allowed_dir
|
||||
|
||||
@property
|
||||
@@ -190,20 +223,20 @@ class ListDirTool(Tool):
|
||||
|
||||
async def execute(self, path: str, **kwargs: Any) -> str:
|
||||
try:
|
||||
dir_path = _resolve_path(path, self._allowed_dir)
|
||||
dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
|
||||
if not dir_path.exists():
|
||||
return f"Error: Directory not found: {path}"
|
||||
if not dir_path.is_dir():
|
||||
return f"Error: Not a directory: {path}"
|
||||
|
||||
|
||||
items = []
|
||||
for item in sorted(dir_path.iterdir()):
|
||||
prefix = "📁 " if item.is_dir() else "📄 "
|
||||
items.append(f"{prefix}{item.name}")
|
||||
|
||||
|
||||
if not items:
|
||||
return f"Directory {path} is empty"
|
||||
|
||||
|
||||
return "\n".join(items)
|
||||
except PermissionError as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
class MCPToolWrapper(Tool):
|
||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||
|
||||
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
|
||||
self._session = session
|
||||
self._original_name = tool_def.name
|
||||
self._name = f"mcp_{server_name}_{tool_def.name}"
|
||||
self._description = tool_def.description or tool_def.name
|
||||
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
|
||||
self._tool_timeout = tool_timeout
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
self._session.call_tool(self._original_name, arguments=kwargs),
|
||||
timeout=self._tool_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
|
||||
return f"(MCP tool call timed out after {self._tool_timeout}s)"
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
|
||||
|
||||
async def connect_mcp_servers(
|
||||
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
|
||||
) -> None:
|
||||
"""Connect to configured MCP servers and register their tools."""
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
for name, cfg in mcp_servers.items():
|
||||
try:
|
||||
if cfg.command:
|
||||
params = StdioServerParameters(
|
||||
command=cfg.command, args=cfg.args, env=cfg.env or None
|
||||
)
|
||||
read, write = await stack.enter_async_context(stdio_client(params))
|
||||
elif cfg.url:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
# Always provide an explicit httpx client so MCP HTTP transport does not
|
||||
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
headers=cfg.headers or None,
|
||||
follow_redirects=True,
|
||||
timeout=None,
|
||||
)
|
||||
)
|
||||
read, write, _ = await stack.enter_async_context(
|
||||
streamable_http_client(cfg.url, http_client=http_client)
|
||||
)
|
||||
else:
|
||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||
continue
|
||||
|
||||
session = await stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
|
||||
tools = await session.list_tools()
|
||||
for tool_def in tools.tools:
|
||||
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
||||
registry.register(wrapper)
|
||||
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
||||
|
||||
logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
|
||||
except Exception as e:
|
||||
logger.error("MCP server '{}': failed to connect: {}", name, e)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Mem0 memory tools — expose semantic memory to the agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.memory_mem0 import Mem0MemoryStore
|
||||
|
||||
|
||||
class Mem0ToolContext:
|
||||
"""Shared mutable state injected into every mem0 tool."""
|
||||
|
||||
def __init__(self, store: Mem0MemoryStore, consolidate_fn):
|
||||
self.store = store
|
||||
self.consolidate_fn = consolidate_fn # async (session, archive_all) -> None
|
||||
self.user_id: str = "unknown"
|
||||
self.session = None
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, session=None):
|
||||
self.user_id = f"{channel}_{chat_id}"
|
||||
self.session = session
|
||||
|
||||
|
||||
class MemorySearchTool(Tool):
|
||||
"""Search memories semantically."""
|
||||
|
||||
name = "memory_search"
|
||||
description = (
|
||||
"Search your long-term memory for facts relevant to a query. "
|
||||
"Returns the most relevant memories ranked by similarity."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural-language search query",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default 5)",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, query: str, limit: int = 5, **kw: Any) -> str:
|
||||
results = self._ctx.store.search_memories(
|
||||
query=query,
|
||||
user_id=self._ctx.user_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not results:
|
||||
return "No memories found."
|
||||
lines = []
|
||||
for i, mem in enumerate(results, 1):
|
||||
text = mem.get("memory", "")
|
||||
score = mem.get("score")
|
||||
mid = mem.get("id", "")
|
||||
score_str = f" (score: {score:.2f})" if score else ""
|
||||
lines.append(f"{i}. [{mid}] {text}{score_str}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MemoryListTool(Tool):
|
||||
"""List all memories for the current user."""
|
||||
|
||||
name = "memory_list"
|
||||
description = (
|
||||
"List ALL stored memories for the current user. "
|
||||
"Use memory_search for targeted lookup; use this to browse everything."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, **kw: Any) -> str:
|
||||
memories = self._ctx.store.get_all_memories(self._ctx.user_id)
|
||||
if not memories:
|
||||
return "No memories stored."
|
||||
lines = []
|
||||
for i, mem in enumerate(memories, 1):
|
||||
text = mem.get("memory", "")
|
||||
mid = mem.get("id", "")
|
||||
lines.append(f"{i}. [{mid}] {text}")
|
||||
return f"{len(memories)} memories:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
class MemoryAddTool(Tool):
|
||||
"""Add a fact to long-term memory."""
|
||||
|
||||
name = "memory_add"
|
||||
description = (
|
||||
"Store a new fact or piece of information in long-term memory. "
|
||||
"The content will be processed by the extraction LLM and stored as one or more facts."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The fact or information to remember",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, content: str, **kw: Any) -> str:
|
||||
try:
|
||||
result = self._ctx.store.memory.add(
|
||||
[{"role": "user", "content": content}],
|
||||
user_id=self._ctx.user_id,
|
||||
)
|
||||
facts_count = len(result.get("results", [])) if result else 0
|
||||
return f"Added to memory. {facts_count} fact(s) extracted."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_add failed: {e}")
|
||||
return f"Error adding memory: {e}"
|
||||
|
||||
|
||||
class MemoryUpdateTool(Tool):
|
||||
"""Update an existing memory by ID."""
|
||||
|
||||
name = "memory_update"
|
||||
description = (
|
||||
"Update the content of an existing memory. "
|
||||
"Use memory_list or memory_search first to find the memory ID."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory ID to update",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The new content for this memory",
|
||||
},
|
||||
},
|
||||
"required": ["memory_id", "content"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, memory_id: str, content: str, **kw: Any) -> str:
|
||||
try:
|
||||
self._ctx.store.update_memory(memory_id, content)
|
||||
return f"Memory {memory_id} updated."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_update failed: {e}")
|
||||
return f"Error updating memory: {e}"
|
||||
|
||||
|
||||
class MemoryDeleteTool(Tool):
|
||||
"""Delete a memory by ID."""
|
||||
|
||||
name = "memory_delete"
|
||||
description = (
|
||||
"Delete a specific memory by its ID. "
|
||||
"Use memory_list or memory_search first to find the memory ID."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "The memory ID to delete",
|
||||
},
|
||||
},
|
||||
"required": ["memory_id"],
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, memory_id: str, **kw: Any) -> str:
|
||||
try:
|
||||
self._ctx.store.delete_memory(memory_id)
|
||||
return f"Memory {memory_id} deleted."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_delete failed: {e}")
|
||||
return f"Error deleting memory: {e}"
|
||||
|
||||
|
||||
class MemoryConsolidateTool(Tool):
|
||||
"""Trigger memory consolidation for the current session."""
|
||||
|
||||
name = "memory_consolidate"
|
||||
description = (
|
||||
"Extract and store facts from the current conversation into long-term memory. "
|
||||
"Normally this happens automatically on /new, but you can trigger it manually."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
|
||||
def __init__(self, ctx: Mem0ToolContext):
|
||||
self._ctx = ctx
|
||||
|
||||
async def execute(self, **kw: Any) -> str:
|
||||
session = self._ctx.session
|
||||
if not session:
|
||||
return "Error: no active session."
|
||||
try:
|
||||
await self._ctx.consolidate_fn(session, archive_all=False)
|
||||
return "Memory consolidation complete."
|
||||
except Exception as e:
|
||||
logger.error(f"memory_consolidate failed: {e}")
|
||||
return f"Error during consolidation: {e}"
|
||||
@@ -4,20 +4,24 @@ from typing import Any, Callable, Awaitable
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.session import SessionManager
|
||||
|
||||
|
||||
class MessageTool(Tool):
|
||||
"""Tool to send messages to users on chat channels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
self,
|
||||
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
|
||||
sessions: SessionManager | None = None,
|
||||
default_channel: str = "",
|
||||
default_chat_id: str = ""
|
||||
):
|
||||
self._send_callback = send_callback
|
||||
self._sessions = sessions
|
||||
self._default_channel = default_channel
|
||||
self._default_chat_id = default_chat_id
|
||||
self._sent_in_turn: bool = False
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
"""Set the current message context."""
|
||||
@@ -27,6 +31,10 @@ class MessageTool(Tool):
|
||||
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
|
||||
"""Set the callback for sending messages."""
|
||||
self._send_callback = callback
|
||||
|
||||
def start_turn(self) -> None:
|
||||
"""Reset per-turn send tracking."""
|
||||
self._sent_in_turn = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -45,6 +53,11 @@ class MessageTool(Tool):
|
||||
"type": "string",
|
||||
"description": "The message content to send"
|
||||
},
|
||||
"media": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: list of media file paths or URLs to attach"
|
||||
},
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, discord, etc.)"
|
||||
@@ -58,29 +71,42 @@ class MessageTool(Tool):
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
content: str,
|
||||
channel: str | None = None,
|
||||
self,
|
||||
content: str,
|
||||
media: list[str] | None = None,
|
||||
channel: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
**kwargs: Any
|
||||
) -> str:
|
||||
channel = channel or self._default_channel
|
||||
chat_id = chat_id or self._default_chat_id
|
||||
|
||||
|
||||
if not channel or not chat_id:
|
||||
return "Error: No target channel/chat specified"
|
||||
|
||||
|
||||
if not self._send_callback:
|
||||
return "Error: Message sending not configured"
|
||||
|
||||
|
||||
msg = OutboundMessage(
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
content=content
|
||||
content=content,
|
||||
media=media or []
|
||||
)
|
||||
|
||||
try:
|
||||
await self._send_callback(msg)
|
||||
|
||||
# Track if sent to same target as current context
|
||||
if channel == self._default_channel and chat_id == self._default_chat_id:
|
||||
self._sent_in_turn = True
|
||||
|
||||
if self._sessions:
|
||||
session_key = f"{channel}:{chat_id}"
|
||||
session = self._sessions.get_or_create(session_key)
|
||||
session.add_message("assistant", content)
|
||||
self._sessions.save(session)
|
||||
|
||||
return f"Message sent to {channel}:{chat_id}"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
|
||||
@@ -32,20 +32,33 @@ class ToolRegistry:
|
||||
return name in self._tools
|
||||
|
||||
def get_definitions(self) -> list[dict[str, Any]]:
|
||||
"""Get all tool definitions in OpenAI format."""
|
||||
return [tool.to_schema() for tool in self._tools.values()]
|
||||
"""Get tool definitions for all registered tools.
|
||||
|
||||
Supports both function tools (with to_schema) and native tools (with to_params).
|
||||
"""
|
||||
definitions = []
|
||||
for tool in self._tools.values():
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
definitions.append(tool.to_params())
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
definitions.append(tool.to_schema())
|
||||
else:
|
||||
raise ValueError(f"Tool {tool.name} has no schema method (to_params or to_schema)")
|
||||
return definitions
|
||||
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
||||
async def execute(self, name: str, params: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Execute a tool by name with given parameters.
|
||||
|
||||
|
||||
Supports both native Anthropic tools (via __call__) and function tools (via execute).
|
||||
|
||||
Args:
|
||||
name: Tool name.
|
||||
params: Tool parameters.
|
||||
|
||||
|
||||
Returns:
|
||||
Tool execution result as string.
|
||||
|
||||
Tool execution result (ToolResult, CLIResult, or string).
|
||||
|
||||
Raises:
|
||||
KeyError: If tool not found.
|
||||
"""
|
||||
@@ -54,20 +67,33 @@ class ToolRegistry:
|
||||
return f"Error: Tool '{name}' not found"
|
||||
|
||||
try:
|
||||
errors = tool.validate_params(params)
|
||||
if errors:
|
||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||
return await tool.execute(**params)
|
||||
# Duck typing - support both native and function tools
|
||||
if hasattr(tool, 'to_params'):
|
||||
# Native Anthropic tool - call directly via __call__, no validation needed
|
||||
return await tool(**params)
|
||||
else:
|
||||
# Legacy function tool - validate then execute
|
||||
errors = tool.validate_params(params)
|
||||
if errors:
|
||||
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||
return await tool.execute(**params)
|
||||
except Exception as e:
|
||||
return f"Error executing {name}: {str(e)}"
|
||||
|
||||
def get_tools(self) -> list[Any]:
|
||||
"""Get list of tool objects (not definitions).
|
||||
|
||||
Returns tool objects which can be inspected for metadata like beta_flag.
|
||||
"""
|
||||
return list(self._tools.values())
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
"""Get list of registered tool names."""
|
||||
return list(self._tools.keys())
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._tools)
|
||||
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._tools
|
||||
|
||||
@@ -19,6 +19,7 @@ class ExecTool(Tool):
|
||||
deny_patterns: list[str] | None = None,
|
||||
allow_patterns: list[str] | None = None,
|
||||
restrict_to_workspace: bool = False,
|
||||
path_append: str = "",
|
||||
):
|
||||
self.timeout = timeout
|
||||
self.working_dir = working_dir
|
||||
@@ -26,7 +27,8 @@ class ExecTool(Tool):
|
||||
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||
r"\brmdir\s+/s\b", # rmdir /s
|
||||
r"\b(format|mkfs|diskpart)\b", # disk operations
|
||||
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
|
||||
r"\b(mkfs|diskpart)\b", # disk operations
|
||||
r"\bdd\s+if=", # dd
|
||||
r">\s*/dev/sd", # write to disk
|
||||
r"\b(shutdown|reboot|poweroff)\b", # system power
|
||||
@@ -34,6 +36,7 @@ class ExecTool(Tool):
|
||||
]
|
||||
self.allow_patterns = allow_patterns or []
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self.path_append = path_append
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -66,12 +69,17 @@ class ExecTool(Tool):
|
||||
if guard_error:
|
||||
return guard_error
|
||||
|
||||
env = os.environ.copy()
|
||||
if self.path_append:
|
||||
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -81,6 +89,12 @@ class ExecTool(Tool):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
# Wait for the process to fully terminate so pipes are
|
||||
# drained and file descriptors are released.
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return f"Error: Command timed out after {self.timeout} seconds"
|
||||
|
||||
output_parts = []
|
||||
|
||||
@@ -20,11 +20,13 @@ class SpawnTool(Tool):
|
||||
self._manager = manager
|
||||
self._origin_channel = "cli"
|
||||
self._origin_chat_id = "direct"
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
self._origin_metadata: dict[str, Any] = {}
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, metadata: dict[str, Any] | None = None) -> None:
|
||||
"""Set the origin context for subagent announcements."""
|
||||
self._origin_channel = channel
|
||||
self._origin_chat_id = chat_id
|
||||
self._origin_metadata = metadata or {}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -53,7 +55,7 @@ class SpawnTool(Tool):
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
|
||||
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
@@ -67,4 +69,5 @@ class SpawnTool(Tool):
|
||||
model=model,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
origin_metadata=self._origin_metadata,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Message tool for subagents to communicate with the main agent."""
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
class SubagentMessageTool(Tool):
|
||||
"""
|
||||
Tool for subagents to send messages to the main agent.
|
||||
|
||||
Messages are sent via the bus and preserve metadata (e.g. suppress_output)
|
||||
from the originating message that spawned the subagent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bus: "MessageBus",
|
||||
origin_channel: str,
|
||||
origin_chat_id: str,
|
||||
origin_metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
self._bus = bus
|
||||
self._origin_channel = origin_channel
|
||||
self._origin_chat_id = origin_chat_id
|
||||
self._origin_metadata = origin_metadata or {}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "message"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Send a message to the main agent. "
|
||||
"Use this to communicate findings, request clarification, or provide updates. "
|
||||
"The main agent will process your message and decide how to respond."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The message content to send to the main agent"
|
||||
},
|
||||
},
|
||||
"required": ["content"]
|
||||
}
|
||||
|
||||
async def execute(self, content: str, **kwargs: Any) -> str:
|
||||
"""Send a message to the main agent via the bus."""
|
||||
# Create InboundMessage to trigger main agent
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id=f"{self._origin_channel}:{self._origin_chat_id}",
|
||||
content=f"[Subagent message]\n\n{content}",
|
||||
metadata=self._origin_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._bus.publish_inbound(msg)
|
||||
return "Message sent to main agent"
|
||||
except Exception as e:
|
||||
return f"Error sending message: {str(e)}"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Wait-for-subagents tool for orchestrator subagents."""
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
|
||||
|
||||
class WaitForSubagentsTool(Tool):
|
||||
"""
|
||||
Tool to wait for child subagents to complete and collect their results.
|
||||
|
||||
Use this after spawning multiple subagents to wait for all of them
|
||||
and get their results for synthesis.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: "SubagentManager"):
|
||||
self._manager = manager
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "wait_for_subagents"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Wait for one or more child subagents to complete and return their results. "
|
||||
"Use this after spawning subagents to collect all results before synthesizing. "
|
||||
"Blocks until all specified subagents finish."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of task IDs to wait for (from spawn tool responses)",
|
||||
},
|
||||
},
|
||||
"required": ["task_ids"],
|
||||
}
|
||||
|
||||
async def execute(self, task_ids: list[str], **kwargs: Any) -> str:
|
||||
"""Wait for the specified subagents and return their results."""
|
||||
return await self._manager.wait_for(task_ids)
|
||||
@@ -58,12 +58,21 @@ class WebSearchTool(Tool):
|
||||
}
|
||||
|
||||
def __init__(self, api_key: str | None = None, max_results: int = 5):
|
||||
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
self._init_api_key = api_key
|
||||
self.max_results = max_results
|
||||
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Resolve API key at call time so env/config changes are picked up."""
|
||||
return self._init_api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
|
||||
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
|
||||
if not self.api_key:
|
||||
return "Error: BRAVE_API_KEY not configured"
|
||||
return (
|
||||
"Error: Brave Search API key not configured. "
|
||||
"Set it in ~/.nanobot/config.json under tools.web.search.apiKey "
|
||||
"(or export BRAVE_API_KEY), then restart the gateway."
|
||||
)
|
||||
|
||||
try:
|
||||
n = min(max(count or self.max_results, 1), 10)
|
||||
@@ -116,7 +125,7 @@ class WebFetchTool(Tool):
|
||||
# Validate URL before fetching
|
||||
is_valid, error_msg = _validate_url(url)
|
||||
if not is_valid:
|
||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url})
|
||||
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
@@ -131,7 +140,7 @@ class WebFetchTool(Tool):
|
||||
|
||||
# JSON
|
||||
if "application/json" in ctype:
|
||||
text, extractor = json.dumps(r.json(), indent=2), "json"
|
||||
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
|
||||
# HTML
|
||||
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
|
||||
doc = Document(r.text)
|
||||
@@ -146,9 +155,9 @@ class WebFetchTool(Tool):
|
||||
text = text[:max_chars]
|
||||
|
||||
return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code,
|
||||
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text})
|
||||
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e), "url": url})
|
||||
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
|
||||
|
||||
def _to_markdown(self, html: str) -> str:
|
||||
"""Convert HTML to markdown."""
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# nanobot/agent/visibility.py
|
||||
"""Cryptographic signing for visibility markers to prevent model forgery."""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
SECRET_KEY = "nanobot_visibility_secret_key_v1"
|
||||
|
||||
|
||||
def compute_signature(content: str) -> str:
|
||||
"""Compute HMAC signature for content (hex string, no prefix)."""
|
||||
return hmac.new(
|
||||
SECRET_KEY.encode(),
|
||||
content.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()[:8]
|
||||
|
||||
|
||||
def sign_content(content: str) -> str:
|
||||
"""
|
||||
Sign content with HMAC and prepend marker.
|
||||
|
||||
Args:
|
||||
content: The message content to sign
|
||||
|
||||
Returns:
|
||||
Content with signed visibility marker: "[HIDDEN:{sig}] {content}"
|
||||
"""
|
||||
sig = compute_signature(content)
|
||||
return f"[HIDDEN:{sig}] {content}"
|
||||
|
||||
|
||||
def verify_signature(marked_content: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify HMAC signature and extract clean content.
|
||||
|
||||
Args:
|
||||
marked_content: Content potentially with [HIDDEN:{sig}] marker
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, clean_content)
|
||||
- is_valid: True if signature is valid, False otherwise
|
||||
- clean_content: Content without marker
|
||||
"""
|
||||
match = re.match(r'\[HIDDEN:([a-f0-9]{8})\] (.*)', marked_content, re.DOTALL)
|
||||
if not match:
|
||||
return False, marked_content
|
||||
|
||||
claimed_sig, content = match.groups()
|
||||
expected_sig = compute_signature(content)
|
||||
is_valid = hmac.compare_digest(claimed_sig, expected_sig)
|
||||
return is_valid, content
|
||||
|
||||
|
||||
def has_forged_marker(content: str) -> bool:
|
||||
"""
|
||||
Check if content has an invalid [HIDDEN:*] marker at the start.
|
||||
|
||||
Args:
|
||||
content: Content to check
|
||||
|
||||
Returns:
|
||||
True if content starts with forged marker, False otherwise
|
||||
"""
|
||||
if not content.startswith("[HIDDEN:"):
|
||||
return False
|
||||
is_valid, _ = verify_signature(content)
|
||||
return not is_valid
|
||||
|
||||
|
||||
def strip_all_hidden_markers(content: str) -> str:
|
||||
"""
|
||||
Remove all [HIDDEN:*] patterns from content (valid or invalid).
|
||||
|
||||
Args:
|
||||
content: Content potentially with markers
|
||||
|
||||
Returns:
|
||||
Content with all markers stripped
|
||||
"""
|
||||
return re.sub(r'\[HIDDEN:[a-f0-9]{8}\]\s*', '', content)
|
||||
@@ -16,11 +16,12 @@ class InboundMessage:
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
media: list[str] = field(default_factory=list) # Media URLs
|
||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
"""Unique key for session identification."""
|
||||
return f"{self.channel}:{self.chat_id}"
|
||||
return self.session_key_override or f"{self.channel}:{self.chat_id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+32
-12
@@ -1,9 +1,7 @@
|
||||
"""Async message queue for decoupled channel-agent communication."""
|
||||
|
||||
import asyncio
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
from loguru import logger
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
|
||||
@@ -11,33 +9,55 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
class MessageBus:
|
||||
"""
|
||||
Async message bus that decouples chat channels from the agent core.
|
||||
|
||||
|
||||
Channels push messages to the inbound queue, and the agent processes
|
||||
them and pushes responses to the outbound queue.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
||||
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
|
||||
self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
|
||||
self._correlation_store: dict[str, asyncio.Future] = {}
|
||||
self._running = False
|
||||
|
||||
|
||||
async def publish_inbound(self, msg: InboundMessage) -> None:
|
||||
"""Publish a message from a channel to the agent."""
|
||||
await self.inbound.put(msg)
|
||||
|
||||
|
||||
async def consume_inbound(self) -> InboundMessage:
|
||||
"""Consume the next inbound message (blocks until available)."""
|
||||
return await self.inbound.get()
|
||||
|
||||
|
||||
async def publish_outbound(self, msg: OutboundMessage) -> None:
|
||||
"""Publish a response from the agent to channels."""
|
||||
await self.outbound.put(msg)
|
||||
|
||||
|
||||
async def consume_outbound(self) -> OutboundMessage:
|
||||
"""Consume the next outbound message (blocks until available)."""
|
||||
return await self.outbound.get()
|
||||
|
||||
|
||||
def register_correlation(self, correlation_id: str) -> asyncio.Future:
|
||||
"""Register a Future to be resolved when a matching outbound message appears."""
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
self._correlation_store[correlation_id] = future
|
||||
return future
|
||||
|
||||
def resolve_correlation(self, msg: OutboundMessage) -> None:
|
||||
"""Check if an outbound message has a correlation_id and resolve the matching Future."""
|
||||
cid = msg.metadata.get("correlation_id") if msg.metadata else None
|
||||
if cid and cid in self._correlation_store:
|
||||
future = self._correlation_store.pop(cid)
|
||||
if not future.done():
|
||||
future.set_result(msg.content)
|
||||
|
||||
def cancel_correlation(self, correlation_id: str) -> None:
|
||||
"""Cancel and remove a pending correlation."""
|
||||
future = self._correlation_store.pop(correlation_id, None)
|
||||
if future and not future.done():
|
||||
future.cancel()
|
||||
|
||||
def subscribe_outbound(
|
||||
self,
|
||||
channel: str,
|
||||
@@ -69,12 +89,12 @@ class MessageBus:
|
||||
def stop(self) -> None:
|
||||
"""Stop the dispatcher loop."""
|
||||
self._running = False
|
||||
|
||||
|
||||
@property
|
||||
def inbound_size(self) -> int:
|
||||
"""Number of pending inbound messages."""
|
||||
return self.inbound.qsize()
|
||||
|
||||
|
||||
@property
|
||||
def outbound_size(self) -> int:
|
||||
"""Number of pending outbound messages."""
|
||||
|
||||
@@ -93,7 +93,8 @@ class BaseChannel(ABC):
|
||||
chat_id: str,
|
||||
content: str,
|
||||
media: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None
|
||||
metadata: dict[str, Any] | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle an incoming message from the chat platform.
|
||||
@@ -106,11 +107,13 @@ class BaseChannel(ABC):
|
||||
content: Message text content.
|
||||
media: Optional list of media URLs.
|
||||
metadata: Optional channel-specific metadata.
|
||||
session_key: Optional session key override (e.g. thread-scoped sessions).
|
||||
"""
|
||||
if not self.is_allowed(sender_id):
|
||||
logger.warning(
|
||||
f"Access denied for sender {sender_id} on channel {self.name}. "
|
||||
f"Add them to allowFrom list in config to grant access."
|
||||
"Access denied for sender {} on channel {}. "
|
||||
"Add them to allowFrom list in config to grant access.",
|
||||
sender_id, self.name,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -120,7 +123,8 @@ class BaseChannel(ABC):
|
||||
chat_id=str(chat_id),
|
||||
content=content,
|
||||
media=media or [],
|
||||
metadata=metadata or {}
|
||||
metadata=metadata or {},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -58,14 +58,15 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
|
||||
if not content:
|
||||
logger.warning(
|
||||
f"Received empty or unsupported message type: {chatbot_msg.message_type}"
|
||||
"Received empty or unsupported message type: {}",
|
||||
chatbot_msg.message_type,
|
||||
)
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
|
||||
sender_name = chatbot_msg.sender_nick or "Unknown"
|
||||
|
||||
logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
|
||||
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
|
||||
|
||||
# Forward to Nanobot via _on_message (non-blocking).
|
||||
# Store reference to prevent GC before task completes.
|
||||
@@ -78,7 +79,7 @@ class NanobotDingTalkHandler(CallbackHandler):
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing DingTalk message: {e}")
|
||||
logger.error("Error processing DingTalk message: {}", e)
|
||||
# Return OK to avoid retry loop from DingTalk server
|
||||
return AckMessage.STATUS_OK, "Error"
|
||||
|
||||
@@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel):
|
||||
self._http = httpx.AsyncClient()
|
||||
|
||||
logger.info(
|
||||
f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..."
|
||||
"Initializing DingTalk Stream Client with Client ID: {}...",
|
||||
self.config.client_id,
|
||||
)
|
||||
credential = Credential(self.config.client_id, self.config.client_secret)
|
||||
self._client = DingTalkStreamClient(credential)
|
||||
@@ -142,13 +144,13 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.start()
|
||||
except Exception as e:
|
||||
logger.warning(f"DingTalk stream error: {e}")
|
||||
logger.warning("DingTalk stream error: {}", e)
|
||||
if self._running:
|
||||
logger.info("Reconnecting DingTalk stream in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to start DingTalk channel: {e}")
|
||||
logger.exception("Failed to start DingTalk channel: {}", e)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the DingTalk bot."""
|
||||
@@ -186,7 +188,7 @@ class DingTalkChannel(BaseChannel):
|
||||
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
|
||||
return self._access_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get DingTalk access token: {e}")
|
||||
logger.error("Failed to get DingTalk access token: {}", e)
|
||||
return None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
@@ -208,7 +210,7 @@ class DingTalkChannel(BaseChannel):
|
||||
"msgParam": json.dumps({
|
||||
"text": msg.content,
|
||||
"title": "Nanobot Reply",
|
||||
}),
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
if not self._http:
|
||||
@@ -218,11 +220,11 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
resp = await self._http.post(url, json=data, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"DingTalk send failed: {resp.text}")
|
||||
logger.error("DingTalk send failed: {}", resp.text)
|
||||
else:
|
||||
logger.debug(f"DingTalk message sent to {msg.chat_id}")
|
||||
logger.debug("DingTalk message sent to {}", msg.chat_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending DingTalk message: {e}")
|
||||
logger.error("Error sending DingTalk message: {}", e)
|
||||
|
||||
async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
|
||||
"""Handle incoming message (called by NanobotDingTalkHandler).
|
||||
@@ -231,7 +233,7 @@ class DingTalkChannel(BaseChannel):
|
||||
permission checks before publishing to the bus.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"DingTalk inbound: {content} from {sender_name}")
|
||||
logger.info("DingTalk inbound: {} from {}", content, sender_name)
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=sender_id, # For private chat, chat_id == sender_id
|
||||
@@ -242,4 +244,4 @@ class DingTalkChannel(BaseChannel):
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing DingTalk message: {e}")
|
||||
logger.error("Error publishing DingTalk message: {}", e)
|
||||
|
||||
+68
-28
@@ -17,6 +17,29 @@ from nanobot.config.schema import DiscordConfig
|
||||
|
||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
||||
MAX_MESSAGE_LEN = 2000 # Discord message character limit
|
||||
|
||||
|
||||
def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
|
||||
"""Split content into chunks within max_len, preferring line breaks."""
|
||||
if not content:
|
||||
return []
|
||||
if len(content) <= max_len:
|
||||
return [content]
|
||||
chunks: list[str] = []
|
||||
while content:
|
||||
if len(content) <= max_len:
|
||||
chunks.append(content)
|
||||
break
|
||||
cut = content[:max_len]
|
||||
pos = cut.rfind('\n')
|
||||
if pos <= 0:
|
||||
pos = cut.rfind(' ')
|
||||
if pos <= 0:
|
||||
pos = max_len
|
||||
chunks.append(content[:pos])
|
||||
content = content[pos:].lstrip()
|
||||
return chunks
|
||||
|
||||
|
||||
class DiscordChannel(BaseChannel):
|
||||
@@ -51,7 +74,7 @@ class DiscordChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Discord gateway error: {e}")
|
||||
logger.warning("Discord gateway error: {}", e)
|
||||
if self._running:
|
||||
logger.info("Reconnecting to Discord gateway in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
@@ -79,34 +102,48 @@ class DiscordChannel(BaseChannel):
|
||||
return
|
||||
|
||||
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
|
||||
payload: dict[str, Any] = {"content": msg.content}
|
||||
|
||||
if msg.reply_to:
|
||||
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||
payload["allowed_mentions"] = {"replied_user": False}
|
||||
|
||||
headers = {"Authorization": f"Bot {self.config.token}"}
|
||||
|
||||
try:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = await self._http.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
retry_after = float(data.get("retry_after", 1.0))
|
||||
logger.warning(f"Discord rate limited, retrying in {retry_after}s")
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error(f"Error sending Discord message: {e}")
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
chunks = _split_message(msg.content or "")
|
||||
if not chunks:
|
||||
return
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
payload: dict[str, Any] = {"content": chunk}
|
||||
|
||||
# Only set reply reference on the first chunk
|
||||
if i == 0 and msg.reply_to:
|
||||
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||
payload["allowed_mentions"] = {"replied_user": False}
|
||||
|
||||
if not await self._send_payload(url, headers, payload):
|
||||
break # Abort remaining chunks on failure
|
||||
finally:
|
||||
await self._stop_typing(msg.chat_id)
|
||||
|
||||
async def _send_payload(
|
||||
self, url: str, headers: dict[str, str], payload: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Send a single Discord API payload with retry on rate-limit. Returns True on success."""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = await self._http.post(url, headers=headers, json=payload)
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
retry_after = float(data.get("retry_after", 1.0))
|
||||
logger.warning("Discord rate limited, retrying in {}s", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error("Error sending Discord message: {}", e)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
return False
|
||||
|
||||
async def _gateway_loop(self) -> None:
|
||||
"""Main gateway loop: identify, heartbeat, dispatch events."""
|
||||
if not self._ws:
|
||||
@@ -116,7 +153,7 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
|
||||
logger.warning("Invalid JSON from Discord gateway: {}", raw[:100])
|
||||
continue
|
||||
|
||||
op = data.get("op")
|
||||
@@ -175,7 +212,7 @@ class DiscordChannel(BaseChannel):
|
||||
try:
|
||||
await self._ws.send(json.dumps(payload))
|
||||
except Exception as e:
|
||||
logger.warning(f"Discord heartbeat failed: {e}")
|
||||
logger.warning("Discord heartbeat failed: {}", e)
|
||||
break
|
||||
await asyncio.sleep(interval_s)
|
||||
|
||||
@@ -219,7 +256,7 @@ class DiscordChannel(BaseChannel):
|
||||
media_paths.append(str(file_path))
|
||||
content_parts.append(f"[attachment: {file_path}]")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to download Discord attachment: {e}")
|
||||
logger.warning("Failed to download Discord attachment: {}", e)
|
||||
content_parts.append(f"[attachment: {filename} - download failed]")
|
||||
|
||||
reply_to = (payload.get("referenced_message") or {}).get("id")
|
||||
@@ -248,8 +285,11 @@ class DiscordChannel(BaseChannel):
|
||||
while self._running:
|
||||
try:
|
||||
await self._http.post(url, headers=headers)
|
||||
except Exception:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
|
||||
return
|
||||
await asyncio.sleep(8)
|
||||
|
||||
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
||||
|
||||
@@ -94,7 +94,7 @@ class EmailChannel(BaseChannel):
|
||||
metadata=item.get("metadata", {}),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Email polling error: {e}")
|
||||
logger.error("Email polling error: {}", e)
|
||||
|
||||
await asyncio.sleep(poll_seconds)
|
||||
|
||||
@@ -108,11 +108,6 @@ class EmailChannel(BaseChannel):
|
||||
logger.warning("Skip email send: consent_granted is false")
|
||||
return
|
||||
|
||||
force_send = bool((msg.metadata or {}).get("force_send"))
|
||||
if not self.config.auto_reply_enabled and not force_send:
|
||||
logger.info("Skip automatic email reply: auto_reply_enabled is false")
|
||||
return
|
||||
|
||||
if not self.config.smtp_host:
|
||||
logger.warning("Email channel SMTP host not configured")
|
||||
return
|
||||
@@ -122,6 +117,15 @@ class EmailChannel(BaseChannel):
|
||||
logger.warning("Email channel missing recipient address")
|
||||
return
|
||||
|
||||
# Determine if this is a reply (recipient has sent us an email before)
|
||||
is_reply = to_addr in self._last_subject_by_chat
|
||||
force_send = bool((msg.metadata or {}).get("force_send"))
|
||||
|
||||
# autoReplyEnabled only controls automatic replies, not proactive sends
|
||||
if is_reply and not self.config.auto_reply_enabled and not force_send:
|
||||
logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr)
|
||||
return
|
||||
|
||||
base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
|
||||
subject = self._reply_subject(base_subject)
|
||||
if msg.metadata and isinstance(msg.metadata.get("subject"), str):
|
||||
@@ -143,7 +147,7 @@ class EmailChannel(BaseChannel):
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, email_msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email to {to_addr}: {e}")
|
||||
logger.error("Error sending email to {}: {}", to_addr, e)
|
||||
raise
|
||||
|
||||
def _validate_config(self) -> bool:
|
||||
@@ -162,7 +166,7 @@ class EmailChannel(BaseChannel):
|
||||
missing.append("smtp_password")
|
||||
|
||||
if missing:
|
||||
logger.error(f"Email channel not configured, missing: {', '.join(missing)}")
|
||||
logger.error("Email channel not configured, missing: {}", ', '.join(missing))
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -304,7 +308,8 @@ class EmailChannel(BaseChannel):
|
||||
self._processed_uids.add(uid)
|
||||
# mark_seen is the primary dedup; this set is a safety net
|
||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
||||
self._processed_uids.clear()
|
||||
# Evict a random half to cap memory; mark_seen is the primary dedup
|
||||
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
|
||||
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
|
||||
+469
-59
@@ -2,9 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -17,11 +19,17 @@ from nanobot.config.schema import FeishuConfig
|
||||
try:
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
CreateImageRequest,
|
||||
CreateImageRequestBody,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestBody,
|
||||
CreateMessageReactionRequest,
|
||||
CreateMessageReactionRequestBody,
|
||||
Emoji,
|
||||
GetFileRequest,
|
||||
GetMessageResourceRequest,
|
||||
P2ImMessageReceiveV1,
|
||||
)
|
||||
FEISHU_AVAILABLE = True
|
||||
@@ -39,6 +47,204 @@ MSG_TYPE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
|
||||
"""Extract text representation from share cards and interactive messages."""
|
||||
parts = []
|
||||
|
||||
if msg_type == "share_chat":
|
||||
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
||||
elif msg_type == "share_user":
|
||||
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
|
||||
elif msg_type == "interactive":
|
||||
parts.extend(_extract_interactive_content(content_json))
|
||||
elif msg_type == "share_calendar_event":
|
||||
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
|
||||
elif msg_type == "system":
|
||||
parts.append("[system message]")
|
||||
elif msg_type == "merge_forward":
|
||||
parts.append("[merged forward messages]")
|
||||
|
||||
return "\n".join(parts) if parts else f"[{msg_type}]"
|
||||
|
||||
|
||||
def _extract_interactive_content(content: dict) -> list[str]:
|
||||
"""Recursively extract text and links from interactive card content."""
|
||||
parts = []
|
||||
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return [content] if content.strip() else []
|
||||
|
||||
if not isinstance(content, dict):
|
||||
return parts
|
||||
|
||||
if "title" in content:
|
||||
title = content["title"]
|
||||
if isinstance(title, dict):
|
||||
title_content = title.get("content", "") or title.get("text", "")
|
||||
if title_content:
|
||||
parts.append(f"title: {title_content}")
|
||||
elif isinstance(title, str):
|
||||
parts.append(f"title: {title}")
|
||||
|
||||
for element in content.get("elements", []) if isinstance(content.get("elements"), list) else []:
|
||||
parts.extend(_extract_element_content(element))
|
||||
|
||||
card = content.get("card", {})
|
||||
if card:
|
||||
parts.extend(_extract_interactive_content(card))
|
||||
|
||||
header = content.get("header", {})
|
||||
if header:
|
||||
header_title = header.get("title", {})
|
||||
if isinstance(header_title, dict):
|
||||
header_text = header_title.get("content", "") or header_title.get("text", "")
|
||||
if header_text:
|
||||
parts.append(f"title: {header_text}")
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_element_content(element: dict) -> list[str]:
|
||||
"""Extract content from a single card element."""
|
||||
parts = []
|
||||
|
||||
if not isinstance(element, dict):
|
||||
return parts
|
||||
|
||||
tag = element.get("tag", "")
|
||||
|
||||
if tag in ("markdown", "lark_md"):
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
|
||||
elif tag == "div":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
text_content = text.get("content", "") or text.get("text", "")
|
||||
if text_content:
|
||||
parts.append(text_content)
|
||||
elif isinstance(text, str):
|
||||
parts.append(text)
|
||||
for field in element.get("fields", []):
|
||||
if isinstance(field, dict):
|
||||
field_text = field.get("text", {})
|
||||
if isinstance(field_text, dict):
|
||||
c = field_text.get("content", "")
|
||||
if c:
|
||||
parts.append(c)
|
||||
|
||||
elif tag == "a":
|
||||
href = element.get("href", "")
|
||||
text = element.get("text", "")
|
||||
if href:
|
||||
parts.append(f"link: {href}")
|
||||
if text:
|
||||
parts.append(text)
|
||||
|
||||
elif tag == "button":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
c = text.get("content", "")
|
||||
if c:
|
||||
parts.append(c)
|
||||
url = element.get("url", "") or element.get("multi_url", {}).get("url", "")
|
||||
if url:
|
||||
parts.append(f"link: {url}")
|
||||
|
||||
elif tag == "img":
|
||||
alt = element.get("alt", {})
|
||||
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]")
|
||||
|
||||
elif tag == "note":
|
||||
for ne in element.get("elements", []):
|
||||
parts.extend(_extract_element_content(ne))
|
||||
|
||||
elif tag == "column_set":
|
||||
for col in element.get("columns", []):
|
||||
for ce in col.get("elements", []):
|
||||
parts.extend(_extract_element_content(ce))
|
||||
|
||||
elif tag == "plain_text":
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
|
||||
else:
|
||||
for ne in element.get("elements", []):
|
||||
parts.extend(_extract_element_content(ne))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
|
||||
"""Extract text and image keys from Feishu post (rich text) message content.
|
||||
|
||||
Supports two formats:
|
||||
1. Direct format: {"title": "...", "content": [...]}
|
||||
2. Localized format: {"zh_cn": {"title": "...", "content": [...]}}
|
||||
|
||||
Returns:
|
||||
(text, image_keys) - extracted text and list of image keys
|
||||
"""
|
||||
def extract_from_lang(lang_content: dict) -> tuple[str | None, list[str]]:
|
||||
if not isinstance(lang_content, dict):
|
||||
return None, []
|
||||
title = lang_content.get("title", "")
|
||||
content_blocks = lang_content.get("content", [])
|
||||
if not isinstance(content_blocks, list):
|
||||
return None, []
|
||||
text_parts = []
|
||||
image_keys = []
|
||||
if title:
|
||||
text_parts.append(title)
|
||||
for block in content_blocks:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if isinstance(element, dict):
|
||||
tag = element.get("tag")
|
||||
if tag == "text":
|
||||
text_parts.append(element.get("text", ""))
|
||||
elif tag == "a":
|
||||
text_parts.append(element.get("text", ""))
|
||||
elif tag == "at":
|
||||
text_parts.append(f"@{element.get('user_name', 'user')}")
|
||||
elif tag == "img":
|
||||
img_key = element.get("image_key")
|
||||
if img_key:
|
||||
image_keys.append(img_key)
|
||||
text = " ".join(text_parts).strip() if text_parts else None
|
||||
return text, image_keys
|
||||
|
||||
# Try direct format first
|
||||
if "content" in content_json:
|
||||
text, images = extract_from_lang(content_json)
|
||||
if text or images:
|
||||
return text or "", images
|
||||
|
||||
# Try localized format
|
||||
for lang_key in ("zh_cn", "en_us", "ja_jp"):
|
||||
lang_content = content_json.get(lang_key)
|
||||
text, images = extract_from_lang(lang_content)
|
||||
if text or images:
|
||||
return text or "", images
|
||||
|
||||
return "", []
|
||||
|
||||
|
||||
def _extract_post_text(content_json: dict) -> str:
|
||||
"""Extract plain text from Feishu post (rich text) message content.
|
||||
|
||||
Legacy wrapper for _extract_post_content, returns only text.
|
||||
"""
|
||||
text, _ = _extract_post_content(content_json)
|
||||
return text
|
||||
|
||||
|
||||
class FeishuChannel(BaseChannel):
|
||||
"""
|
||||
Feishu/Lark channel using WebSocket long connection.
|
||||
@@ -104,7 +310,7 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as e:
|
||||
logger.warning(f"Feishu WebSocket error: {e}")
|
||||
logger.warning("Feishu WebSocket error: {}", e)
|
||||
if self._running:
|
||||
import time; time.sleep(5)
|
||||
|
||||
@@ -125,7 +331,7 @@ class FeishuChannel(BaseChannel):
|
||||
try:
|
||||
self._ws_client.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping WebSocket client: {e}")
|
||||
logger.warning("Error stopping WebSocket client: {}", e)
|
||||
logger.info("Feishu bot stopped")
|
||||
|
||||
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
|
||||
@@ -142,11 +348,11 @@ class FeishuChannel(BaseChannel):
|
||||
response = self._client.im.v1.message_reaction.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
|
||||
logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
|
||||
else:
|
||||
logger.debug(f"Added {emoji_type} reaction to message {message_id}")
|
||||
logger.debug("Added {} reaction to message {}", emoji_type, message_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error adding reaction: {e}")
|
||||
logger.warning("Error adding reaction: {}", e)
|
||||
|
||||
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
|
||||
"""
|
||||
@@ -216,7 +422,6 @@ class FeishuChannel(BaseChannel):
|
||||
before = protected[last_end:m.start()].strip()
|
||||
if before:
|
||||
elements.append({"tag": "markdown", "content": before})
|
||||
level = len(m.group(1))
|
||||
text = m.group(2).strip()
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
@@ -237,50 +442,220 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
return elements or [{"tag": "markdown", "content": content}]
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu."""
|
||||
if not self._client:
|
||||
logger.warning("Feishu client not initialized")
|
||||
return
|
||||
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||
_AUDIO_EXTS = {".opus"}
|
||||
_FILE_TYPE_MAP = {
|
||||
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
||||
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
|
||||
}
|
||||
|
||||
def _upload_image_sync(self, file_path: str) -> str | None:
|
||||
"""Upload an image to Feishu and return the image_key."""
|
||||
try:
|
||||
# Determine receive_id_type based on chat_id format
|
||||
# open_id starts with "ou_", chat_id starts with "oc_"
|
||||
if msg.chat_id.startswith("oc_"):
|
||||
receive_id_type = "chat_id"
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateImageRequest.builder() \
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type("message")
|
||||
.image(f)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.image.create(request)
|
||||
if response.success():
|
||||
image_key = response.data.image_key
|
||||
logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key)
|
||||
return image_key
|
||||
else:
|
||||
logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading image {}: {}", file_path, e)
|
||||
return None
|
||||
|
||||
def _upload_file_sync(self, file_path: str) -> str | None:
|
||||
"""Upload a file to Feishu and return the file_key."""
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
||||
file_name = os.path.basename(file_path)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateFileRequest.builder() \
|
||||
.request_body(
|
||||
CreateFileRequestBody.builder()
|
||||
.file_type(file_type)
|
||||
.file_name(file_name)
|
||||
.file(f)
|
||||
.build()
|
||||
).build()
|
||||
response = self._client.im.v1.file.create(request)
|
||||
if response.success():
|
||||
file_key = response.data.file_key
|
||||
logger.debug("Uploaded file {}: {}", file_name, file_key)
|
||||
return file_key
|
||||
else:
|
||||
logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error uploading file {}: {}", file_path, e)
|
||||
return None
|
||||
|
||||
def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]:
|
||||
"""Download an image from Feishu message by message_id and image_key."""
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder() \
|
||||
.message_id(message_id) \
|
||||
.file_key(image_key) \
|
||||
.type("image") \
|
||||
.build()
|
||||
response = self._client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
file_data = response.file
|
||||
# GetMessageResourceRequest returns BytesIO, need to read bytes
|
||||
if hasattr(file_data, 'read'):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
else:
|
||||
receive_id_type = "open_id"
|
||||
|
||||
# Build card with markdown + table support
|
||||
elements = self._build_card_elements(msg.content)
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": elements,
|
||||
}
|
||||
content = json.dumps(card, ensure_ascii=False)
|
||||
|
||||
logger.error("Failed to download image: code={}, msg={}", response.code, response.msg)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error("Error downloading image {}: {}", image_key, e)
|
||||
return None, None
|
||||
|
||||
def _download_file_sync(
|
||||
self, message_id: str, file_key: str, resource_type: str = "file"
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
|
||||
try:
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message_id)
|
||||
.file_key(file_key)
|
||||
.type(resource_type)
|
||||
.build()
|
||||
)
|
||||
response = self._client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
file_data = response.file
|
||||
if hasattr(file_data, "read"):
|
||||
file_data = file_data.read()
|
||||
return file_data, response.file_name
|
||||
else:
|
||||
logger.error("Failed to download {}: code={}, msg={}", resource_type, response.code, response.msg)
|
||||
return None, None
|
||||
except Exception:
|
||||
logger.exception("Error downloading {} {}", resource_type, file_key)
|
||||
return None, None
|
||||
|
||||
async def _download_and_save_media(
|
||||
self,
|
||||
msg_type: str,
|
||||
content_json: dict,
|
||||
message_id: str | None = None
|
||||
) -> tuple[str | None, str]:
|
||||
"""
|
||||
Download media from Feishu and save to local disk.
|
||||
|
||||
Returns:
|
||||
(file_path, content_text) - file_path is None if download failed
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data, filename = None, None
|
||||
|
||||
if msg_type == "image":
|
||||
image_key = content_json.get("image_key")
|
||||
if image_key and message_id:
|
||||
data, filename = await loop.run_in_executor(
|
||||
None, self._download_image_sync, message_id, image_key
|
||||
)
|
||||
if not filename:
|
||||
filename = f"{image_key[:16]}.jpg"
|
||||
|
||||
elif msg_type in ("audio", "file", "media"):
|
||||
file_key = content_json.get("file_key")
|
||||
if file_key and message_id:
|
||||
data, filename = await loop.run_in_executor(
|
||||
None, self._download_file_sync, message_id, file_key, msg_type
|
||||
)
|
||||
if not filename:
|
||||
ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
|
||||
filename = f"{file_key[:16]}{ext}"
|
||||
|
||||
if data and filename:
|
||||
file_path = media_dir / filename
|
||||
file_path.write_bytes(data)
|
||||
logger.debug("Downloaded {} to {}", msg_type, file_path)
|
||||
return str(file_path), f"[{msg_type}: {filename}]"
|
||||
|
||||
return None, f"[{msg_type}: download failed]"
|
||||
|
||||
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
|
||||
"""Send a single message (text/image/file/interactive) synchronously."""
|
||||
try:
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(msg.chat_id)
|
||||
.msg_type("interactive")
|
||||
.receive_id(receive_id)
|
||||
.msg_type(msg_type)
|
||||
.content(content)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
response = self._client.im.v1.message.create(request)
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"Failed to send Feishu message: code={response.code}, "
|
||||
f"msg={response.msg}, log_id={response.get_log_id()}"
|
||||
"Failed to send Feishu {} message: code={}, msg={}, log_id={}",
|
||||
msg_type, response.code, response.msg, response.get_log_id()
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Feishu message sent to {msg.chat_id}")
|
||||
|
||||
return False
|
||||
logger.debug("Feishu {} message sent to {}", msg_type, receive_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Feishu message: {e}")
|
||||
logger.error("Error sending Feishu {} message: {}", msg_type, e)
|
||||
return False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a message through Feishu, including media (images/files) if present."""
|
||||
if not self._client:
|
||||
logger.warning("Feishu client not initialized")
|
||||
return
|
||||
|
||||
try:
|
||||
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
for file_path in msg.media:
|
||||
if not os.path.isfile(file_path):
|
||||
logger.warning("Media file not found: {}", file_path)
|
||||
continue
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in self._IMAGE_EXTS:
|
||||
key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
|
||||
if key:
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}, ensure_ascii=False),
|
||||
)
|
||||
else:
|
||||
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
||||
if key:
|
||||
media_type = "audio" if ext in self._AUDIO_EXTS else "file"
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
if msg.content and msg.content.strip():
|
||||
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync,
|
||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Feishu message: {}", e)
|
||||
|
||||
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
|
||||
"""
|
||||
@@ -296,54 +671,89 @@ class FeishuChannel(BaseChannel):
|
||||
event = data.event
|
||||
message = event.message
|
||||
sender = event.sender
|
||||
|
||||
|
||||
# Deduplication check
|
||||
message_id = message.message_id
|
||||
if message_id in self._processed_message_ids:
|
||||
return
|
||||
self._processed_message_ids[message_id] = None
|
||||
|
||||
# Trim cache: keep most recent 500 when exceeds 1000
|
||||
|
||||
# Trim cache
|
||||
while len(self._processed_message_ids) > 1000:
|
||||
self._processed_message_ids.popitem(last=False)
|
||||
|
||||
|
||||
# Skip bot messages
|
||||
sender_type = sender.sender_type
|
||||
if sender_type == "bot":
|
||||
if sender.sender_type == "bot":
|
||||
return
|
||||
|
||||
|
||||
sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
|
||||
chat_id = message.chat_id
|
||||
chat_type = message.chat_type # "p2p" or "group"
|
||||
chat_type = message.chat_type
|
||||
msg_type = message.message_type
|
||||
|
||||
# Add reaction to indicate "seen"
|
||||
await self._add_reaction(message_id, "THUMBSUP")
|
||||
|
||||
# Parse message content
|
||||
|
||||
# Add reaction
|
||||
await self._add_reaction(message_id, self.config.react_emoji)
|
||||
|
||||
# Parse content
|
||||
content_parts = []
|
||||
media_paths = []
|
||||
|
||||
try:
|
||||
content_json = json.loads(message.content) if message.content else {}
|
||||
except json.JSONDecodeError:
|
||||
content_json = {}
|
||||
|
||||
if msg_type == "text":
|
||||
try:
|
||||
content = json.loads(message.content).get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
content = message.content or ""
|
||||
text = content_json.get("text", "")
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
|
||||
elif msg_type == "post":
|
||||
text, image_keys = _extract_post_content(content_json)
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
# Download images embedded in post
|
||||
for img_key in image_keys:
|
||||
file_path, content_text = await self._download_and_save_media(
|
||||
"image", {"image_key": img_key}, message_id
|
||||
)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
content_parts.append(content_text)
|
||||
|
||||
elif msg_type in ("image", "audio", "file", "media"):
|
||||
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
|
||||
if file_path:
|
||||
media_paths.append(file_path)
|
||||
content_parts.append(content_text)
|
||||
|
||||
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
|
||||
# Handle share cards and interactive messages
|
||||
text = _extract_share_card_content(content_json, msg_type)
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
|
||||
else:
|
||||
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
|
||||
|
||||
if not content:
|
||||
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
||||
|
||||
content = "\n".join(content_parts) if content_parts else ""
|
||||
|
||||
if not content and not media_paths:
|
||||
return
|
||||
|
||||
|
||||
# Forward to message bus
|
||||
reply_to = chat_id if chat_type == "group" else sender_id
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=reply_to,
|
||||
content=content,
|
||||
media=media_paths,
|
||||
metadata={
|
||||
"message_id": message_id,
|
||||
"chat_type": chat_type,
|
||||
"msg_type": msg_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Feishu message: {e}")
|
||||
logger.error("Error processing Feishu message: {}", e)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Hook channel — receives outbound messages from hook-initiated conversations."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
class HookChannel:
|
||||
"""
|
||||
Minimal channel for hook-initiated conversations.
|
||||
|
||||
The hook HTTP server publishes InboundMessages to the bus.
|
||||
Responses come back as OutboundMessages routed here.
|
||||
send() is a no-op because the HTTP caller gets the response
|
||||
via bus correlation, not channel delivery.
|
||||
"""
|
||||
|
||||
name = "hook"
|
||||
|
||||
def __init__(self, bus: MessageBus):
|
||||
self.bus = bus
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
logger.info("Hook channel started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""No-op — response is returned via bus correlation to the HTTP caller."""
|
||||
logger.debug(f"Hook channel received outbound for {msg.chat_id} (no-op)")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
+42
-16
@@ -45,7 +45,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Telegram channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Telegram channel not available: {e}")
|
||||
logger.warning("Telegram channel not available: {}", e)
|
||||
|
||||
# WhatsApp channel
|
||||
if self.config.channels.whatsapp.enabled:
|
||||
@@ -56,7 +56,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("WhatsApp channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"WhatsApp channel not available: {e}")
|
||||
logger.warning("WhatsApp channel not available: {}", e)
|
||||
|
||||
# Discord channel
|
||||
if self.config.channels.discord.enabled:
|
||||
@@ -67,7 +67,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Discord channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Discord channel not available: {e}")
|
||||
logger.warning("Discord channel not available: {}", e)
|
||||
|
||||
# Feishu channel
|
||||
if self.config.channels.feishu.enabled:
|
||||
@@ -78,7 +78,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Feishu channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Feishu channel not available: {e}")
|
||||
logger.warning("Feishu channel not available: {}", e)
|
||||
|
||||
# Mochat channel
|
||||
if self.config.channels.mochat.enabled:
|
||||
@@ -90,7 +90,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Mochat channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Mochat channel not available: {e}")
|
||||
logger.warning("Mochat channel not available: {}", e)
|
||||
|
||||
# DingTalk channel
|
||||
if self.config.channels.dingtalk.enabled:
|
||||
@@ -101,7 +101,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("DingTalk channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"DingTalk channel not available: {e}")
|
||||
logger.warning("DingTalk channel not available: {}", e)
|
||||
|
||||
# Email channel
|
||||
if self.config.channels.email.enabled:
|
||||
@@ -112,7 +112,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Email channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Email channel not available: {e}")
|
||||
logger.warning("Email channel not available: {}", e)
|
||||
|
||||
# Slack channel
|
||||
if self.config.channels.slack.enabled:
|
||||
@@ -123,7 +123,7 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("Slack channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"Slack channel not available: {e}")
|
||||
logger.warning("Slack channel not available: {}", e)
|
||||
|
||||
# QQ channel
|
||||
if self.config.channels.qq.enabled:
|
||||
@@ -135,14 +135,31 @@ class ChannelManager:
|
||||
)
|
||||
logger.info("QQ channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning(f"QQ channel not available: {e}")
|
||||
logger.warning("QQ channel not available: {}", e)
|
||||
|
||||
# Matrix channel
|
||||
if self.config.channels.matrix.enabled:
|
||||
try:
|
||||
from nanobot.channels.matrix import MatrixChannel
|
||||
self.channels["matrix"] = MatrixChannel(
|
||||
self.config.channels.matrix,
|
||||
self.bus,
|
||||
)
|
||||
logger.info("Matrix channel enabled")
|
||||
except ImportError as e:
|
||||
logger.warning("Matrix channel not available: {}", e)
|
||||
|
||||
def register_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
"""Register an external channel."""
|
||||
self.channels[name] = channel
|
||||
logger.info(f"{name} channel registered")
|
||||
|
||||
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
"""Start a channel and log any exceptions."""
|
||||
try:
|
||||
await channel.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start channel {name}: {e}")
|
||||
logger.error("Failed to start channel {}: {}", name, e)
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""Start all channels and the outbound dispatcher."""
|
||||
@@ -156,7 +173,7 @@ class ChannelManager:
|
||||
# Start channels
|
||||
tasks = []
|
||||
for name, channel in self.channels.items():
|
||||
logger.info(f"Starting {name} channel...")
|
||||
logger.info("Starting {} channel...", name)
|
||||
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
|
||||
|
||||
# Wait for all to complete (they should run forever)
|
||||
@@ -178,9 +195,9 @@ class ChannelManager:
|
||||
for name, channel in self.channels.items():
|
||||
try:
|
||||
await channel.stop()
|
||||
logger.info(f"Stopped {name} channel")
|
||||
logger.info("Stopped {} channel", name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping {name}: {e}")
|
||||
logger.error("Error stopping {}: {}", name, e)
|
||||
|
||||
async def _dispatch_outbound(self) -> None:
|
||||
"""Dispatch outbound messages to the appropriate channel."""
|
||||
@@ -192,15 +209,24 @@ class ChannelManager:
|
||||
self.bus.consume_outbound(),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
|
||||
# Resolve any pending correlation (hook request-response)
|
||||
self.bus.resolve_correlation(msg)
|
||||
|
||||
if msg.metadata.get("_progress"):
|
||||
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
|
||||
continue
|
||||
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
|
||||
continue
|
||||
|
||||
channel = self.channels.get(msg.channel)
|
||||
if channel:
|
||||
try:
|
||||
await channel.send(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending to {msg.channel}: {e}")
|
||||
logger.error("Error sending to {}: {}", msg.channel, e)
|
||||
else:
|
||||
logger.warning(f"Unknown channel: {msg.channel}")
|
||||
logger.warning("Unknown channel: {}", msg.channel)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import nh3
|
||||
from mistune import create_markdown
|
||||
from nio import (
|
||||
AsyncClient, AsyncClientConfig, ContentRepositoryConfigError,
|
||||
DownloadError, InviteEvent, JoinError, MatrixRoom, MemoryDownloadResponse,
|
||||
RoomEncryptedMedia, RoomMessage, RoomMessageMedia, RoomMessageText,
|
||||
RoomSendError, RoomTypingError, SyncError, UploadError,
|
||||
)
|
||||
from nio.crypto.attachments import decrypt_attachment
|
||||
from nio.exceptions import EncryptionError
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
|
||||
) from e
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||
# Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing.
|
||||
TYPING_KEEPALIVE_INTERVAL_MS = 20_000
|
||||
MATRIX_HTML_FORMAT = "org.matrix.custom.html"
|
||||
_ATTACH_MARKER = "[attachment: {}]"
|
||||
_ATTACH_TOO_LARGE = "[attachment: {} - too large]"
|
||||
_ATTACH_FAILED = "[attachment: {} - download failed]"
|
||||
_ATTACH_UPLOAD_FAILED = "[attachment: {} - upload failed]"
|
||||
_DEFAULT_ATTACH_NAME = "attachment"
|
||||
_MSGTYPE_MAP = {"m.image": "image", "m.audio": "audio", "m.video": "video", "m.file": "file"}
|
||||
|
||||
MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
|
||||
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
|
||||
|
||||
MATRIX_MARKDOWN = create_markdown(
|
||||
escape=True,
|
||||
plugins=["table", "strikethrough", "url", "superscript", "subscript"],
|
||||
)
|
||||
|
||||
MATRIX_ALLOWED_HTML_TAGS = {
|
||||
"p", "a", "strong", "em", "del", "code", "pre", "blockquote",
|
||||
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"hr", "br", "table", "thead", "tbody", "tr", "th", "td",
|
||||
"caption", "sup", "sub", "img",
|
||||
}
|
||||
MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
|
||||
"a": {"href"}, "code": {"class"}, "ol": {"start"},
|
||||
"img": {"src", "alt", "title", "width", "height"},
|
||||
}
|
||||
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
|
||||
|
||||
|
||||
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
|
||||
"""Filter attribute values to a safe Matrix-compatible subset."""
|
||||
if tag == "a" and attr == "href":
|
||||
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
|
||||
if tag == "img" and attr == "src":
|
||||
return value if value.lower().startswith("mxc://") else None
|
||||
if tag == "code" and attr == "class":
|
||||
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
|
||||
return " ".join(classes) if classes else None
|
||||
return value
|
||||
|
||||
|
||||
MATRIX_HTML_CLEANER = nh3.Cleaner(
|
||||
tags=MATRIX_ALLOWED_HTML_TAGS,
|
||||
attributes=MATRIX_ALLOWED_HTML_ATTRIBUTES,
|
||||
attribute_filter=_filter_matrix_html_attribute,
|
||||
url_schemes=MATRIX_ALLOWED_URL_SCHEMES,
|
||||
strip_comments=True,
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
def _render_markdown_html(text: str) -> str | None:
|
||||
"""Render markdown to sanitized HTML; returns None for plain text."""
|
||||
try:
|
||||
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not formatted:
|
||||
return None
|
||||
# Skip formatted_body for plain <p>text</p> to keep payload minimal.
|
||||
if formatted.startswith("<p>") and formatted.endswith("</p>"):
|
||||
inner = formatted[3:-4]
|
||||
if "<" not in inner and ">" not in inner:
|
||||
return None
|
||||
return formatted
|
||||
|
||||
|
||||
def _build_matrix_text_content(text: str) -> dict[str, object]:
|
||||
"""Build Matrix m.text payload with optional HTML formatted_body."""
|
||||
content: dict[str, object] = {"msgtype": "m.text", "body": text, "m.mentions": {}}
|
||||
if html := _render_markdown_html(text):
|
||||
content["format"] = MATRIX_HTML_FORMAT
|
||||
content["formatted_body"] = html
|
||||
return content
|
||||
|
||||
|
||||
class _NioLoguruHandler(logging.Handler):
|
||||
"""Route matrix-nio stdlib logs into Loguru."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame, depth = frame.f_back, depth + 1
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
def _configure_nio_logging_bridge() -> None:
|
||||
"""Bridge matrix-nio logs to Loguru (idempotent)."""
|
||||
nio_logger = logging.getLogger("nio")
|
||||
if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers):
|
||||
nio_logger.handlers = [_NioLoguruHandler()]
|
||||
nio_logger.propagate = False
|
||||
|
||||
|
||||
class MatrixChannel(BaseChannel):
|
||||
"""Matrix (Element) channel using long-polling sync."""
|
||||
|
||||
name = "matrix"
|
||||
|
||||
def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False,
|
||||
workspace: Path | None = None):
|
||||
super().__init__(config, bus)
|
||||
self.client: AsyncClient | None = None
|
||||
self._sync_task: asyncio.Task | None = None
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||
self._restrict_to_workspace = restrict_to_workspace
|
||||
self._workspace = workspace.expanduser().resolve() if workspace else None
|
||||
self._server_upload_limit_bytes: int | None = None
|
||||
self._server_upload_limit_checked = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start Matrix client and begin sync loop."""
|
||||
self._running = True
|
||||
_configure_nio_logging_bridge()
|
||||
|
||||
store_path = get_data_dir() / "matrix-store"
|
||||
store_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.client = AsyncClient(
|
||||
homeserver=self.config.homeserver, user=self.config.user_id,
|
||||
store_path=store_path,
|
||||
config=AsyncClientConfig(store_sync_tokens=True, encryption_enabled=self.config.e2ee_enabled),
|
||||
)
|
||||
self.client.user_id = self.config.user_id
|
||||
self.client.access_token = self.config.access_token
|
||||
self.client.device_id = self.config.device_id
|
||||
|
||||
self._register_event_callbacks()
|
||||
self._register_response_callbacks()
|
||||
|
||||
if not self.config.e2ee_enabled:
|
||||
logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.")
|
||||
|
||||
if self.config.device_id:
|
||||
try:
|
||||
self.client.load_store()
|
||||
except Exception:
|
||||
logger.exception("Matrix store load failed; restart may replay recent messages.")
|
||||
else:
|
||||
logger.warning("Matrix device_id empty; restart may replay recent messages.")
|
||||
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Matrix channel with graceful sync shutdown."""
|
||||
self._running = False
|
||||
for room_id in list(self._typing_tasks):
|
||||
await self._stop_typing_keepalive(room_id, clear_typing=False)
|
||||
if self.client:
|
||||
self.client.stop_sync_forever()
|
||||
if self._sync_task:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(self._sync_task),
|
||||
timeout=self.config.sync_stop_grace_seconds)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
self._sync_task.cancel()
|
||||
try:
|
||||
await self._sync_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
|
||||
def _is_workspace_path_allowed(self, path: Path) -> bool:
|
||||
"""Check path is inside workspace (when restriction enabled)."""
|
||||
if not self._restrict_to_workspace or not self._workspace:
|
||||
return True
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(self._workspace)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
|
||||
"""Deduplicate and resolve outbound attachment paths."""
|
||||
seen: set[str] = set()
|
||||
candidates: list[Path] = []
|
||||
for raw in media:
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
path = Path(raw.strip()).expanduser()
|
||||
try:
|
||||
key = str(path.resolve(strict=False))
|
||||
except OSError:
|
||||
key = str(path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _build_outbound_attachment_content(
|
||||
*, filename: str, mime: str, size_bytes: int,
|
||||
mxc_url: str, encryption_info: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build Matrix content payload for an uploaded file/image/audio/video."""
|
||||
prefix = mime.split("/")[0]
|
||||
msgtype = {"image": "m.image", "audio": "m.audio", "video": "m.video"}.get(prefix, "m.file")
|
||||
content: dict[str, Any] = {
|
||||
"msgtype": msgtype, "body": filename, "filename": filename,
|
||||
"info": {"mimetype": mime, "size": size_bytes}, "m.mentions": {},
|
||||
}
|
||||
if encryption_info:
|
||||
content["file"] = {**encryption_info, "url": mxc_url}
|
||||
else:
|
||||
content["url"] = mxc_url
|
||||
return content
|
||||
|
||||
def _is_encrypted_room(self, room_id: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
room = getattr(self.client, "rooms", {}).get(room_id)
|
||||
return bool(getattr(room, "encrypted", False))
|
||||
|
||||
async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None:
|
||||
"""Send m.room.message with E2EE options."""
|
||||
if not self.client:
|
||||
return
|
||||
kwargs: dict[str, Any] = {"room_id": room_id, "message_type": "m.room.message", "content": content}
|
||||
if self.config.e2ee_enabled:
|
||||
kwargs["ignore_unverified_devices"] = True
|
||||
await self.client.room_send(**kwargs)
|
||||
|
||||
async def _resolve_server_upload_limit_bytes(self) -> int | None:
|
||||
"""Query homeserver upload limit once per channel lifecycle."""
|
||||
if self._server_upload_limit_checked:
|
||||
return self._server_upload_limit_bytes
|
||||
self._server_upload_limit_checked = True
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
response = await self.client.content_repository_config()
|
||||
except Exception:
|
||||
return None
|
||||
upload_size = getattr(response, "upload_size", None)
|
||||
if isinstance(upload_size, int) and upload_size > 0:
|
||||
self._server_upload_limit_bytes = upload_size
|
||||
return upload_size
|
||||
return None
|
||||
|
||||
async def _effective_media_limit_bytes(self) -> int:
|
||||
"""min(local config, server advertised) — 0 blocks all uploads."""
|
||||
local_limit = max(int(self.config.max_media_bytes), 0)
|
||||
server_limit = await self._resolve_server_upload_limit_bytes()
|
||||
if server_limit is None:
|
||||
return local_limit
|
||||
return min(local_limit, server_limit) if local_limit else 0
|
||||
|
||||
async def _upload_and_send_attachment(
|
||||
self, room_id: str, path: Path, limit_bytes: int,
|
||||
relates_to: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Upload one local file to Matrix and send it as a media message. Returns failure marker or None."""
|
||||
if not self.client:
|
||||
return _ATTACH_UPLOAD_FAILED.format(path.name or _DEFAULT_ATTACH_NAME)
|
||||
|
||||
resolved = path.expanduser().resolve(strict=False)
|
||||
filename = safe_filename(resolved.name) or _DEFAULT_ATTACH_NAME
|
||||
fail = _ATTACH_UPLOAD_FAILED.format(filename)
|
||||
|
||||
if not resolved.is_file() or not self._is_workspace_path_allowed(resolved):
|
||||
return fail
|
||||
try:
|
||||
size_bytes = resolved.stat().st_size
|
||||
except OSError:
|
||||
return fail
|
||||
if limit_bytes <= 0 or size_bytes > limit_bytes:
|
||||
return _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream"
|
||||
try:
|
||||
with resolved.open("rb") as f:
|
||||
upload_result = await self.client.upload(
|
||||
f, content_type=mime, filename=filename,
|
||||
encrypt=self.config.e2ee_enabled and self._is_encrypted_room(room_id),
|
||||
filesize=size_bytes,
|
||||
)
|
||||
except Exception:
|
||||
return fail
|
||||
|
||||
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
|
||||
encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None
|
||||
if isinstance(upload_response, UploadError):
|
||||
return fail
|
||||
mxc_url = getattr(upload_response, "content_uri", None)
|
||||
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
|
||||
return fail
|
||||
|
||||
content = self._build_outbound_attachment_content(
|
||||
filename=filename, mime=mime, size_bytes=size_bytes,
|
||||
mxc_url=mxc_url, encryption_info=encryption_info,
|
||||
)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
try:
|
||||
await self._send_room_content(room_id, content)
|
||||
except Exception:
|
||||
return fail
|
||||
return None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send outbound content; clear typing for non-progress messages."""
|
||||
if not self.client:
|
||||
return
|
||||
text = msg.content or ""
|
||||
candidates = self._collect_outbound_media_candidates(msg.media)
|
||||
relates_to = self._build_thread_relates_to(msg.metadata)
|
||||
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||
try:
|
||||
failures: list[str] = []
|
||||
if candidates:
|
||||
limit_bytes = await self._effective_media_limit_bytes()
|
||||
for path in candidates:
|
||||
if fail := await self._upload_and_send_attachment(
|
||||
msg.chat_id, path, limit_bytes, relates_to):
|
||||
failures.append(fail)
|
||||
if failures:
|
||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
||||
if text or not candidates:
|
||||
content = _build_matrix_text_content(text)
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
await self._send_room_content(msg.chat_id, content)
|
||||
finally:
|
||||
if not is_progress:
|
||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
||||
|
||||
def _register_event_callbacks(self) -> None:
|
||||
self.client.add_event_callback(self._on_message, RoomMessageText)
|
||||
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
|
||||
self.client.add_event_callback(self._on_room_invite, InviteEvent)
|
||||
|
||||
def _register_response_callbacks(self) -> None:
|
||||
self.client.add_response_callback(self._on_sync_error, SyncError)
|
||||
self.client.add_response_callback(self._on_join_error, JoinError)
|
||||
self.client.add_response_callback(self._on_send_error, RoomSendError)
|
||||
|
||||
def _log_response_error(self, label: str, response: Any) -> None:
|
||||
"""Log Matrix response errors — auth errors at ERROR level, rest at WARNING."""
|
||||
code = getattr(response, "status_code", None)
|
||||
is_auth = code in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
|
||||
is_fatal = is_auth or getattr(response, "soft_logout", False)
|
||||
(logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response)
|
||||
|
||||
async def _on_sync_error(self, response: SyncError) -> None:
|
||||
self._log_response_error("sync", response)
|
||||
|
||||
async def _on_join_error(self, response: JoinError) -> None:
|
||||
self._log_response_error("join", response)
|
||||
|
||||
async def _on_send_error(self, response: RoomSendError) -> None:
|
||||
self._log_response_error("send", response)
|
||||
|
||||
async def _set_typing(self, room_id: str, typing: bool) -> None:
|
||||
"""Best-effort typing indicator update."""
|
||||
if not self.client:
|
||||
return
|
||||
try:
|
||||
response = await self.client.room_typing(room_id=room_id, typing_state=typing,
|
||||
timeout=TYPING_NOTICE_TIMEOUT_MS)
|
||||
if isinstance(response, RoomTypingError):
|
||||
logger.debug("Matrix typing failed for {}: {}", room_id, response)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _start_typing_keepalive(self, room_id: str) -> None:
|
||||
"""Start periodic typing refresh (spec-recommended keepalive)."""
|
||||
await self._stop_typing_keepalive(room_id, clear_typing=False)
|
||||
await self._set_typing(room_id, True)
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
async def loop() -> None:
|
||||
try:
|
||||
while self._running:
|
||||
await asyncio.sleep(TYPING_KEEPALIVE_INTERVAL_MS / 1000)
|
||||
await self._set_typing(room_id, True)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._typing_tasks[room_id] = asyncio.create_task(loop())
|
||||
|
||||
async def _stop_typing_keepalive(self, room_id: str, *, clear_typing: bool) -> None:
|
||||
if task := self._typing_tasks.pop(room_id, None):
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if clear_typing:
|
||||
await self._set_typing(room_id, False)
|
||||
|
||||
async def _sync_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
await self.client.sync_forever(timeout=30000, full_state=True)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||
allow_from = self.config.allow_from or []
|
||||
if not allow_from or event.sender in allow_from:
|
||||
await self.client.join(room.room_id)
|
||||
|
||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||
count = getattr(room, "member_count", None)
|
||||
return isinstance(count, int) and count <= 2
|
||||
|
||||
def _is_bot_mentioned(self, event: RoomMessage) -> bool:
|
||||
"""Check m.mentions payload for bot mention."""
|
||||
source = getattr(event, "source", None)
|
||||
if not isinstance(source, dict):
|
||||
return False
|
||||
mentions = (source.get("content") or {}).get("m.mentions")
|
||||
if not isinstance(mentions, dict):
|
||||
return False
|
||||
user_ids = mentions.get("user_ids")
|
||||
if isinstance(user_ids, list) and self.config.user_id in user_ids:
|
||||
return True
|
||||
return bool(self.config.allow_room_mentions and mentions.get("room") is True)
|
||||
|
||||
def _should_process_message(self, room: MatrixRoom, event: RoomMessage) -> bool:
|
||||
"""Apply sender and room policy checks."""
|
||||
if not self.is_allowed(event.sender):
|
||||
return False
|
||||
if self._is_direct_room(room):
|
||||
return True
|
||||
policy = self.config.group_policy
|
||||
if policy == "open":
|
||||
return True
|
||||
if policy == "allowlist":
|
||||
return room.room_id in (self.config.group_allow_from or [])
|
||||
if policy == "mention":
|
||||
return self._is_bot_mentioned(event)
|
||||
return False
|
||||
|
||||
def _media_dir(self) -> Path:
|
||||
d = get_data_dir() / "media" / "matrix"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
|
||||
source = getattr(event, "source", None)
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
content = source.get("content")
|
||||
return content if isinstance(content, dict) else {}
|
||||
|
||||
def _event_thread_root_id(self, event: RoomMessage) -> str | None:
|
||||
relates_to = self._event_source_content(event).get("m.relates_to")
|
||||
if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread":
|
||||
return None
|
||||
root_id = relates_to.get("event_id")
|
||||
return root_id if isinstance(root_id, str) and root_id else None
|
||||
|
||||
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
|
||||
if not (root_id := self._event_thread_root_id(event)):
|
||||
return None
|
||||
meta: dict[str, str] = {"thread_root_event_id": root_id}
|
||||
if isinstance(reply_to := getattr(event, "event_id", None), str) and reply_to:
|
||||
meta["thread_reply_to_event_id"] = reply_to
|
||||
return meta
|
||||
|
||||
@staticmethod
|
||||
def _build_thread_relates_to(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not metadata:
|
||||
return None
|
||||
root_id = metadata.get("thread_root_event_id")
|
||||
if not isinstance(root_id, str) or not root_id:
|
||||
return None
|
||||
reply_to = metadata.get("thread_reply_to_event_id") or metadata.get("event_id")
|
||||
if not isinstance(reply_to, str) or not reply_to:
|
||||
return None
|
||||
return {"rel_type": "m.thread", "event_id": root_id,
|
||||
"m.in_reply_to": {"event_id": reply_to}, "is_falling_back": True}
|
||||
|
||||
def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
|
||||
msgtype = self._event_source_content(event).get("msgtype")
|
||||
return _MSGTYPE_MAP.get(msgtype, "file")
|
||||
|
||||
@staticmethod
|
||||
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
|
||||
return (isinstance(getattr(event, "key", None), dict)
|
||||
and isinstance(getattr(event, "hashes", None), dict)
|
||||
and isinstance(getattr(event, "iv", None), str))
|
||||
|
||||
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
|
||||
info = self._event_source_content(event).get("info")
|
||||
size = info.get("size") if isinstance(info, dict) else None
|
||||
return size if isinstance(size, int) and size >= 0 else None
|
||||
|
||||
def _event_mime(self, event: MatrixMediaEvent) -> str | None:
|
||||
info = self._event_source_content(event).get("info")
|
||||
if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m:
|
||||
return m
|
||||
m = getattr(event, "mimetype", None)
|
||||
return m if isinstance(m, str) and m else None
|
||||
|
||||
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
|
||||
body = getattr(event, "body", None)
|
||||
if isinstance(body, str) and body.strip():
|
||||
if candidate := safe_filename(Path(body).name):
|
||||
return candidate
|
||||
return _DEFAULT_ATTACH_NAME if attachment_type == "file" else attachment_type
|
||||
|
||||
def _build_attachment_path(self, event: MatrixMediaEvent, attachment_type: str,
|
||||
filename: str, mime: str | None) -> Path:
|
||||
safe_name = safe_filename(Path(filename).name) or _DEFAULT_ATTACH_NAME
|
||||
suffix = Path(safe_name).suffix
|
||||
if not suffix and mime:
|
||||
if guessed := mimetypes.guess_extension(mime, strict=False):
|
||||
safe_name, suffix = f"{safe_name}{guessed}", guessed
|
||||
stem = (Path(safe_name).stem or attachment_type)[:72]
|
||||
suffix = suffix[:16]
|
||||
event_id = safe_filename(str(getattr(event, "event_id", "") or "evt").lstrip("$"))
|
||||
event_prefix = (event_id[:24] or "evt").strip("_")
|
||||
return self._media_dir() / f"{event_prefix}_{stem}{suffix}"
|
||||
|
||||
async def _download_media_bytes(self, mxc_url: str) -> bytes | None:
|
||||
if not self.client:
|
||||
return None
|
||||
response = await self.client.download(mxc=mxc_url)
|
||||
if isinstance(response, DownloadError):
|
||||
logger.warning("Matrix download failed for {}: {}", mxc_url, response)
|
||||
return None
|
||||
body = getattr(response, "body", None)
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
return bytes(body)
|
||||
if isinstance(response, MemoryDownloadResponse):
|
||||
return bytes(response.body)
|
||||
if isinstance(body, (str, Path)):
|
||||
path = Path(body)
|
||||
if path.is_file():
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
|
||||
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
|
||||
key = key_obj.get("k") if isinstance(key_obj, dict) else None
|
||||
sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None
|
||||
if not all(isinstance(v, str) for v in (key, sha256, iv)):
|
||||
return None
|
||||
try:
|
||||
return decrypt_attachment(ciphertext, key, sha256, iv)
|
||||
except (EncryptionError, ValueError, TypeError):
|
||||
logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", ""))
|
||||
return None
|
||||
|
||||
async def _fetch_media_attachment(
|
||||
self, room: MatrixRoom, event: MatrixMediaEvent,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
"""Download, decrypt if needed, and persist a Matrix attachment."""
|
||||
atype = self._event_attachment_type(event)
|
||||
mime = self._event_mime(event)
|
||||
filename = self._event_filename(event, atype)
|
||||
mxc_url = getattr(event, "url", None)
|
||||
fail = _ATTACH_FAILED.format(filename)
|
||||
|
||||
if not isinstance(mxc_url, str) or not mxc_url.startswith("mxc://"):
|
||||
return None, fail
|
||||
|
||||
limit_bytes = await self._effective_media_limit_bytes()
|
||||
declared = self._event_declared_size_bytes(event)
|
||||
if declared is not None and declared > limit_bytes:
|
||||
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
downloaded = await self._download_media_bytes(mxc_url)
|
||||
if downloaded is None:
|
||||
return None, fail
|
||||
|
||||
encrypted = self._is_encrypted_media_event(event)
|
||||
data = downloaded
|
||||
if encrypted:
|
||||
if (data := self._decrypt_media_bytes(event, downloaded)) is None:
|
||||
return None, fail
|
||||
|
||||
if len(data) > limit_bytes:
|
||||
return None, _ATTACH_TOO_LARGE.format(filename)
|
||||
|
||||
path = self._build_attachment_path(event, atype, filename, mime)
|
||||
try:
|
||||
path.write_bytes(data)
|
||||
except OSError:
|
||||
return None, fail
|
||||
|
||||
attachment = {
|
||||
"type": atype, "mime": mime, "filename": filename,
|
||||
"event_id": str(getattr(event, "event_id", "") or ""),
|
||||
"encrypted": encrypted, "size_bytes": len(data),
|
||||
"path": str(path), "mxc_url": mxc_url,
|
||||
}
|
||||
return attachment, _ATTACH_MARKER.format(path)
|
||||
|
||||
def _base_metadata(self, room: MatrixRoom, event: RoomMessage) -> dict[str, Any]:
|
||||
"""Build common metadata for text and media handlers."""
|
||||
meta: dict[str, Any] = {"room": getattr(room, "display_name", room.room_id)}
|
||||
if isinstance(eid := getattr(event, "event_id", None), str) and eid:
|
||||
meta["event_id"] = eid
|
||||
if thread := self._thread_metadata(event):
|
||||
meta.update(thread)
|
||||
return meta
|
||||
|
||||
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
return
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content=event.body, metadata=self._base_metadata(room, event),
|
||||
)
|
||||
except Exception:
|
||||
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
|
||||
raise
|
||||
|
||||
async def _on_media_message(self, room: MatrixRoom, event: MatrixMediaEvent) -> None:
|
||||
if event.sender == self.config.user_id or not self._should_process_message(room, event):
|
||||
return
|
||||
attachment, marker = await self._fetch_media_attachment(room, event)
|
||||
parts: list[str] = []
|
||||
if isinstance(body := getattr(event, "body", None), str) and body.strip():
|
||||
parts.append(body.strip())
|
||||
parts.append(marker)
|
||||
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
meta = self._base_metadata(room, event)
|
||||
if attachment:
|
||||
meta["attachments"] = [attachment]
|
||||
await self._handle_message(
|
||||
sender_id=event.sender, chat_id=room.room_id,
|
||||
content="\n".join(parts),
|
||||
media=[attachment["path"]] if attachment else [],
|
||||
metadata=meta,
|
||||
)
|
||||
except Exception:
|
||||
await self._stop_typing_keepalive(room.room_id, clear_typing=True)
|
||||
raise
|
||||
+12
-12
@@ -322,7 +322,7 @@ class MochatChannel(BaseChannel):
|
||||
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
|
||||
content, msg.reply_to)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Mochat message: {e}")
|
||||
logger.error("Failed to send Mochat message: {}", e)
|
||||
|
||||
# ---- config / init helpers ---------------------------------------------
|
||||
|
||||
@@ -380,7 +380,7 @@ class MochatChannel(BaseChannel):
|
||||
|
||||
@client.event
|
||||
async def connect_error(data: Any) -> None:
|
||||
logger.error(f"Mochat websocket connect error: {data}")
|
||||
logger.error("Mochat websocket connect error: {}", data)
|
||||
|
||||
@client.on("claw.session.events")
|
||||
async def on_session_events(payload: dict[str, Any]) -> None:
|
||||
@@ -407,7 +407,7 @@ class MochatChannel(BaseChannel):
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect Mochat websocket: {e}")
|
||||
logger.error("Failed to connect Mochat websocket: {}", e)
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
@@ -444,7 +444,7 @@ class MochatChannel(BaseChannel):
|
||||
"limit": self.config.watch_limit,
|
||||
})
|
||||
if not ack.get("result"):
|
||||
logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
|
||||
logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
|
||||
return False
|
||||
|
||||
data = ack.get("data")
|
||||
@@ -466,7 +466,7 @@ class MochatChannel(BaseChannel):
|
||||
return True
|
||||
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
|
||||
if not ack.get("result"):
|
||||
logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
|
||||
logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -488,7 +488,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
await self._refresh_targets(subscribe_new=self._ws_ready)
|
||||
except Exception as e:
|
||||
logger.warning(f"Mochat refresh failed: {e}")
|
||||
logger.warning("Mochat refresh failed: {}", e)
|
||||
if self._fallback_mode:
|
||||
await self._ensure_fallback_workers()
|
||||
|
||||
@@ -502,7 +502,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/sessions/list", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"Mochat listSessions failed: {e}")
|
||||
logger.warning("Mochat listSessions failed: {}", e)
|
||||
return
|
||||
|
||||
sessions = response.get("sessions")
|
||||
@@ -536,7 +536,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
response = await self._post_json("/api/claw/groups/get", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
|
||||
logger.warning("Mochat getWorkspaceGroup failed: {}", e)
|
||||
return
|
||||
|
||||
raw_panels = response.get("panels")
|
||||
@@ -598,7 +598,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Mochat watch fallback error ({session_id}): {e}")
|
||||
logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
|
||||
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
|
||||
|
||||
async def _panel_poll_worker(self, panel_id: str) -> None:
|
||||
@@ -625,7 +625,7 @@ class MochatChannel(BaseChannel):
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
|
||||
logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
|
||||
await asyncio.sleep(sleep_s)
|
||||
|
||||
# ---- inbound event processing ------------------------------------------
|
||||
@@ -836,7 +836,7 @@ class MochatChannel(BaseChannel):
|
||||
try:
|
||||
data = json.loads(self._cursor_path.read_text("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read Mochat cursor file: {e}")
|
||||
logger.warning("Failed to read Mochat cursor file: {}", e)
|
||||
return
|
||||
cursors = data.get("cursors") if isinstance(data, dict) else None
|
||||
if isinstance(cursors, dict):
|
||||
@@ -852,7 +852,7 @@ class MochatChannel(BaseChannel):
|
||||
"cursors": self._session_cursor,
|
||||
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save Mochat cursor file: {e}")
|
||||
logger.warning("Failed to save Mochat cursor file: {}", e)
|
||||
|
||||
# ---- HTTP helpers ------------------------------------------------------
|
||||
|
||||
|
||||
+9
-11
@@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
||||
super().__init__(intents=intents)
|
||||
|
||||
async def on_ready(self):
|
||||
logger.info(f"QQ bot ready: {self.robot.name}")
|
||||
logger.info("QQ bot ready: {}", self.robot.name)
|
||||
|
||||
async def on_c2c_message_create(self, message: "C2CMessage"):
|
||||
await channel._on_message(message)
|
||||
@@ -55,7 +55,6 @@ class QQChannel(BaseChannel):
|
||||
self.config: QQConfig = config
|
||||
self._client: "botpy.Client | None" = None
|
||||
self._processed_ids: deque = deque(maxlen=1000)
|
||||
self._bot_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the QQ bot."""
|
||||
@@ -71,8 +70,8 @@ class QQChannel(BaseChannel):
|
||||
BotClass = _make_bot_class(self)
|
||||
self._client = BotClass()
|
||||
|
||||
self._bot_task = asyncio.create_task(self._run_bot())
|
||||
logger.info("QQ bot started (C2C private message)")
|
||||
await self._run_bot()
|
||||
|
||||
async def _run_bot(self) -> None:
|
||||
"""Run the bot connection with auto-reconnect."""
|
||||
@@ -80,7 +79,7 @@ class QQChannel(BaseChannel):
|
||||
try:
|
||||
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
|
||||
except Exception as e:
|
||||
logger.warning(f"QQ bot error: {e}")
|
||||
logger.warning("QQ bot error: {}", e)
|
||||
if self._running:
|
||||
logger.info("Reconnecting QQ bot in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
@@ -88,11 +87,10 @@ class QQChannel(BaseChannel):
|
||||
async def stop(self) -> None:
|
||||
"""Stop the QQ bot."""
|
||||
self._running = False
|
||||
if self._bot_task:
|
||||
self._bot_task.cancel()
|
||||
if self._client:
|
||||
try:
|
||||
await self._bot_task
|
||||
except asyncio.CancelledError:
|
||||
await self._client.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("QQ bot stopped")
|
||||
|
||||
@@ -108,7 +106,7 @@ class QQChannel(BaseChannel):
|
||||
content=msg.content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending QQ message: {e}")
|
||||
logger.error("Error sending QQ message: {}", e)
|
||||
|
||||
async def _on_message(self, data: "C2CMessage") -> None:
|
||||
"""Handle incoming message from QQ."""
|
||||
@@ -130,5 +128,5 @@ class QQChannel(BaseChannel):
|
||||
content=content,
|
||||
metadata={"message_id": data.id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling QQ message: {e}")
|
||||
except Exception:
|
||||
logger.exception("Error handling QQ message")
|
||||
|
||||
+101
-25
@@ -10,6 +10,8 @@ from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
from slackify_markdown import slackify_markdown
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
@@ -34,7 +36,7 @@ class SlackChannel(BaseChannel):
|
||||
logger.error("Slack bot/app token not configured")
|
||||
return
|
||||
if self.config.mode != "socket":
|
||||
logger.error(f"Unsupported Slack mode: {self.config.mode}")
|
||||
logger.error("Unsupported Slack mode: {}", self.config.mode)
|
||||
return
|
||||
|
||||
self._running = True
|
||||
@@ -51,9 +53,9 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
auth = await self._web_client.auth_test()
|
||||
self._bot_user_id = auth.get("user_id")
|
||||
logger.info(f"Slack bot connected as {self._bot_user_id}")
|
||||
logger.info("Slack bot connected as {}", self._bot_user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack auth_test failed: {e}")
|
||||
logger.warning("Slack auth_test failed: {}", e)
|
||||
|
||||
logger.info("Starting Slack Socket Mode client...")
|
||||
await self._socket_client.connect()
|
||||
@@ -68,7 +70,7 @@ class SlackChannel(BaseChannel):
|
||||
try:
|
||||
await self._socket_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack socket close failed: {e}")
|
||||
logger.warning("Slack socket close failed: {}", e)
|
||||
self._socket_client = None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
@@ -82,13 +84,26 @@ class SlackChannel(BaseChannel):
|
||||
channel_type = slack_meta.get("channel_type")
|
||||
# Only reply in thread for channel/group messages; DMs don't use threads
|
||||
use_thread = thread_ts and channel_type != "im"
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=msg.chat_id,
|
||||
text=msg.content or "",
|
||||
thread_ts=thread_ts if use_thread else None,
|
||||
)
|
||||
thread_ts_param = thread_ts if use_thread else None
|
||||
|
||||
if msg.content:
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=msg.chat_id,
|
||||
text=self._to_mrkdwn(msg.content),
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
|
||||
for media_path in msg.media or []:
|
||||
try:
|
||||
await self._web_client.files_upload_v2(
|
||||
channel=msg.chat_id,
|
||||
file=media_path,
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to upload file {}: {}", media_path, e)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending Slack message: {e}")
|
||||
logger.error("Error sending Slack message: {}", e)
|
||||
|
||||
async def _on_socket_request(
|
||||
self,
|
||||
@@ -150,30 +165,39 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
text = self._strip_bot_mention(text)
|
||||
|
||||
thread_ts = event.get("thread_ts") or event.get("ts")
|
||||
thread_ts = event.get("thread_ts")
|
||||
if self.config.reply_in_thread and not thread_ts:
|
||||
thread_ts = event.get("ts")
|
||||
# Add :eyes: reaction to the triggering message (best-effort)
|
||||
try:
|
||||
if self._web_client and event.get("ts"):
|
||||
await self._web_client.reactions_add(
|
||||
channel=chat_id,
|
||||
name="eyes",
|
||||
name=self.config.react_emoji,
|
||||
timestamp=event.get("ts"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Slack reactions_add failed: {e}")
|
||||
logger.debug("Slack reactions_add failed: {}", e)
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
# Thread-scoped session key for channel/group messages
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts and channel_type != "im" else None
|
||||
|
||||
try:
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"slack": {
|
||||
"event": event,
|
||||
"thread_ts": thread_ts,
|
||||
"channel_type": channel_type,
|
||||
},
|
||||
},
|
||||
session_key=session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error handling Slack message from {}", sender_id)
|
||||
|
||||
def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
|
||||
if channel_type == "im":
|
||||
@@ -203,3 +227,55 @@ class SlackChannel(BaseChannel):
|
||||
if not text or not self._bot_user_id:
|
||||
return text
|
||||
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
|
||||
|
||||
_TABLE_RE = re.compile(r"(?m)^\|.*\|$(?:\n\|[\s:|-]*\|$)(?:\n\|.*\|$)*")
|
||||
_CODE_FENCE_RE = re.compile(r"```[\s\S]*?```")
|
||||
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
|
||||
_LEFTOVER_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
||||
_LEFTOVER_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)
|
||||
_BARE_URL_RE = re.compile(r"(?<![|<])(https?://\S+)")
|
||||
|
||||
@classmethod
|
||||
def _to_mrkdwn(cls, text: str) -> str:
|
||||
"""Convert Markdown to Slack mrkdwn, including tables."""
|
||||
if not text:
|
||||
return ""
|
||||
text = cls._TABLE_RE.sub(cls._convert_table, text)
|
||||
return cls._fixup_mrkdwn(slackify_markdown(text))
|
||||
|
||||
@classmethod
|
||||
def _fixup_mrkdwn(cls, text: str) -> str:
|
||||
"""Fix markdown artifacts that slackify_markdown misses."""
|
||||
code_blocks: list[str] = []
|
||||
|
||||
def _save_code(m: re.Match) -> str:
|
||||
code_blocks.append(m.group(0))
|
||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||
|
||||
text = cls._CODE_FENCE_RE.sub(_save_code, text)
|
||||
text = cls._INLINE_CODE_RE.sub(_save_code, text)
|
||||
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
|
||||
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
|
||||
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&", "&"), text)
|
||||
|
||||
for i, block in enumerate(code_blocks):
|
||||
text = text.replace(f"\x00CB{i}\x00", block)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _convert_table(match: re.Match) -> str:
|
||||
"""Convert a Markdown table to a Slack-readable list."""
|
||||
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
|
||||
if len(lines) < 2:
|
||||
return match.group(0)
|
||||
headers = [h.strip() for h in lines[0].strip("|").split("|")]
|
||||
start = 2 if re.fullmatch(r"[|\s:\-]+", lines[1]) else 1
|
||||
rows: list[str] = []
|
||||
for line in lines[start:]:
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
cells = (cells + [""] * len(headers))[: len(headers)]
|
||||
parts = [f"**{headers[i]}**: {cells[i]}" for i in range(len(headers)) if cells[i]]
|
||||
if parts:
|
||||
rows.append(" · ".join(parts))
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
+221
-11
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||
@@ -185,33 +187,241 @@ class TelegramChannel(BaseChannel):
|
||||
if not self._app:
|
||||
logger.warning("Telegram bot not running")
|
||||
return
|
||||
|
||||
|
||||
# Stop typing indicator for this chat
|
||||
self._stop_typing(msg.chat_id)
|
||||
|
||||
# Check for suppression
|
||||
if msg.metadata.get("suppressed", False):
|
||||
logger.debug(f"Suppressed output (not sent to Telegram): {msg.content[:100]}...")
|
||||
return # Don't send to Telegram API
|
||||
|
||||
try:
|
||||
# chat_id should be the Telegram chat ID (integer)
|
||||
chat_id = int(msg.chat_id)
|
||||
# Convert markdown to Telegram HTML
|
||||
html_content = _markdown_to_telegram_html(msg.content)
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=html_content,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Check if message has media attachments
|
||||
if msg.media:
|
||||
await self._send_with_media(chat_id, html_content, msg.media)
|
||||
else:
|
||||
# Text-only message - split if too long
|
||||
await self._send_text_chunks(chat_id, html_content, parse_mode="HTML")
|
||||
except ValueError:
|
||||
logger.error(f"Invalid chat_id: {msg.chat_id}")
|
||||
except Exception as e:
|
||||
# Fallback to plain text if HTML parsing fails
|
||||
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
|
||||
try:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=int(msg.chat_id),
|
||||
text=msg.content
|
||||
)
|
||||
await self._send_text_chunks(int(msg.chat_id), msg.content, parse_mode=None)
|
||||
except Exception as e2:
|
||||
logger.error(f"Error sending Telegram message: {e2}")
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _split_message(content: str, max_len: int = 4000) -> list[str]:
|
||||
"""Split content into chunks within max_len, preferring line breaks.
|
||||
|
||||
From upstream HKUDS/nanobot - battle-tested implementation.
|
||||
Uses 4000 char limit (safer than 4096) with split priority: \n → space → hard cut.
|
||||
"""
|
||||
if len(content) <= max_len:
|
||||
return [content]
|
||||
chunks: list[str] = []
|
||||
while content:
|
||||
if len(content) <= max_len:
|
||||
chunks.append(content)
|
||||
break
|
||||
cut = content[:max_len]
|
||||
pos = cut.rfind('\n')
|
||||
if pos == -1:
|
||||
pos = cut.rfind(' ')
|
||||
if pos == -1:
|
||||
pos = max_len
|
||||
chunks.append(content[:pos])
|
||||
content = content[pos:].lstrip()
|
||||
return chunks
|
||||
|
||||
async def _send_text_chunks(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
parse_mode: str | None = "HTML"
|
||||
) -> None:
|
||||
"""Split and send long messages.
|
||||
|
||||
Telegram has a 4096 character limit per message.
|
||||
Uses upstream's proven implementation - splits at line breaks, then spaces.
|
||||
"""
|
||||
chunks = self._split_message(text)
|
||||
|
||||
for chunk in chunks:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=chunk.strip(),
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def _send_with_media(self, chat_id: int, caption: str, media_paths: list[str]) -> None:
|
||||
"""
|
||||
Send message with media attachments.
|
||||
|
||||
Args:
|
||||
chat_id: Telegram chat ID
|
||||
caption: Message caption
|
||||
media_paths: List of file paths or URLs
|
||||
"""
|
||||
from telegram import InputMediaPhoto, InputMediaVideo
|
||||
|
||||
from nanobot.channels.telegram_media import (
|
||||
MediaKind,
|
||||
classify_media,
|
||||
detect_mime,
|
||||
fetch_media,
|
||||
group_media_for_album,
|
||||
optimize_image,
|
||||
)
|
||||
|
||||
# Process each media item
|
||||
processed_media: list[tuple[str, MediaKind, bytes, str]] = []
|
||||
|
||||
for path in media_paths:
|
||||
try:
|
||||
# Fetch remote URLs
|
||||
if path.startswith(("http://", "https://")):
|
||||
content, mime = await fetch_media(path, max_bytes=100_000_000)
|
||||
kind = classify_media(mime)
|
||||
# Extract filename from URL
|
||||
filename = Path(path).name
|
||||
else:
|
||||
# Local file
|
||||
file_path = Path(path)
|
||||
if not file_path.exists():
|
||||
logger.warning(f"Media file not found: {path}")
|
||||
continue
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
mime = detect_mime(path, content)
|
||||
kind = classify_media(mime)
|
||||
# Extract filename from local path
|
||||
filename = file_path.name
|
||||
|
||||
# Optimize images
|
||||
if kind == MediaKind.IMAGE:
|
||||
try:
|
||||
content = optimize_image(path, max_bytes=6_000_000)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image optimization failed: {e}, sending original")
|
||||
|
||||
processed_media.append((path, kind, content, filename))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process media {path}: {e}")
|
||||
continue
|
||||
|
||||
if not processed_media:
|
||||
# No media could be processed, send text only
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=caption,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
# Group media for album sending
|
||||
media_items = [(path, kind) for path, kind, _, _ in processed_media]
|
||||
grouping = group_media_for_album(media_items)
|
||||
|
||||
# Handle caption length (Telegram limit: 1024 chars)
|
||||
if len(caption) > 1024:
|
||||
# Send media without caption, then follow-up text
|
||||
media_caption = None
|
||||
followup_text = caption
|
||||
else:
|
||||
media_caption = caption
|
||||
followup_text = None
|
||||
|
||||
# Send album if grouped
|
||||
if grouping["album"]:
|
||||
album_paths = grouping["album"]
|
||||
album_media = []
|
||||
|
||||
for path, kind, content, filename in processed_media:
|
||||
if path not in album_paths:
|
||||
continue
|
||||
|
||||
if kind == MediaKind.IMAGE:
|
||||
media_obj = InputMediaPhoto(
|
||||
media=content,
|
||||
caption=media_caption if len(album_media) == 0 else None,
|
||||
parse_mode="HTML" if media_caption else None
|
||||
)
|
||||
elif kind == MediaKind.VIDEO:
|
||||
media_obj = InputMediaVideo(
|
||||
media=content,
|
||||
caption=media_caption if len(album_media) == 0 else None,
|
||||
parse_mode="HTML" if media_caption else None
|
||||
)
|
||||
else:
|
||||
continue # Skip non-album types
|
||||
|
||||
album_media.append(media_obj)
|
||||
|
||||
if album_media:
|
||||
await self._app.bot.send_media_group(
|
||||
chat_id=chat_id,
|
||||
media=album_media
|
||||
)
|
||||
|
||||
# Send separate media
|
||||
for i, (path, kind, content, filename) in enumerate(processed_media):
|
||||
if path in grouping["album"]:
|
||||
continue # Already sent in album
|
||||
|
||||
# Only first separate item gets caption
|
||||
item_caption = media_caption if i == 0 else None
|
||||
|
||||
if kind == MediaKind.IMAGE:
|
||||
await self._app.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None
|
||||
)
|
||||
elif kind == MediaKind.VIDEO:
|
||||
await self._app.bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None
|
||||
)
|
||||
elif kind == MediaKind.AUDIO:
|
||||
await self._app.bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None,
|
||||
filename=filename
|
||||
)
|
||||
elif kind == MediaKind.DOCUMENT:
|
||||
await self._app.bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=content,
|
||||
caption=item_caption,
|
||||
parse_mode="HTML" if item_caption else None,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
# Send follow-up text if caption was too long
|
||||
if followup_text:
|
||||
await self._app.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=followup_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /start command."""
|
||||
if not update.message or not update.effective_user:
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Media handling utilities for Telegram channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
# Telegram API photo size limit (6MB)
|
||||
TELEGRAM_PHOTO_SIZE_LIMIT = 6_000_000
|
||||
|
||||
try:
|
||||
import magic
|
||||
HAS_MAGIC = True
|
||||
except ImportError:
|
||||
HAS_MAGIC = False
|
||||
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
register_heif_opener()
|
||||
HAS_HEIF = True
|
||||
except ImportError:
|
||||
HAS_HEIF = False
|
||||
|
||||
|
||||
class MediaKind(Enum):
|
||||
"""Media type classification."""
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
DOCUMENT = "document"
|
||||
|
||||
|
||||
def detect_mime(path: str, content: bytes | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type of media file.
|
||||
|
||||
Priority:
|
||||
1. python-magic sniff (if available and content provided)
|
||||
2. Extension-based lookup
|
||||
3. Fallback to application/octet-stream
|
||||
|
||||
Args:
|
||||
path: File path (used for extension detection)
|
||||
content: Optional file content bytes for magic sniffing
|
||||
|
||||
Returns:
|
||||
MIME type string (e.g., "image/jpeg")
|
||||
"""
|
||||
# Try magic detection first if we have content
|
||||
if HAS_MAGIC and content:
|
||||
try:
|
||||
mime = magic.from_buffer(content, mime=True)
|
||||
# Avoid generic types if we can be more specific from extension
|
||||
if mime and mime != "application/octet-stream":
|
||||
return mime
|
||||
except Exception as e:
|
||||
logger.debug(f"Magic detection failed, falling back to extension: {e}")
|
||||
|
||||
# Extension-based detection
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
if mime_type:
|
||||
return mime_type
|
||||
|
||||
# Fallback
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def classify_media(mime: str) -> MediaKind:
|
||||
"""
|
||||
Classify MIME type into media kind.
|
||||
|
||||
Args:
|
||||
mime: MIME type string (e.g., "image/jpeg")
|
||||
|
||||
Returns:
|
||||
MediaKind enum value
|
||||
"""
|
||||
if mime.startswith("image/"):
|
||||
return MediaKind.IMAGE
|
||||
if mime.startswith("video/"):
|
||||
return MediaKind.VIDEO
|
||||
if mime.startswith("audio/"):
|
||||
return MediaKind.AUDIO
|
||||
# Everything else is a document
|
||||
return MediaKind.DOCUMENT
|
||||
|
||||
|
||||
def is_heic_format(path: str) -> bool:
|
||||
"""
|
||||
Check if file is HEIC/HEIF format.
|
||||
|
||||
Args:
|
||||
path: File path
|
||||
|
||||
Returns:
|
||||
True if file extension is .heic or .heif
|
||||
"""
|
||||
ext = Path(path).suffix.lower()
|
||||
return ext in (".heic", ".heif")
|
||||
|
||||
|
||||
async def fetch_media(url: str, max_bytes: int) -> tuple[bytes, str]:
|
||||
"""
|
||||
Download media from remote URL.
|
||||
|
||||
Args:
|
||||
url: Remote URL to fetch
|
||||
max_bytes: Maximum size to download
|
||||
|
||||
Returns:
|
||||
Tuple of (content bytes, detected MIME type)
|
||||
|
||||
Raises:
|
||||
ValueError: If download fails or exceeds size limit
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
|
||||
if len(content) > max_bytes:
|
||||
raise ValueError(f"Media exceeds size limit: {len(content)} > {max_bytes}")
|
||||
|
||||
# Get MIME type from response or detect
|
||||
mime = response.headers.get("content-type", "application/octet-stream")
|
||||
# Strip charset if present (e.g., "image/jpeg; charset=utf-8" → "image/jpeg")
|
||||
mime = mime.split(";")[0].strip()
|
||||
|
||||
# Detect from content if generic type
|
||||
if mime == "application/octet-stream":
|
||||
mime = detect_mime(url, content)
|
||||
|
||||
return content, mime
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
raise ValueError(f"Download timeout: {url}") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise ValueError(f"Download failed: {url}: {e}") from e
|
||||
|
||||
|
||||
def optimize_image(path: str, max_bytes: int = TELEGRAM_PHOTO_SIZE_LIMIT) -> bytes:
|
||||
"""
|
||||
Optimize image to fit under size limit.
|
||||
|
||||
Strategy:
|
||||
1. Convert HEIC to JPEG if needed
|
||||
2. PNG with alpha → preserve with compression levels [6,7,8,9]
|
||||
3. JPEG/PNG without alpha → resize + quality grid
|
||||
|
||||
Sizes: [2048, 1536, 1280, 1024, 800] px (max dimension)
|
||||
Qualities: [80, 70, 60, 50, 40] (JPEG only)
|
||||
|
||||
Args:
|
||||
path: Path to image file
|
||||
max_bytes: Maximum size in bytes (default 6MB for Telegram)
|
||||
|
||||
Returns:
|
||||
Optimized image bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If image cannot be optimized under limit
|
||||
"""
|
||||
# Load image with context manager to ensure file handle is closed
|
||||
with Image.open(path) as img:
|
||||
# Convert HEIC to JPEG
|
||||
if is_heic_format(path):
|
||||
if not HAS_HEIF:
|
||||
raise ValueError("pillow-heif not available for HEIC conversion")
|
||||
# Convert to RGB (HEIC → JPEG)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return _optimize_jpeg(img, max_bytes)
|
||||
|
||||
# PNG with alpha channel - preserve it
|
||||
if img.mode == "RGBA" or img.mode == "LA":
|
||||
return _optimize_png(img, max_bytes)
|
||||
|
||||
# Everything else → convert to JPEG and optimize
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
return _optimize_jpeg(img, max_bytes)
|
||||
|
||||
|
||||
def _optimize_jpeg(img: Image.Image, max_bytes: int) -> bytes:
|
||||
"""Optimize JPEG with size/quality grid."""
|
||||
sizes = [2048, 1536, 1280, 1024, 800]
|
||||
qualities = [80, 70, 60, 50, 40]
|
||||
|
||||
for size in sizes:
|
||||
# Always copy to avoid mutation issues
|
||||
resized = img.copy()
|
||||
if max(img.size) > size:
|
||||
resized.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
for quality in qualities:
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="JPEG", quality=quality, optimize=True)
|
||||
data = buf.getvalue()
|
||||
|
||||
if len(data) <= max_bytes:
|
||||
return data
|
||||
|
||||
# If we get here, even smallest size/quality is too large
|
||||
raise ValueError(f"Cannot optimize image under {max_bytes} bytes")
|
||||
|
||||
|
||||
def _optimize_png(img: Image.Image, max_bytes: int) -> bytes:
|
||||
"""Optimize PNG while preserving alpha channel."""
|
||||
compress_levels = [6, 7, 8, 9]
|
||||
sizes = [2048, 1536, 1280, 1024, 800]
|
||||
|
||||
for size in sizes:
|
||||
# Always copy to avoid mutation issues
|
||||
resized = img.copy()
|
||||
if max(img.size) > size:
|
||||
resized.thumbnail((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
for compress_level in compress_levels:
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="PNG", compress_level=compress_level, optimize=True)
|
||||
data = buf.getvalue()
|
||||
|
||||
if len(data) <= max_bytes:
|
||||
return data
|
||||
|
||||
# Fallback: try converting to JPEG if still too large
|
||||
if img.mode in ("RGBA", "LA"):
|
||||
# Create white background
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "RGBA":
|
||||
background.paste(img, mask=img.split()[3]) # Use alpha as mask
|
||||
else: # LA (grayscale + alpha)
|
||||
background.paste(img.convert("L"), mask=img.split()[1])
|
||||
return _optimize_jpeg(background, max_bytes)
|
||||
|
||||
raise ValueError(f"Cannot optimize PNG under {max_bytes} bytes")
|
||||
|
||||
|
||||
def group_media_for_album(media_items: list[tuple[str, MediaKind]]) -> dict[str, list[str]]:
|
||||
"""
|
||||
Group media items for album sending.
|
||||
|
||||
Logic:
|
||||
- All images (2+) → album
|
||||
- All videos (2+) → album
|
||||
- Mixed types → separate
|
||||
- Single item → separate
|
||||
|
||||
Args:
|
||||
media_items: List of (path, MediaKind) tuples
|
||||
|
||||
Returns:
|
||||
Dict with 'album' and 'separate' keys containing lists of paths
|
||||
"""
|
||||
if len(media_items) <= 1:
|
||||
return {
|
||||
"album": [],
|
||||
"separate": [path for path, _ in media_items]
|
||||
}
|
||||
|
||||
# Count each kind
|
||||
kinds = [kind for _, kind in media_items]
|
||||
unique_kinds = set(kinds)
|
||||
|
||||
# All same type → album (if images or videos)
|
||||
if len(unique_kinds) == 1:
|
||||
kind = kinds[0]
|
||||
if kind in (MediaKind.IMAGE, MediaKind.VIDEO):
|
||||
return {
|
||||
"album": [path for path, _ in media_items],
|
||||
"separate": []
|
||||
}
|
||||
|
||||
# Mixed types or non-album-able types → separate
|
||||
return {
|
||||
"album": [],
|
||||
"separate": [path for path, _ in media_items]
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
|
||||
bridge_url = self.config.bridge_url
|
||||
|
||||
logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...")
|
||||
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
|
||||
|
||||
self._running = True
|
||||
|
||||
@@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel):
|
||||
try:
|
||||
await self._handle_bridge_message(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling bridge message: {e}")
|
||||
logger.error("Error handling bridge message: {}", e)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
self._ws = None
|
||||
logger.warning(f"WhatsApp bridge connection error: {e}")
|
||||
logger.warning("WhatsApp bridge connection error: {}", e)
|
||||
|
||||
if self._running:
|
||||
logger.info("Reconnecting in 5 seconds...")
|
||||
@@ -87,16 +87,16 @@ class WhatsAppChannel(BaseChannel):
|
||||
"to": msg.chat_id,
|
||||
"text": msg.content
|
||||
}
|
||||
await self._ws.send(json.dumps(payload))
|
||||
await self._ws.send(json.dumps(payload, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending WhatsApp message: {e}")
|
||||
logger.error("Error sending WhatsApp message: {}", e)
|
||||
|
||||
async def _handle_bridge_message(self, raw: str) -> None:
|
||||
"""Handle a message from the bridge."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON from bridge: {raw[:100]}")
|
||||
logger.warning("Invalid JSON from bridge: {}", raw[:100])
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
@@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel):
|
||||
# Extract just the phone number or lid as chat_id
|
||||
user_id = pn if pn else sender
|
||||
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
|
||||
logger.info(f"Sender {sender}")
|
||||
logger.info("Sender {}", sender)
|
||||
|
||||
# Handle voice transcription if it's a voice message
|
||||
if content == "[Voice Message]":
|
||||
logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.")
|
||||
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
|
||||
content = "[Voice Message: Transcription not available for WhatsApp yet]"
|
||||
|
||||
await self._handle_message(
|
||||
@@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel):
|
||||
elif msg_type == "status":
|
||||
# Connection status update
|
||||
status = data.get("status")
|
||||
logger.info(f"WhatsApp status: {status}")
|
||||
logger.info("WhatsApp status: {}", status)
|
||||
|
||||
if status == "connected":
|
||||
self._connected = True
|
||||
@@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel):
|
||||
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
|
||||
|
||||
elif msg_type == "error":
|
||||
logger.error(f"WhatsApp bridge error: {data.get('error')}")
|
||||
logger.error("WhatsApp bridge error: {}", data.get('error'))
|
||||
|
||||
+219
-99
@@ -2,23 +2,26 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
from nanobot import __version__, __logo__
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.cli.oauth import oauth_app
|
||||
|
||||
app = typer.Typer(
|
||||
name="nanobot",
|
||||
@@ -158,26 +161,26 @@ def onboard():
|
||||
from nanobot.config.loader import get_config_path, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
|
||||
|
||||
config_path = get_config_path()
|
||||
|
||||
|
||||
if config_path.exists():
|
||||
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
||||
if not typer.confirm("Overwrite?"):
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
# Create default config
|
||||
config = Config()
|
||||
save_config(config)
|
||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||
|
||||
|
||||
# Create workspace
|
||||
workspace = get_workspace_path()
|
||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||
|
||||
|
||||
# Create default bootstrap files
|
||||
_create_workspace_templates(workspace)
|
||||
|
||||
|
||||
console.print(f"\n{__logo__} nanobot is ready!")
|
||||
console.print("\nNext steps:")
|
||||
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
|
||||
@@ -229,13 +232,13 @@ Information about the user goes here.
|
||||
- Language: (your preferred language)
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
for filename, content in templates.items():
|
||||
file_path = workspace / filename
|
||||
if not file_path.exists():
|
||||
file_path.write_text(content)
|
||||
console.print(f" [dim]Created {filename}[/dim]")
|
||||
|
||||
|
||||
# Create memory directory and MEMORY.md
|
||||
memory_dir = workspace / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
@@ -258,7 +261,7 @@ This file stores important information that should persist across sessions.
|
||||
(Things to remember)
|
||||
""")
|
||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
||||
|
||||
|
||||
history_file = memory_dir / "HISTORY.md"
|
||||
if not history_file.exists():
|
||||
history_file.write_text("")
|
||||
@@ -293,36 +296,81 @@ def _make_provider(config):
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _start_moltbook_loop():
|
||||
"""Start the moltbook polling loop in the background."""
|
||||
loop_script = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.sh"
|
||||
log_file = Path.home() / ".nanobot" / "scripts" / "moltbook-loop.log"
|
||||
|
||||
if not loop_script.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["/bin/bash", str(loop_script)],
|
||||
stdout=open(log_file, "a"),
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Moltbook polling: every 15m")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not start moltbook loop: {e}[/yellow]")
|
||||
|
||||
|
||||
@app.command()
|
||||
def gateway(
|
||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
from nanobot.config.loader import load_config, get_data_dir
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
if verbose:
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||
|
||||
|
||||
config = load_config()
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
|
||||
# Create cron service first (callback set after agent creation)
|
||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
|
||||
# Convert mem0 config to dict for AgentLoop
|
||||
mem0_config = None
|
||||
if config.tools.mem0.enabled:
|
||||
mem0_config = {
|
||||
"enabled": True,
|
||||
"search_limit": config.tools.mem0.search_limit,
|
||||
}
|
||||
if config.tools.mem0.api_key:
|
||||
mem0_config["api_key"] = config.tools.mem0.api_key
|
||||
if config.tools.mem0.llm:
|
||||
mem0_config["llm"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.llm}
|
||||
}
|
||||
if config.tools.mem0.embedder:
|
||||
mem0_config["embedder"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.embedder}
|
||||
}
|
||||
if config.tools.mem0.vector_store:
|
||||
mem0_config["vector_store"] = config.tools.mem0.vector_store
|
||||
|
||||
# DEBUG: Log what's being passed
|
||||
logger.debug(f"Passing mem0_config to AgentLoop: {list(mem0_config.keys())}")
|
||||
|
||||
# Create agent with cron service
|
||||
agent = AgentLoop(
|
||||
bus=bus,
|
||||
@@ -335,9 +383,11 @@ def gateway(
|
||||
exec_config=config.tools.exec,
|
||||
cron_service=cron,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
enable_memory_tool=config.tools.enable_memory_tool,
|
||||
mem0_config=mem0_config,
|
||||
session_manager=session_manager,
|
||||
)
|
||||
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
@@ -356,48 +406,84 @@ def gateway(
|
||||
))
|
||||
return response
|
||||
cron.on_job = on_cron_job
|
||||
|
||||
|
||||
# Create heartbeat service
|
||||
async def on_heartbeat(prompt: str) -> str:
|
||||
async def on_heartbeat(prompt: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
"""Execute heartbeat through the agent."""
|
||||
return await agent.process_direct(prompt, session_key="heartbeat")
|
||||
|
||||
return await agent.process_direct(
|
||||
prompt,
|
||||
session_key="telegram:239824268", # Run in main telegram session
|
||||
channel="telegram",
|
||||
chat_id="239824268",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=30 * 60, # 30 minutes
|
||||
enabled=True
|
||||
enabled=True,
|
||||
session_manager=session_manager, # Pass session manager
|
||||
target_session_key="telegram:239824268", # Target session
|
||||
idle_threshold_s=20 * 60, # 20 minutes idle
|
||||
)
|
||||
|
||||
|
||||
# Create channel manager
|
||||
channels = ChannelManager(config, bus)
|
||||
|
||||
|
||||
# Create hooks server
|
||||
from nanobot.channels.hook import HookChannel
|
||||
from nanobot.hooks.server import HooksServer
|
||||
|
||||
hooks_config = config.hooks if hasattr(config, 'hooks') else None
|
||||
hooks_server = None
|
||||
|
||||
if hooks_config and hooks_config.enabled:
|
||||
# Register hook channel
|
||||
hook_channel = HookChannel(bus)
|
||||
channels.register_channel("hook", hook_channel)
|
||||
|
||||
# Create hooks server (checks has_tokens internally)
|
||||
hooks_server = HooksServer(
|
||||
host=config.gateway.host,
|
||||
port=config.gateway.port,
|
||||
config=hooks_config,
|
||||
bus=bus,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Hooks: {hooks_config.path} on port {config.gateway.port}")
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
else:
|
||||
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
||||
|
||||
|
||||
cron_status = cron.status()
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
|
||||
console.print("[green]✓[/green] Heartbeat: every 30m")
|
||||
|
||||
_start_moltbook_loop()
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
await heartbeat.start()
|
||||
if hooks_server:
|
||||
await hooks_server.start()
|
||||
await asyncio.gather(
|
||||
agent.run(),
|
||||
channels.start_all(),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
if hooks_server:
|
||||
await hooks_server.stop()
|
||||
heartbeat.stop()
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
await channels.stop_all()
|
||||
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@@ -416,13 +502,14 @@ def agent(
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||
):
|
||||
"""Interact with the agent directly."""
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from loguru import logger
|
||||
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
config = load_config()
|
||||
|
||||
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
|
||||
@@ -430,7 +517,29 @@ def agent(
|
||||
logger.enable("nanobot")
|
||||
else:
|
||||
logger.disable("nanobot")
|
||||
|
||||
|
||||
# Convert mem0 config to dict for AgentLoop
|
||||
mem0_config = None
|
||||
if config.tools.mem0.enabled:
|
||||
mem0_config = {
|
||||
"enabled": True,
|
||||
"search_limit": config.tools.mem0.search_limit,
|
||||
}
|
||||
if config.tools.mem0.api_key:
|
||||
mem0_config["api_key"] = config.tools.mem0.api_key
|
||||
if config.tools.mem0.llm:
|
||||
mem0_config["llm"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.llm}
|
||||
}
|
||||
if config.tools.mem0.embedder:
|
||||
mem0_config["embedder"] = {
|
||||
"provider": "openai",
|
||||
"config": {"model": config.tools.mem0.embedder}
|
||||
}
|
||||
if config.tools.mem0.vector_store:
|
||||
mem0_config["vector_store"] = config.tools.mem0.vector_store
|
||||
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
@@ -441,8 +550,10 @@ def agent(
|
||||
brave_api_key=config.tools.web.search.api_key or None,
|
||||
exec_config=config.tools.exec,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
enable_memory_tool=config.tools.enable_memory_tool,
|
||||
mem0_config=mem0_config,
|
||||
)
|
||||
|
||||
|
||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
||||
def _thinking_ctx():
|
||||
if logs:
|
||||
@@ -457,7 +568,7 @@ def agent(
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(message, session_id)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
|
||||
|
||||
asyncio.run(run_once())
|
||||
else:
|
||||
# Interactive mode
|
||||
@@ -470,7 +581,7 @@ def agent(
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
||||
|
||||
|
||||
async def run_interactive():
|
||||
while True:
|
||||
try:
|
||||
@@ -484,7 +595,7 @@ def agent(
|
||||
_restore_terminal()
|
||||
console.print("\nGoodbye!")
|
||||
break
|
||||
|
||||
|
||||
with _thinking_ctx():
|
||||
response = await agent_loop.process_direct(user_input, session_id)
|
||||
_print_agent_response(response, render_markdown=markdown)
|
||||
@@ -496,7 +607,7 @@ def agent(
|
||||
_restore_terminal()
|
||||
console.print("\nGoodbye!")
|
||||
break
|
||||
|
||||
|
||||
asyncio.run(run_interactive())
|
||||
|
||||
|
||||
@@ -508,7 +619,6 @@ def agent(
|
||||
channels_app = typer.Typer(help="Manage channels")
|
||||
app.add_typer(channels_app, name="channels")
|
||||
|
||||
from nanobot.cli.oauth import oauth_app
|
||||
app.add_typer(oauth_app, name="oauth")
|
||||
|
||||
|
||||
@@ -556,7 +666,7 @@ def channels_status():
|
||||
"✓" if mc.enabled else "✗",
|
||||
mc_base
|
||||
)
|
||||
|
||||
|
||||
# Telegram
|
||||
tg = config.channels.telegram
|
||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||
@@ -582,57 +692,57 @@ def _get_bridge_dir() -> Path:
|
||||
"""Get the bridge directory, setting it up if needed."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
# User's bridge location
|
||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
# Check if already built
|
||||
if (user_bridge / "dist" / "index.js").exists():
|
||||
return user_bridge
|
||||
|
||||
|
||||
# Check for npm
|
||||
if not shutil.which("npm"):
|
||||
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
# Find source bridge: first check package data, then source dir
|
||||
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
|
||||
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
||||
|
||||
|
||||
source = None
|
||||
if (pkg_bridge / "package.json").exists():
|
||||
source = pkg_bridge
|
||||
elif (src_bridge / "package.json").exists():
|
||||
source = src_bridge
|
||||
|
||||
|
||||
if not source:
|
||||
console.print("[red]Bridge source not found.[/red]")
|
||||
console.print("Try reinstalling: pip install --force-reinstall nanobot")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
console.print(f"{__logo__} Setting up bridge...")
|
||||
|
||||
|
||||
# Copy to user directory
|
||||
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
||||
if user_bridge.exists():
|
||||
shutil.rmtree(user_bridge)
|
||||
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
||||
|
||||
|
||||
# Install and build
|
||||
try:
|
||||
console.print(" Installing dependencies...")
|
||||
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print(" Building...")
|
||||
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
||||
|
||||
|
||||
console.print("[green]✓[/green] Bridge ready\n")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Build failed: {e}[/red]")
|
||||
if e.stderr:
|
||||
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
return user_bridge
|
||||
|
||||
|
||||
@@ -640,18 +750,19 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login():
|
||||
"""Link device via QR code."""
|
||||
import subprocess
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
|
||||
config = load_config()
|
||||
bridge_dir = _get_bridge_dir()
|
||||
|
||||
|
||||
console.print(f"{__logo__} Starting bridge...")
|
||||
console.print("Scan the QR code to connect.\n")
|
||||
|
||||
|
||||
env = {**os.environ}
|
||||
if config.channels.whatsapp.bridge_token:
|
||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||
|
||||
|
||||
try:
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -675,23 +786,23 @@ def cron_list(
|
||||
"""List scheduled jobs."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
jobs = service.list_jobs(include_disabled=all)
|
||||
|
||||
|
||||
if not jobs:
|
||||
console.print("No scheduled jobs.")
|
||||
return
|
||||
|
||||
|
||||
table = Table(title="Scheduled Jobs")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Schedule")
|
||||
table.add_column("Status")
|
||||
table.add_column("Next Run")
|
||||
|
||||
|
||||
import time
|
||||
for job in jobs:
|
||||
# Format schedule
|
||||
@@ -701,17 +812,17 @@ def cron_list(
|
||||
sched = job.schedule.expr or ""
|
||||
else:
|
||||
sched = "one-time"
|
||||
|
||||
|
||||
# Format next run
|
||||
next_run = ""
|
||||
if job.state.next_run_at_ms:
|
||||
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
||||
next_run = next_time
|
||||
|
||||
|
||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||
|
||||
|
||||
table.add_row(job.id, job.name, sched, status, next_run)
|
||||
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -721,6 +832,7 @@ def cron_add(
|
||||
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
||||
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
||||
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
||||
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
|
||||
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
||||
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
||||
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
||||
@@ -730,12 +842,16 @@ def cron_add(
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
|
||||
if tz and not cron_expr:
|
||||
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Determine schedule type
|
||||
if every:
|
||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
||||
elif cron_expr:
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||
elif at:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromisoformat(at)
|
||||
@@ -743,19 +859,23 @@ def cron_add(
|
||||
else:
|
||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||
|
||||
|
||||
@@ -766,10 +886,10 @@ def cron_remove(
|
||||
"""Remove a scheduled job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
if service.remove_job(job_id):
|
||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||
else:
|
||||
@@ -784,10 +904,10 @@ def cron_enable(
|
||||
"""Enable or disable a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
job = service.enable_job(job_id, enabled=not disable)
|
||||
if job:
|
||||
status = "disabled" if disable else "enabled"
|
||||
@@ -804,15 +924,15 @@ def cron_run(
|
||||
"""Manually run a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
|
||||
async def run():
|
||||
return await service.run_job(job_id, force=force)
|
||||
|
||||
|
||||
if asyncio.run(run()):
|
||||
console.print(f"[green]✓[/green] Job executed")
|
||||
console.print("[green]✓[/green] Job executed")
|
||||
else:
|
||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||
|
||||
@@ -825,7 +945,7 @@ def cron_run(
|
||||
@app.command()
|
||||
def status():
|
||||
"""Show nanobot status."""
|
||||
from nanobot.config.loader import load_config, get_config_path
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
|
||||
config_path = get_config_path()
|
||||
config = load_config()
|
||||
@@ -840,7 +960,7 @@ def status():
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
console.print(f"Model: {config.agents.defaults.model}")
|
||||
|
||||
|
||||
# Check API keys from registry
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(config.providers, spec.name, None)
|
||||
|
||||
+31
-46
@@ -1,14 +1,21 @@
|
||||
"""Configuration loading utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the default configuration file path."""
|
||||
"""Get the configuration file path.
|
||||
|
||||
Checks NANOBOT_CONFIG environment variable first, otherwise defaults
|
||||
to ~/.nanobot/config.json
|
||||
"""
|
||||
env_path = os.getenv("NANOBOT_CONFIG")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
return Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
|
||||
@@ -49,10 +56,10 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data = _migrate_config(data)
|
||||
config = Config.model_validate(convert_keys(data))
|
||||
config = Config.model_validate(data)
|
||||
return _inject_oauth_credentials(config)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
print(f"Warning: Failed to load config from {path}: {e}")
|
||||
@@ -64,20 +71,18 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
"""
|
||||
Save configuration to file.
|
||||
|
||||
|
||||
Args:
|
||||
config: Configuration to save.
|
||||
config_path: Optional path to save to. Uses default if not provided.
|
||||
"""
|
||||
path = config_path or get_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert to camelCase format
|
||||
data = config.model_dump()
|
||||
data = convert_to_camel(data)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
data = config.model_dump(by_alias=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _migrate_config(data: dict) -> dict:
|
||||
@@ -87,38 +92,18 @@ def _migrate_config(data: dict) -> dict:
|
||||
exec_cfg = tools.get("exec", {})
|
||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||
|
||||
# Extract api_key from oauthCredentials if present
|
||||
providers = data.get("providers", {})
|
||||
for _, provider_config in providers.items():
|
||||
if isinstance(provider_config, dict):
|
||||
oauth_creds = provider_config.get("oauthCredentials")
|
||||
if oauth_creds and isinstance(oauth_creds, dict):
|
||||
access_token = oauth_creds.get("access_token", "")
|
||||
# Only set api_key if not already set and access_token exists
|
||||
if access_token and not provider_config.get("api_key"):
|
||||
provider_config["api_key"] = access_token
|
||||
# Clean up migrated data to avoid duplication
|
||||
del provider_config["oauthCredentials"]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def convert_keys(data: Any) -> Any:
|
||||
"""Convert camelCase keys to snake_case for Pydantic."""
|
||||
if isinstance(data, dict):
|
||||
return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [convert_keys(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def convert_to_camel(data: Any) -> Any:
|
||||
"""Convert snake_case keys to camelCase."""
|
||||
if isinstance(data, dict):
|
||||
return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
|
||||
if isinstance(data, list):
|
||||
return [convert_to_camel(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def camel_to_snake(name: str) -> str:
|
||||
"""Convert camelCase to snake_case."""
|
||||
result = []
|
||||
for i, char in enumerate(name):
|
||||
if char.isupper() and i > 0:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def snake_to_camel(name: str) -> str:
|
||||
"""Convert snake_case to camelCase."""
|
||||
components = name.split("_")
|
||||
return components[0] + "".join(x.title() for x in components[1:])
|
||||
|
||||
+188
-34
@@ -1,54 +1,89 @@
|
||||
"""Configuration schema using Pydantic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class WhatsAppConfig(BaseModel):
|
||||
class Base(BaseModel):
|
||||
"""Base model that accepts both camelCase and snake_case keys."""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class WhatsAppConfig(Base):
|
||||
"""WhatsApp channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
bridge_url: str = "ws://localhost:3001"
|
||||
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||
|
||||
|
||||
class TelegramConfig(BaseModel):
|
||||
class TelegramConfig(Base):
|
||||
"""Telegram channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
token: str = "" # Bot token from @BotFather
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
|
||||
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
||||
|
||||
|
||||
class FeishuConfig(BaseModel):
|
||||
class FeishuConfig(Base):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = "" # App ID from Feishu Open Platform
|
||||
app_secret: str = "" # App Secret from Feishu Open Platform
|
||||
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
|
||||
verification_token: str = "" # Verification Token for event subscription (optional)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
||||
react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
|
||||
|
||||
|
||||
class DingTalkConfig(BaseModel):
|
||||
class DingTalkConfig(Base):
|
||||
"""DingTalk channel configuration using Stream mode."""
|
||||
|
||||
enabled: bool = False
|
||||
client_id: str = "" # AppKey
|
||||
client_secret: str = "" # AppSecret
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
|
||||
|
||||
|
||||
class DiscordConfig(BaseModel):
|
||||
class DiscordConfig(Base):
|
||||
"""Discord channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
token: str = "" # Bot token from Discord Developer Portal
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
||||
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||
|
||||
class EmailConfig(BaseModel):
|
||||
|
||||
class MatrixConfig(Base):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
homeserver: str = "https://matrix.org"
|
||||
access_token: str = ""
|
||||
user_id: str = "" # @bot:matrix.org
|
||||
device_id: str = ""
|
||||
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
|
||||
sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
||||
max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound).
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False
|
||||
|
||||
|
||||
class EmailConfig(Base):
|
||||
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
|
||||
|
||||
enabled: bool = False
|
||||
consent_granted: bool = False # Explicit owner permission to access mailbox data
|
||||
|
||||
@@ -78,18 +113,21 @@ class EmailConfig(BaseModel):
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
||||
|
||||
|
||||
class MochatMentionConfig(BaseModel):
|
||||
class MochatMentionConfig(Base):
|
||||
"""Mochat mention behavior configuration."""
|
||||
|
||||
require_in_groups: bool = False
|
||||
|
||||
|
||||
class MochatGroupRule(BaseModel):
|
||||
class MochatGroupRule(Base):
|
||||
"""Mochat per-group mention requirement."""
|
||||
|
||||
require_mention: bool = False
|
||||
|
||||
|
||||
class MochatConfig(BaseModel):
|
||||
class MochatConfig(Base):
|
||||
"""Mochat channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = "https://mochat.io"
|
||||
socket_url: str = ""
|
||||
@@ -114,36 +152,58 @@ class MochatConfig(BaseModel):
|
||||
reply_delay_ms: int = 120000
|
||||
|
||||
|
||||
class SlackDMConfig(BaseModel):
|
||||
class SlackDMConfig(Base):
|
||||
"""Slack DM policy configuration."""
|
||||
|
||||
enabled: bool = True
|
||||
policy: str = "open" # "open" or "allowlist"
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
|
||||
|
||||
|
||||
class SlackConfig(BaseModel):
|
||||
class SlackConfig(Base):
|
||||
"""Slack channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
mode: str = "socket" # "socket" supported
|
||||
webhook_path: str = "/slack/events"
|
||||
bot_token: str = "" # xoxb-...
|
||||
app_token: str = "" # xapp-...
|
||||
user_token_read_only: bool = True
|
||||
reply_in_thread: bool = True
|
||||
react_emoji: str = "eyes"
|
||||
group_policy: str = "mention" # "mention", "open", "allowlist"
|
||||
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||
|
||||
|
||||
class QQConfig(BaseModel):
|
||||
class QQConfig(Base):
|
||||
"""QQ channel configuration using botpy SDK."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
|
||||
|
||||
class MatrixConfig(Base):
|
||||
"""Matrix (Element) channel configuration."""
|
||||
enabled: bool = False
|
||||
homeserver: str = "https://matrix.org"
|
||||
access_token: str = ""
|
||||
user_id: str = "" # e.g. @bot:matrix.org
|
||||
device_id: str = ""
|
||||
e2ee_enabled: bool = True # end-to-end encryption support
|
||||
sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout
|
||||
max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||
group_allow_from: list[str] = Field(default_factory=list)
|
||||
allow_room_mentions: bool = False
|
||||
|
||||
class ChannelsConfig(BaseModel):
|
||||
class ChannelsConfig(Base):
|
||||
"""Configuration for chat channels."""
|
||||
|
||||
send_progress: bool = True # stream agent's text progress to the channel
|
||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||
@@ -153,21 +213,25 @@ class ChannelsConfig(BaseModel):
|
||||
email: EmailConfig = Field(default_factory=EmailConfig)
|
||||
slack: SlackConfig = Field(default_factory=SlackConfig)
|
||||
qq: QQConfig = Field(default_factory=QQConfig)
|
||||
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
|
||||
|
||||
|
||||
class AgentDefaults(BaseModel):
|
||||
class AgentDefaults(Base):
|
||||
"""Default agent configuration."""
|
||||
|
||||
workspace: str = "~/.nanobot/workspace"
|
||||
model: str = "anthropic/claude-opus-4-5"
|
||||
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||
max_tokens: int = 8192
|
||||
temperature: float = 0.7
|
||||
max_tool_iterations: int = 20
|
||||
memory_window: int = 50
|
||||
temperature: float = 0.1
|
||||
max_tool_iterations: int = 40
|
||||
memory_window: int = 100
|
||||
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
|
||||
|
||||
|
||||
class AgentsConfig(BaseModel):
|
||||
class AgentsConfig(Base):
|
||||
"""Agent configuration."""
|
||||
|
||||
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
|
||||
|
||||
|
||||
@@ -200,16 +264,19 @@ class OAuthCredentials(BaseModel):
|
||||
return time.time() > (self.expires_at - 600)
|
||||
|
||||
|
||||
class ProviderConfig(BaseModel):
|
||||
class ProviderConfig(Base):
|
||||
"""LLM provider configuration."""
|
||||
|
||||
api_key: str = ""
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
oauth_credentials: OAuthCredentials | None = None
|
||||
|
||||
|
||||
class ProvidersConfig(BaseModel):
|
||||
class ProvidersConfig(Base):
|
||||
"""Configuration for LLM providers."""
|
||||
|
||||
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
|
||||
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openai: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
openrouter: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
@@ -222,63 +289,152 @@ class ProvidersConfig(BaseModel):
|
||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway
|
||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway
|
||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
|
||||
|
||||
|
||||
class GatewayConfig(BaseModel):
|
||||
class HeartbeatConfig(Base):
|
||||
"""Heartbeat service configuration."""
|
||||
|
||||
enabled: bool = True
|
||||
interval_s: int = 30 * 60 # 30 minutes
|
||||
|
||||
|
||||
class GatewayConfig(Base):
|
||||
"""Gateway/server configuration."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 18790
|
||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||
|
||||
|
||||
class HooksConfig(BaseModel):
|
||||
"""Webhook endpoint configuration."""
|
||||
enabled: bool = False
|
||||
tokens: dict[str, str] = Field(default_factory=dict) # Named tokens: {name: secret}
|
||||
path: str = "/hooks" # URL path for the endpoint
|
||||
timeout_seconds: int = 120 # Max time to wait for agent response
|
||||
|
||||
def resolve_token(self, provided: str) -> str | None:
|
||||
"""Return token name if provided secret matches, else None."""
|
||||
for name, secret in self.tokens.items():
|
||||
if secret == provided:
|
||||
return name
|
||||
return None
|
||||
|
||||
@property
|
||||
def has_tokens(self) -> bool:
|
||||
"""True if at least one token is configured."""
|
||||
return bool(self.tokens)
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
"""Web search tool configuration."""
|
||||
|
||||
api_key: str = "" # Brave Search API key
|
||||
max_results: int = 5
|
||||
|
||||
|
||||
class WebToolsConfig(BaseModel):
|
||||
class WebToolsConfig(Base):
|
||||
"""Web tools configuration."""
|
||||
|
||||
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||
|
||||
|
||||
class ExecToolConfig(BaseModel):
|
||||
class ExecToolConfig(Base):
|
||||
"""Shell exec tool configuration."""
|
||||
|
||||
timeout: int = 60
|
||||
path_append: str = ""
|
||||
|
||||
|
||||
class ToolsConfig(BaseModel):
|
||||
class MCPServerConfig(Base):
|
||||
"""MCP server connection configuration (stdio or HTTP)."""
|
||||
|
||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||
url: str = "" # HTTP: streamable HTTP endpoint URL
|
||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
|
||||
tool_timeout: int = 30 # Seconds before a tool call is cancelled
|
||||
|
||||
|
||||
class Mem0Config(Base):
|
||||
"""Mem0 memory system configuration."""
|
||||
|
||||
enabled: bool = False # If true, use mem0 for semantic memory instead of simple MEMORY.md
|
||||
api_key: str = "" # Optional: mem0 cloud API key (leave empty for self-hosted)
|
||||
search_limit: int = 5 # Max memories to retrieve per query
|
||||
llm: str = "" # Optional: LLM for memory extraction (default: gpt-4.1-nano-2025-04-14)
|
||||
embedder: str = "" # Optional: Embedding model (default: mem0's default)
|
||||
vector_store: dict[str, Any] = Field(default_factory=dict) # Vector store config (e.g., {"provider": "qdrant", "config": {...}})
|
||||
|
||||
|
||||
class ToolsConfig(Base):
|
||||
"""Tools configuration."""
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||
enable_memory_tool: bool = True # If true, enable Anthropic's native memory tool
|
||||
mem0: Mem0Config = Field(default_factory=Mem0Config) # Mem0 semantic memory configuration
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
"""Root configuration for nanobot."""
|
||||
|
||||
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
||||
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
||||
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||
|
||||
|
||||
@property
|
||||
def workspace_path(self) -> Path:
|
||||
"""Get expanded workspace path."""
|
||||
return Path(self.agents.defaults.workspace).expanduser()
|
||||
|
||||
|
||||
def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
|
||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
forced = self.agents.defaults.provider
|
||||
if forced != "auto":
|
||||
p = getattr(self.providers, forced, None)
|
||||
return (p, forced) if p else (None, None)
|
||||
|
||||
model_lower = (model or self.agents.defaults.model).lower()
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
|
||||
def _kw_matches(kw: str) -> bool:
|
||||
kw = kw.lower()
|
||||
return kw in model_lower or kw.replace("-", "_") in model_normalized
|
||||
|
||||
# Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and model_prefix and normalized_prefix == spec.name:
|
||||
if spec.is_oauth or p.api_key:
|
||||
return p, spec.name
|
||||
|
||||
# Match by keyword (order follows PROVIDERS registry)
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
|
||||
return p, spec.name
|
||||
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||
if spec.is_oauth or p.api_key:
|
||||
return p, spec.name
|
||||
|
||||
# Fallback: gateways first, then others (follows registry order)
|
||||
# OAuth providers are NOT valid fallbacks — they require explicit model selection
|
||||
for spec in PROVIDERS:
|
||||
if spec.is_oauth:
|
||||
continue
|
||||
p = getattr(self.providers, spec.name, None)
|
||||
if p and p.api_key:
|
||||
return p, spec.name
|
||||
@@ -298,10 +454,11 @@ class Config(BaseSettings):
|
||||
"""Get API key for the given model. Falls back to first available key."""
|
||||
p = self.get_provider(model)
|
||||
return p.api_key if p else None
|
||||
|
||||
|
||||
def get_api_base(self, model: str | None = None) -> str | None:
|
||||
"""Get API base URL for the given model. Applies default URLs for known gateways."""
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
p, name = self._match_provider(model)
|
||||
if p and p.api_base:
|
||||
return p.api_base
|
||||
@@ -313,8 +470,5 @@ class Config(BaseSettings):
|
||||
if spec and spec.is_gateway and spec.default_api_base:
|
||||
return spec.default_api_base
|
||||
return None
|
||||
|
||||
model_config = ConfigDict(
|
||||
env_prefix="NANOBOT_",
|
||||
env_nested_delimiter="__"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
|
||||
|
||||
+33
-12
@@ -4,6 +4,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
@@ -30,15 +31,34 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
|
||||
if schedule.kind == "cron" and schedule.expr:
|
||||
try:
|
||||
from croniter import croniter
|
||||
cron = croniter(schedule.expr, time.time())
|
||||
next_time = cron.get_next()
|
||||
return int(next_time * 1000)
|
||||
from zoneinfo import ZoneInfo
|
||||
# Use caller-provided reference time for deterministic scheduling
|
||||
base_time = now_ms / 1000
|
||||
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
|
||||
base_dt = datetime.fromtimestamp(base_time, tz=tz)
|
||||
cron = croniter(schedule.expr, base_dt)
|
||||
next_dt = cron.get_next(datetime)
|
||||
return int(next_dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_schedule_for_add(schedule: CronSchedule) -> None:
|
||||
"""Validate schedule fields that would otherwise create non-runnable jobs."""
|
||||
if schedule.tz and schedule.kind != "cron":
|
||||
raise ValueError("tz can only be used with cron schedules")
|
||||
|
||||
if schedule.kind == "cron" and schedule.tz:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
ZoneInfo(schedule.tz)
|
||||
except Exception:
|
||||
raise ValueError(f"unknown timezone '{schedule.tz}'") from None
|
||||
|
||||
|
||||
class CronService:
|
||||
"""Service for managing and executing scheduled jobs."""
|
||||
|
||||
@@ -60,7 +80,7 @@ class CronService:
|
||||
|
||||
if self.store_path.exists():
|
||||
try:
|
||||
data = json.loads(self.store_path.read_text())
|
||||
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
||||
jobs = []
|
||||
for j in data.get("jobs", []):
|
||||
jobs.append(CronJob(
|
||||
@@ -93,7 +113,7 @@ class CronService:
|
||||
))
|
||||
self._store = CronStore(jobs=jobs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cron store: {e}")
|
||||
logger.warning("Failed to load cron store: {}", e)
|
||||
self._store = CronStore()
|
||||
else:
|
||||
self._store = CronStore()
|
||||
@@ -142,7 +162,7 @@ class CronService:
|
||||
]
|
||||
}
|
||||
|
||||
self.store_path.write_text(json.dumps(data, indent=2))
|
||||
self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the cron service."""
|
||||
@@ -151,7 +171,7 @@ class CronService:
|
||||
self._recompute_next_runs()
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")
|
||||
logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else []))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the cron service."""
|
||||
@@ -216,7 +236,7 @@ class CronService:
|
||||
async def _execute_job(self, job: CronJob) -> None:
|
||||
"""Execute a single job."""
|
||||
start_ms = _now_ms()
|
||||
logger.info(f"Cron: executing job '{job.name}' ({job.id})")
|
||||
logger.info("Cron: executing job '{}' ({})", job.name, job.id)
|
||||
|
||||
try:
|
||||
response = None
|
||||
@@ -225,12 +245,12 @@ class CronService:
|
||||
|
||||
job.state.last_status = "ok"
|
||||
job.state.last_error = None
|
||||
logger.info(f"Cron: job '{job.name}' completed")
|
||||
logger.info("Cron: job '{}' completed", job.name)
|
||||
|
||||
except Exception as e:
|
||||
job.state.last_status = "error"
|
||||
job.state.last_error = str(e)
|
||||
logger.error(f"Cron: job '{job.name}' failed: {e}")
|
||||
logger.error("Cron: job '{}' failed: {}", job.name, e)
|
||||
|
||||
job.state.last_run_at_ms = start_ms
|
||||
job.updated_at_ms = _now_ms()
|
||||
@@ -266,6 +286,7 @@ class CronService:
|
||||
) -> CronJob:
|
||||
"""Add a new job."""
|
||||
store = self._load_store()
|
||||
_validate_schedule_for_add(schedule)
|
||||
now = _now_ms()
|
||||
|
||||
job = CronJob(
|
||||
@@ -290,7 +311,7 @@ class CronService:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
|
||||
logger.info(f"Cron: added job '{name}' ({job.id})")
|
||||
logger.info("Cron: added job '{}' ({})", name, job.id)
|
||||
return job
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
@@ -303,7 +324,7 @@ class CronService:
|
||||
if removed:
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
logger.info(f"Cron: removed job {job_id}")
|
||||
logger.info("Cron: removed job {}", job_id)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Heartbeat service - periodic agent wake-up to check for tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine
|
||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
# Default interval: 30 minutes
|
||||
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
|
||||
|
||||
@@ -22,45 +27,51 @@ def _is_heartbeat_empty(content: str | None) -> bool:
|
||||
"""Check if HEARTBEAT.md has no actionable content."""
|
||||
if not content:
|
||||
return True
|
||||
|
||||
|
||||
# Lines to skip: empty, headers, HTML comments, empty checkboxes
|
||||
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
|
||||
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
|
||||
continue
|
||||
return False # Found actionable content
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HeartbeatService:
|
||||
"""
|
||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
||||
|
||||
|
||||
The agent reads HEARTBEAT.md from the workspace and executes any
|
||||
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path,
|
||||
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
|
||||
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
|
||||
enabled: bool = True,
|
||||
session_manager: SessionManager | None = None,
|
||||
target_session_key: str = "telegram:239824268",
|
||||
idle_threshold_s: int = 30 * 60, # 30 minutes
|
||||
):
|
||||
self.workspace = workspace
|
||||
self.on_heartbeat = on_heartbeat
|
||||
self.interval_s = interval_s
|
||||
self.enabled = enabled
|
||||
self.session_manager = session_manager
|
||||
self.target_session_key = target_session_key
|
||||
self.idle_threshold_s = idle_threshold_s
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@property
|
||||
def heartbeat_file(self) -> Path:
|
||||
return self.workspace / "HEARTBEAT.md"
|
||||
|
||||
|
||||
def _read_heartbeat_file(self) -> str | None:
|
||||
"""Read HEARTBEAT.md content."""
|
||||
if self.heartbeat_file.exists():
|
||||
@@ -69,24 +80,28 @@ class HeartbeatService:
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the heartbeat service."""
|
||||
if not self.enabled:
|
||||
logger.info("Heartbeat disabled")
|
||||
return
|
||||
|
||||
|
||||
# Idempotent: don't create a new task if already running
|
||||
if self._task is not None and not self._task.done():
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info(f"Heartbeat started (every {self.interval_s}s)")
|
||||
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the heartbeat service."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
"""Main heartbeat loop."""
|
||||
while self._running:
|
||||
@@ -98,33 +113,68 @@ class HeartbeatService:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat error: {e}")
|
||||
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single heartbeat tick."""
|
||||
|
||||
# Check if user is idle (if session manager provided)
|
||||
if self.session_manager and self.target_session_key:
|
||||
try:
|
||||
session = self.session_manager.get_or_create(self.target_session_key)
|
||||
|
||||
# Find last real user message timestamp (exclude system-generated messages)
|
||||
# Real Telegram messages have sender_id like "239824268|username"
|
||||
# System messages (heartbeat, cron) created via process_direct have sender_id="user"
|
||||
# Old messages may not have sender_id field (backwards compat: treat as real user messages)
|
||||
last_user_timestamp = None
|
||||
for msg in reversed(session.messages):
|
||||
if msg.get("role") == "user":
|
||||
sender_id = msg.get("sender_id")
|
||||
# Skip if explicitly marked as system-generated
|
||||
if sender_id == "user":
|
||||
continue
|
||||
# Accept if no sender_id (old message) or if real user ID
|
||||
last_user_timestamp = msg.get("timestamp")
|
||||
break
|
||||
|
||||
if last_user_timestamp:
|
||||
from datetime import datetime
|
||||
last_dt = datetime.fromisoformat(last_user_timestamp)
|
||||
elapsed = (datetime.now() - last_dt).total_seconds()
|
||||
|
||||
if elapsed < self.idle_threshold_s:
|
||||
logger.debug(f"Heartbeat: user active {int(elapsed)}s ago, skipping")
|
||||
return # User is active, don't trigger heartbeat
|
||||
except Exception as e:
|
||||
logger.warning(f"Heartbeat: error checking idle state: {e}")
|
||||
# Continue with heartbeat on error (fail open)
|
||||
|
||||
# Original heartbeat logic
|
||||
content = self._read_heartbeat_file()
|
||||
|
||||
|
||||
# Skip if HEARTBEAT.md is empty or doesn't exist
|
||||
if _is_heartbeat_empty(content):
|
||||
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
|
||||
return
|
||||
|
||||
logger.info("Heartbeat: checking for tasks...")
|
||||
|
||||
|
||||
logger.info("Heartbeat: user idle, checking for tasks...")
|
||||
|
||||
if self.on_heartbeat:
|
||||
try:
|
||||
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
|
||||
|
||||
# Check if agent said "nothing to do"
|
||||
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
|
||||
logger.info("Heartbeat: OK (no action needed)")
|
||||
else:
|
||||
logger.info(f"Heartbeat: completed task")
|
||||
|
||||
# Call with suppress_output metadata
|
||||
await self.on_heartbeat(
|
||||
HEARTBEAT_PROMPT,
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
# Note: HEARTBEAT_OK check removed - suppress mode makes it unnecessary
|
||||
logger.info("Heartbeat: completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Heartbeat execution failed: {e}")
|
||||
|
||||
|
||||
async def trigger_now(self) -> str | None:
|
||||
"""Manually trigger a heartbeat."""
|
||||
if self.on_heartbeat:
|
||||
return await self.on_heartbeat(HEARTBEAT_PROMPT)
|
||||
return await self.on_heartbeat(HEARTBEAT_PROMPT, metadata={"suppress_output": True})
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""HTTP hooks server for external service integration."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import HooksConfig
|
||||
|
||||
|
||||
class HooksServer:
|
||||
"""
|
||||
HTTP server exposing a /hooks endpoint.
|
||||
|
||||
External services POST JSON messages. The server publishes them
|
||||
to the bus as InboundMessages and uses bus-level correlation
|
||||
to return the agent's response synchronously.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
config: HooksConfig,
|
||||
bus: MessageBus,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self._app = web.Application()
|
||||
self._app.router.add_post(self.config.path, self._handle_hook)
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._runner: web.AppRunner | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the HTTP server."""
|
||||
if not self.config.has_tokens:
|
||||
logger.warning("Hooks server has no tokens configured — endpoint disabled for security")
|
||||
return
|
||||
|
||||
self._runner = web.AppRunner(self._app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self.host, self.port)
|
||||
await site.start()
|
||||
logger.info(f"Hooks server listening on {self.host}:{self.port}{self.config.path}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the HTTP server."""
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
|
||||
def _resolve_auth(self, request: web.Request) -> str | None:
|
||||
"""
|
||||
Validate auth and return token name if valid, None otherwise.
|
||||
Checks Authorization: Bearer <token> and X-Hook-Token headers.
|
||||
"""
|
||||
# Try Authorization: Bearer <token>
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
else:
|
||||
# Try X-Hook-Token header
|
||||
token = request.headers.get("X-Hook-Token", "")
|
||||
|
||||
return self.config.resolve_token(token) if token else None
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""Health check endpoint — no auth required."""
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
async def _handle_hook(self, request: web.Request) -> web.Response:
|
||||
"""Handle incoming hook request."""
|
||||
# Auth check — resolve token name
|
||||
token_name = self._resolve_auth(request)
|
||||
if not token_name:
|
||||
return web.json_response({"error": "unauthorized"}, status=401)
|
||||
|
||||
# Parse body
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "invalid JSON body"}, status=400)
|
||||
|
||||
# Validate required fields
|
||||
message = body.get("message")
|
||||
if not message or not isinstance(message, str):
|
||||
return web.json_response(
|
||||
{"error": "missing or invalid 'message' field"}, status=400
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
channel = body.get("channel", "hook")
|
||||
chat_id = body.get("chat_id", token_name)
|
||||
timeout = body.get("timeout", self.config.timeout_seconds)
|
||||
|
||||
# Create correlation
|
||||
correlation_id = str(uuid.uuid4())
|
||||
|
||||
# Build InboundMessage
|
||||
msg = InboundMessage(
|
||||
channel=channel,
|
||||
sender_id=f"hook:{token_name}",
|
||||
chat_id=str(chat_id),
|
||||
content=message,
|
||||
metadata={
|
||||
"correlation_id": correlation_id,
|
||||
"hook_source": token_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Fire-and-forget mode
|
||||
if timeout == 0:
|
||||
await self.bus.publish_inbound(msg)
|
||||
return web.json_response({"ok": True}, status=202)
|
||||
|
||||
# Request-response mode
|
||||
future = self.bus.register_correlation(correlation_id)
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
try:
|
||||
response = await asyncio.wait_for(future, timeout=timeout)
|
||||
return web.json_response({"ok": True, "response": response})
|
||||
except asyncio.TimeoutError:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"agent did not respond within {timeout}s"},
|
||||
status=504,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hook processing error: {e}")
|
||||
return web.json_response({"error": "internal error"}, status=500)
|
||||
finally:
|
||||
# Clean up correlation on any failure
|
||||
self.bus.cancel_correlation(correlation_id)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
from nanobot.providers.registry import should_use_oauth_provider
|
||||
|
||||
@@ -10,6 +11,7 @@ __all__ = [
|
||||
"LLMResponse",
|
||||
"ToolCallRequest",
|
||||
"LiteLLMProvider",
|
||||
"OpenAICodexProvider",
|
||||
"AnthropicOAuthProvider",
|
||||
"create_provider",
|
||||
]
|
||||
|
||||
@@ -59,9 +59,83 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create async HTTP client."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=300.0)
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(300.0, pool=30.0),
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def _reset_client(self) -> None:
|
||||
"""Destroy and recreate the HTTP client after connection errors."""
|
||||
old = self._client
|
||||
self._client = None
|
||||
if old:
|
||||
try:
|
||||
await old.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Reset httpx client (pool recycled)")
|
||||
|
||||
async def _diagnose_connectivity(self) -> None:
|
||||
"""Run diagnostics when ConnectTimeout occurs to understand why."""
|
||||
import socket
|
||||
import asyncio
|
||||
|
||||
# 1. Raw socket test (bypasses httpx entirely)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
s = socket.create_connection(('api.anthropic.com', 443), timeout=10)
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
s.close()
|
||||
logger.warning(f"DIAG: raw socket connect OK in {elapsed:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: raw socket connect FAILED: {e}")
|
||||
|
||||
# 2. asyncio connect test (same event loop)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection('api.anthropic.com', 443),
|
||||
timeout=10.0,
|
||||
)
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
logger.warning(f"DIAG: asyncio connect OK in {elapsed:.3f}s")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: asyncio connect FAILED: {e}")
|
||||
|
||||
# 3. Fresh httpx client test (new pool)
|
||||
try:
|
||||
t0 = __import__('time').monotonic()
|
||||
async with httpx.AsyncClient(timeout=10.0) as fresh:
|
||||
r = await fresh.get('https://api.anthropic.com/')
|
||||
elapsed = __import__('time').monotonic() - t0
|
||||
logger.warning(f"DIAG: fresh httpx OK in {elapsed:.3f}s (status={r.status_code})")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: fresh httpx FAILED: {e}")
|
||||
|
||||
# 4. DNS resolution
|
||||
try:
|
||||
ips = socket.getaddrinfo('api.anthropic.com', 443)
|
||||
logger.warning(f"DIAG: DNS resolved to {len(ips)} entries, first={ips[0][4][0]}")
|
||||
except Exception as e:
|
||||
logger.error(f"DIAG: DNS FAILED: {e}")
|
||||
|
||||
# 5. Connection pool state of the broken client
|
||||
if self._client:
|
||||
transport = self._client._transport
|
||||
if hasattr(transport, '_pool'):
|
||||
pool = transport._pool
|
||||
conns = getattr(pool, '_connections', [])
|
||||
reqs = getattr(pool, '_requests', [])
|
||||
logger.warning(
|
||||
f"DIAG: pool state: {len(conns)} connections, "
|
||||
f"{len(reqs)} pending requests"
|
||||
)
|
||||
for i, conn in enumerate(conns[:5]):
|
||||
state = getattr(conn, '_state', 'unknown')
|
||||
logger.warning(f"DIAG: conn[{i}] state={state}")
|
||||
|
||||
def _prepare_messages(
|
||||
self,
|
||||
messages: list[dict[str, Any]]
|
||||
@@ -199,21 +273,41 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
|
||||
def _convert_tools_to_anthropic(
|
||||
self,
|
||||
tools: list[dict[str, Any]] | None
|
||||
tools: list[dict[str, Any]] | list[Any] | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Convert OpenAI-format tools to Anthropic format."""
|
||||
"""Convert tools to Anthropic API format.
|
||||
|
||||
Supports both function tools (custom) and native tools (Anthropic).
|
||||
Function tools are converted to Anthropic format.
|
||||
Native tools are passed through unchanged.
|
||||
Tool objects (with to_params/to_schema methods) are converted to dicts.
|
||||
"""
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function":
|
||||
func = tool["function"]
|
||||
# Convert tool objects to dicts first
|
||||
if hasattr(tool, 'to_params'): # Native Anthropic tool
|
||||
tool_dict = tool.to_params()
|
||||
elif hasattr(tool, 'to_schema'): # Function tool
|
||||
tool_dict = tool.to_schema()
|
||||
else:
|
||||
tool_dict = tool # Already a dict
|
||||
|
||||
# Now process the dict
|
||||
if tool_dict.get("type") == "function":
|
||||
# Convert function tool format
|
||||
func = tool_dict["function"]
|
||||
anthropic_tools.append({
|
||||
"name": func["name"],
|
||||
"description": func.get("description", ""),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||
})
|
||||
else:
|
||||
# Pass through native tool format as-is
|
||||
# (bash_20250124, text_editor_20250728, computer_20251124, etc.)
|
||||
anthropic_tools.append(tool_dict)
|
||||
|
||||
return anthropic_tools if anthropic_tools else None
|
||||
|
||||
@@ -226,10 +320,42 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
temperature: float = 0.7,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
thinking_budget_override: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
beta_flags: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make request to Anthropic API."""
|
||||
client = await self._get_client()
|
||||
|
||||
# Add cache breakpoints on the last TWO user messages (4-breakpoint strategy):
|
||||
# BP3: Second-to-last user message (stable history from previous turn)
|
||||
# BP4: Last user message (current turn, will become BP3 next turn)
|
||||
# This allows BP3 to reuse what BP4 cached last turn.
|
||||
user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
|
||||
|
||||
if len(user_indices) >= 2:
|
||||
# BP3: Second-to-last user message
|
||||
idx = user_indices[-2]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
if len(user_indices) >= 1:
|
||||
# BP4: Last user message
|
||||
idx = user_indices[-1]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -251,30 +377,85 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
payload["temperature"] = temperature
|
||||
|
||||
if system:
|
||||
payload["system"] = system
|
||||
payload["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
cached_tools = list(tools)
|
||||
cached_tools[-1] = {**cached_tools[-1], "cache_control": {"type": "ephemeral", "ttl": "1h"}}
|
||||
payload["tools"] = cached_tools
|
||||
|
||||
if context_management:
|
||||
payload["context_management"] = context_management
|
||||
|
||||
edit_types = [e.get("type") for e in (context_management or {}).get("edits", [])]
|
||||
|
||||
# Build headers with beta flags if provided
|
||||
headers = self._get_headers()
|
||||
if beta_flags:
|
||||
# Merge with existing beta header (from OAuth hardcoded flags)
|
||||
existing_beta = headers.get("anthropic-beta", "")
|
||||
existing_flags = set(existing_beta.split(",")) if existing_beta else set()
|
||||
all_flags = existing_flags | beta_flags
|
||||
headers["anthropic-beta"] = ",".join(sorted(all_flags))
|
||||
|
||||
logger.info(
|
||||
"Anthropic request: model={} max_tokens={} thinking={} tools={}",
|
||||
"Anthropic request: model={} max_tokens={} thinking={} tools={} context_mgmt={} beta={}",
|
||||
payload.get("model"), payload.get("max_tokens"),
|
||||
payload.get("thinking", "disabled"),
|
||||
len(payload.get("tools", [])),
|
||||
edit_types or "none",
|
||||
headers.get("anthropic-beta", "none"),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
)
|
||||
# Debug: Log tool names for diagnostic purposes
|
||||
if payload.get("tools"):
|
||||
tool_names = [t.get("name", "unnamed") for t in payload["tools"]]
|
||||
logger.debug(f"Tool names in request: {tool_names}")
|
||||
|
||||
# Debug: Log message structure to diagnose orphaned tool_result errors
|
||||
for idx, m in enumerate(payload.get("messages", [])):
|
||||
role = m.get("role", "?")
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
block_types = [b.get("type", "?") for b in content]
|
||||
logger.debug(f" msg[{idx}] role={role} blocks={block_types}")
|
||||
else:
|
||||
logger.debug(f" msg[{idx}] role={role} text={str(content)[:80]}")
|
||||
|
||||
import asyncio
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
try:
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
except httpx.ConnectTimeout:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"ConnectTimeout after {elapsed:.1f}s — running diagnostics")
|
||||
await self._diagnose_connectivity()
|
||||
await self._reset_client()
|
||||
raise
|
||||
except httpx.PoolTimeout:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"PoolTimeout after {elapsed:.1f}s — resetting client")
|
||||
await self._reset_client()
|
||||
raise
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
elapsed = _time.monotonic() - _t0
|
||||
logger.error(f"{type(e).__name__} after {elapsed:.1f}s")
|
||||
raise
|
||||
elapsed = _time.monotonic() - _t0
|
||||
if elapsed > 30:
|
||||
logger.warning(f"Anthropic API slow response: {elapsed:.1f}s")
|
||||
|
||||
# Dump rate limit headers for analysis
|
||||
try:
|
||||
import datetime
|
||||
import os
|
||||
header_dump = {
|
||||
"timestamp": datetime.datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
"status_code": response.status_code,
|
||||
"model": payload.get("model"),
|
||||
"headers": dict(response.headers),
|
||||
@@ -311,11 +492,12 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tools: list[dict[str, Any]] | list[Any] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Send chat completion request to Anthropic API."""
|
||||
model = model or self.default_model
|
||||
@@ -328,6 +510,17 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
model = self._normalize_model(model)
|
||||
|
||||
system, prepared_messages = self._prepare_messages(messages)
|
||||
|
||||
# Collect beta flags from native tools BEFORE conversion
|
||||
beta_flags: set[str] = set()
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if hasattr(tool, 'beta_flag') and tool.beta_flag:
|
||||
beta_flags.add(tool.beta_flag)
|
||||
|
||||
logger.debug(f"Beta flags collected: {beta_flags} (from {len(tools) if tools else 0} tools)")
|
||||
|
||||
# Convert tools to API format
|
||||
anthropic_tools = self._convert_tools_to_anthropic(tools)
|
||||
|
||||
# Per-call thinking override (None = use instance default)
|
||||
@@ -342,11 +535,15 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
temperature=temperature,
|
||||
tools=anthropic_tools,
|
||||
thinking_budget_override=effective_thinking,
|
||||
context_management=context_management,
|
||||
beta_flags=beta_flags,
|
||||
)
|
||||
return self._parse_response(response)
|
||||
except Exception as e:
|
||||
logger.exception("Exception in chat():")
|
||||
error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__} (no message)"
|
||||
return LLMResponse(
|
||||
content=f"Error calling LLM: {str(e)}",
|
||||
content=f"Error calling LLM: {error_msg}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
@@ -384,13 +581,33 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
|
||||
stop_reason = response.get("stop_reason", "end_turn")
|
||||
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
|
||||
raw_usage = response.get("usage", {})
|
||||
cache_write = raw_usage.get("cache_creation_input_tokens", 0)
|
||||
cache_read = raw_usage.get("cache_read_input_tokens", 0)
|
||||
logger.info(
|
||||
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
|
||||
"input={} output={} tokens",
|
||||
"input={} output={} cache_write={} cache_read={} tokens",
|
||||
stop_reason, len(tool_calls), thinking_chars,
|
||||
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
|
||||
cache_write, cache_read,
|
||||
)
|
||||
|
||||
# Log context editing activity if any edits were applied
|
||||
if applied_edits := response.get("context_management", {}).get("applied_edits"):
|
||||
for edit in applied_edits:
|
||||
edit_type = edit.get("type", "?")
|
||||
cleared_tokens = edit.get("cleared_input_tokens", 0)
|
||||
if edit_type == "clear_tool_uses_20250919":
|
||||
logger.info(
|
||||
"Context edit: cleared {} tool uses ({} tokens)",
|
||||
edit.get("cleared_tool_uses", 0), cleared_tokens,
|
||||
)
|
||||
elif edit_type == "clear_thinking_20251015":
|
||||
logger.info(
|
||||
"Context edit: cleared {} thinking turns ({} tokens)",
|
||||
edit.get("cleared_thinking_turns", 0), cleared_tokens,
|
||||
)
|
||||
|
||||
return LLMResponse(
|
||||
content=text_content or None,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -39,6 +39,46 @@ class LLMProvider(ABC):
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace empty text content that causes provider 400 errors.
|
||||
|
||||
Empty content can appear when MCP tools return nothing. Most providers
|
||||
reject empty-string content or empty text blocks in list content.
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
|
||||
if isinstance(content, str) and not content:
|
||||
clean = dict(msg)
|
||||
clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
if isinstance(content, list):
|
||||
filtered = [
|
||||
item for item in content
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") in ("text", "input_text", "output_text")
|
||||
and not item.get("text")
|
||||
)
|
||||
]
|
||||
if len(filtered) != len(content):
|
||||
clean = dict(msg)
|
||||
if filtered:
|
||||
clean["content"] = filtered
|
||||
elif msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||
clean["content"] = None
|
||||
else:
|
||||
clean["content"] = "(empty)"
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
async def chat(
|
||||
@@ -49,6 +89,7 @@ class LLMProvider(ABC):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Send a chat completion request.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Direct OpenAI-compatible provider — bypasses LiteLLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
class CustomProvider(LLMProvider):
|
||||
|
||||
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
|
||||
|
||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model or self.default_model,
|
||||
"messages": self._sanitize_empty_content(messages),
|
||||
"max_tokens": max(1, max_tokens),
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
kwargs.update(tools=tools, tool_choice="auto")
|
||||
try:
|
||||
return self._parse(await self._client.chat.completions.create(**kwargs))
|
||||
except Exception as e:
|
||||
return LLMResponse(content=f"Error: {e}", finish_reason="error")
|
||||
|
||||
def _parse(self, response: Any) -> LLMResponse:
|
||||
choice = response.choices[0]
|
||||
msg = choice.message
|
||||
tool_calls = [
|
||||
ToolCallRequest(id=tc.id, name=tc.function.name,
|
||||
arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments)
|
||||
for tc in (msg.tool_calls or [])
|
||||
]
|
||||
u = response.usage
|
||||
return LLMResponse(
|
||||
content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
|
||||
usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {},
|
||||
reasoning_content=getattr(msg, "reasoning_content", None) or None,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""LiteLLM provider implementation for multi-provider support."""
|
||||
|
||||
import json
|
||||
import json_repair
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
@@ -11,6 +14,16 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.registry import find_by_model, find_gateway
|
||||
|
||||
|
||||
# Standard OpenAI chat-completion message keys plus reasoning_content for
|
||||
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
"""Generate a 9-char alphanumeric ID compatible with all providers (incl. Mistral)."""
|
||||
return "".join(secrets.choice(_ALNUM) for _ in range(9))
|
||||
|
||||
|
||||
class LiteLLMProvider(LLMProvider):
|
||||
"""
|
||||
LLM provider using LiteLLM for multi-provider support.
|
||||
@@ -54,6 +67,9 @@ class LiteLLMProvider(LLMProvider):
|
||||
spec = self._gateway or find_by_model(model)
|
||||
if not spec:
|
||||
return
|
||||
if not spec.env_key:
|
||||
# OAuth/provider-only specs (for example: openai_codex)
|
||||
return
|
||||
|
||||
# Gateway/local overrides existing env; standard provider doesn't
|
||||
if self._gateway:
|
||||
@@ -84,11 +100,55 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Standard mode: auto-prefix for known providers
|
||||
spec = find_by_model(model)
|
||||
if spec and spec.litellm_prefix:
|
||||
model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
|
||||
if not any(model.startswith(s) for s in spec.skip_prefixes):
|
||||
model = f"{spec.litellm_prefix}/{model}"
|
||||
|
||||
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
|
||||
"""Normalize explicit provider prefixes like `github-copilot/...`."""
|
||||
if "/" not in model:
|
||||
return model
|
||||
prefix, remainder = model.split("/", 1)
|
||||
if prefix.lower().replace("-", "_") != spec_name:
|
||||
return model
|
||||
return f"{canonical_prefix}/{remainder}"
|
||||
|
||||
def _supports_cache_control(self, model: str) -> bool:
|
||||
"""Return True when the provider supports cache_control on content blocks."""
|
||||
if self._gateway is not None:
|
||||
return self._gateway.supports_prompt_caching
|
||||
spec = find_by_model(model)
|
||||
return spec is not None and spec.supports_prompt_caching
|
||||
|
||||
def _apply_cache_control(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
|
||||
"""Return copies of messages and tools with cache_control injected."""
|
||||
new_messages = []
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
|
||||
else:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
new_messages.append({**msg, "content": new_content})
|
||||
else:
|
||||
new_messages.append(msg)
|
||||
|
||||
new_tools = tools
|
||||
if tools:
|
||||
new_tools = list(tools)
|
||||
new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
|
||||
|
||||
return new_messages, new_tools
|
||||
|
||||
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
|
||||
"""Apply model-specific parameter overrides from the registry."""
|
||||
model_lower = model.lower()
|
||||
@@ -99,6 +159,18 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs.update(overrides)
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys and ensure assistant messages have a content key."""
|
||||
sanitized = []
|
||||
for msg in messages:
|
||||
clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
|
||||
# Strict providers require "content" even when assistant only has tool_calls
|
||||
if clean.get("role") == "assistant" and "content" not in clean:
|
||||
clean["content"] = None
|
||||
sanitized.append(clean)
|
||||
return sanitized
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -107,6 +179,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
thinking_budget: int | None = None,
|
||||
context_management: dict[str, Any] | None = None, # Anthropic-only, ignored here
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Send a chat completion request via LiteLLM.
|
||||
@@ -121,11 +194,19 @@ class LiteLLMProvider(LLMProvider):
|
||||
Returns:
|
||||
LLMResponse with content and/or tool calls.
|
||||
"""
|
||||
model = self._resolve_model(model or self.default_model)
|
||||
original_model = model or self.default_model
|
||||
model = self._resolve_model(original_model)
|
||||
|
||||
if self._supports_cache_control(original_model):
|
||||
messages, tools = self._apply_cache_control(messages, tools)
|
||||
|
||||
# Clamp max_tokens to at least 1 — negative or zero values cause
|
||||
# LiteLLM to reject the request with "max_tokens must be at least 1".
|
||||
max_tokens = max(1, max_tokens)
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
@@ -170,13 +251,10 @@ class LiteLLMProvider(LLMProvider):
|
||||
# Parse arguments from JSON string if needed
|
||||
args = tc.function.arguments
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
args = {"raw": args}
|
||||
args = json_repair.loads(args)
|
||||
|
||||
tool_calls.append(ToolCallRequest(
|
||||
id=tc.id,
|
||||
id=_short_tool_id(),
|
||||
name=tc.function.name,
|
||||
arguments=args,
|
||||
))
|
||||
@@ -189,7 +267,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
"total_tokens": response.usage.total_tokens,
|
||||
}
|
||||
|
||||
reasoning_content = getattr(message, "reasoning_content", None)
|
||||
reasoning_content = getattr(message, "reasoning_content", None) or None
|
||||
|
||||
return LLMResponse(
|
||||
content=message.content,
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
# Required headers to mimic Claude Code client
|
||||
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20"
|
||||
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27"
|
||||
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
||||
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
|
||||
headers["x-app"] = "cli"
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"""OpenAI Codex Responses Provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from oauth_cli_kit import get_token as get_codex_token
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
DEFAULT_ORIGINATOR = "nanobot"
|
||||
|
||||
|
||||
class OpenAICodexProvider(LLMProvider):
|
||||
"""Use Codex OAuth to call the Responses API."""
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.7,
|
||||
) -> LLMResponse:
|
||||
model = model or self.default_model
|
||||
system_prompt, input_items = _convert_messages(messages)
|
||||
|
||||
token = await asyncio.to_thread(get_codex_token)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(model),
|
||||
"store": False,
|
||||
"stream": True,
|
||||
"instructions": system_prompt,
|
||||
"input": input_items,
|
||||
"text": {"verbosity": "medium"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"prompt_cache_key": _prompt_cache_key(messages),
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
}
|
||||
|
||||
if tools:
|
||||
body["tools"] = _convert_tools(tools)
|
||||
|
||||
url = DEFAULT_CODEX_URL
|
||||
|
||||
try:
|
||||
try:
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
|
||||
except Exception as e:
|
||||
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||
raise
|
||||
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
except Exception as e:
|
||||
return LLMResponse(
|
||||
content=f"Error calling Codex: {str(e)}",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
return self.default_model
|
||||
|
||||
|
||||
def _strip_model_prefix(model: str) -> str:
|
||||
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
||||
return model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
def _build_headers(account_id: str, token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": DEFAULT_ORIGINATOR,
|
||||
"User-Agent": "nanobot (python)",
|
||||
"accept": "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def _request_codex(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
) -> tuple[str, list[ToolCallRequest], str]:
|
||||
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
|
||||
return await _consume_sse(response)
|
||||
|
||||
|
||||
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Convert OpenAI function-calling schema to Codex flat format."""
|
||||
converted: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
|
||||
name = fn.get("name")
|
||||
if not name:
|
||||
continue
|
||||
params = fn.get("parameters") or {}
|
||||
converted.append({
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"description": fn.get("description") or "",
|
||||
"parameters": params if isinstance(params, dict) else {},
|
||||
})
|
||||
return converted
|
||||
|
||||
|
||||
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
system_prompt = ""
|
||||
input_items: list[dict[str, Any]] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
if role == "system":
|
||||
system_prompt = content if isinstance(content, str) else ""
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
input_items.append(_convert_user_message(content))
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Handle text first.
|
||||
if isinstance(content, str) and content:
|
||||
input_items.append(
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}],
|
||||
"status": "completed",
|
||||
"id": f"msg_{idx}",
|
||||
}
|
||||
)
|
||||
# Then handle tool calls.
|
||||
for tool_call in msg.get("tool_calls", []) or []:
|
||||
fn = tool_call.get("function") or {}
|
||||
call_id, item_id = _split_tool_call_id(tool_call.get("id"))
|
||||
call_id = call_id or f"call_{idx}"
|
||||
item_id = item_id or f"fc_{idx}"
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": item_id,
|
||||
"call_id": call_id,
|
||||
"name": fn.get("name"),
|
||||
"arguments": fn.get("arguments") or "{}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
|
||||
output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": output_text,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
return system_prompt, input_items
|
||||
|
||||
|
||||
def _convert_user_message(content: Any) -> dict[str, Any]:
|
||||
if isinstance(content, str):
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
|
||||
if isinstance(content, list):
|
||||
converted: list[dict[str, Any]] = []
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text":
|
||||
converted.append({"type": "input_text", "text": item.get("text", "")})
|
||||
elif item.get("type") == "image_url":
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if url:
|
||||
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
|
||||
if converted:
|
||||
return {"role": "user", "content": converted}
|
||||
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
|
||||
|
||||
|
||||
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
|
||||
if isinstance(tool_call_id, str) and tool_call_id:
|
||||
if "|" in tool_call_id:
|
||||
call_id, item_id = tool_call_id.split("|", 1)
|
||||
return call_id, item_id or None
|
||||
return tool_call_id, None
|
||||
return "call_0", None
|
||||
|
||||
|
||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
||||
buffer: list[str] = []
|
||||
async for line in response.aiter_lines():
|
||||
if line == "":
|
||||
if buffer:
|
||||
data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
|
||||
buffer = []
|
||||
if not data_lines:
|
||||
continue
|
||||
data = "\n".join(data_lines).strip()
|
||||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
continue
|
||||
buffer.append(line)
|
||||
|
||||
|
||||
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
|
||||
content = ""
|
||||
tool_calls: list[ToolCallRequest] = []
|
||||
tool_call_buffers: dict[str, dict[str, Any]] = {}
|
||||
finish_reason = "stop"
|
||||
|
||||
async for event in _iter_sse(response):
|
||||
event_type = event.get("type")
|
||||
if event_type == "response.output_item.added":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call":
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
tool_call_buffers[call_id] = {
|
||||
"id": item.get("id") or "fc_0",
|
||||
"name": item.get("name"),
|
||||
"arguments": item.get("arguments") or "",
|
||||
}
|
||||
elif event_type == "response.output_text.delta":
|
||||
content += event.get("delta") or ""
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
|
||||
elif event_type == "response.function_call_arguments.done":
|
||||
call_id = event.get("call_id")
|
||||
if call_id and call_id in tool_call_buffers:
|
||||
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
|
||||
elif event_type == "response.output_item.done":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "function_call":
|
||||
call_id = item.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
buf = tool_call_buffers.get(call_id) or {}
|
||||
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
|
||||
try:
|
||||
args = json.loads(args_raw)
|
||||
except Exception:
|
||||
args = {"raw": args_raw}
|
||||
tool_calls.append(
|
||||
ToolCallRequest(
|
||||
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
|
||||
name=buf.get("name") or item.get("name"),
|
||||
arguments=args,
|
||||
)
|
||||
)
|
||||
elif event_type == "response.completed":
|
||||
status = (event.get("response") or {}).get("status")
|
||||
finish_reason = _map_finish_reason(status)
|
||||
elif event_type in {"error", "response.failed"}:
|
||||
raise RuntimeError("Codex response failed")
|
||||
|
||||
return content, tool_calls, finish_reason
|
||||
|
||||
|
||||
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
|
||||
|
||||
|
||||
def _map_finish_reason(status: str | None) -> str:
|
||||
return _FINISH_REASON_MAP.get(status or "completed", "stop")
|
||||
|
||||
|
||||
def _friendly_error(status_code: int, raw: str) -> str:
|
||||
if status_code == 429:
|
||||
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
|
||||
return f"HTTP {status_code}: {raw}"
|
||||
@@ -51,6 +51,15 @@ class ProviderSpec:
|
||||
# per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
|
||||
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
|
||||
|
||||
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
|
||||
is_oauth: bool = False # if True, uses OAuth flow instead of API key
|
||||
|
||||
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
|
||||
is_direct: bool = False
|
||||
|
||||
# Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
|
||||
supports_prompt_caching: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.display_name or self.name.title()
|
||||
@@ -62,6 +71,16 @@ class ProviderSpec:
|
||||
|
||||
PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
|
||||
# === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ======
|
||||
ProviderSpec(
|
||||
name="custom",
|
||||
keywords=(),
|
||||
env_key="",
|
||||
display_name="Custom",
|
||||
litellm_prefix="",
|
||||
is_direct=True,
|
||||
),
|
||||
|
||||
# === Gateways (detected by api_key / api_base, not model name) =========
|
||||
# Gateways can route any model, so they win in fallback.
|
||||
|
||||
@@ -81,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="https://openrouter.ai/api/v1",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
supports_prompt_caching=True,
|
||||
),
|
||||
|
||||
# AiHubMix: global gateway, OpenAI-compatible interface.
|
||||
@@ -103,6 +123,42 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix
|
||||
ProviderSpec(
|
||||
name="siliconflow",
|
||||
keywords=("siliconflow",),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="SiliconFlow",
|
||||
litellm_prefix="openai",
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="siliconflow",
|
||||
default_api_base="https://api.siliconflow.cn/v1",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# VolcEngine (火山引擎): OpenAI-compatible gateway
|
||||
ProviderSpec(
|
||||
name="volcengine",
|
||||
keywords=("volcengine", "volces", "ark"),
|
||||
env_key="OPENAI_API_KEY",
|
||||
display_name="VolcEngine",
|
||||
litellm_prefix="volcengine",
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=True,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="volces",
|
||||
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# === Standard providers (matched by model-name keywords) ===============
|
||||
|
||||
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
|
||||
@@ -121,6 +177,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
default_api_base="",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
supports_prompt_caching=True,
|
||||
),
|
||||
|
||||
# OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
|
||||
@@ -141,6 +198,44 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
model_overrides=(),
|
||||
),
|
||||
|
||||
# OpenAI Codex: uses OAuth, not API key.
|
||||
ProviderSpec(
|
||||
name="openai_codex",
|
||||
keywords=("openai-codex", "codex"),
|
||||
env_key="", # OAuth-based, no API key
|
||||
display_name="OpenAI Codex",
|
||||
litellm_prefix="", # Not routed through LiteLLM
|
||||
skip_prefixes=(),
|
||||
env_extras=(),
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="codex",
|
||||
default_api_base="https://chatgpt.com/backend-api",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
is_oauth=True, # OAuth-based authentication
|
||||
),
|
||||
|
||||
# Github Copilot: uses OAuth, not API key.
|
||||
ProviderSpec(
|
||||
name="github_copilot",
|
||||
keywords=("github_copilot", "copilot"),
|
||||
env_key="", # OAuth-based, no API key
|
||||
display_name="Github Copilot",
|
||||
litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model
|
||||
skip_prefixes=("github_copilot/",),
|
||||
env_extras=(),
|
||||
is_gateway=False,
|
||||
is_local=False,
|
||||
detect_by_key_prefix="",
|
||||
detect_by_base_keyword="",
|
||||
default_api_base="",
|
||||
strip_model_prefix=False,
|
||||
model_overrides=(),
|
||||
is_oauth=True, # OAuth-based authentication
|
||||
),
|
||||
|
||||
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
|
||||
ProviderSpec(
|
||||
name="deepseek",
|
||||
@@ -312,10 +407,18 @@ def find_by_model(model: str) -> ProviderSpec | None:
|
||||
"""Match a standard provider by model-name keyword (case-insensitive).
|
||||
Skips gateways/local — those are matched by api_key/api_base instead."""
|
||||
model_lower = model.lower()
|
||||
for spec in PROVIDERS:
|
||||
if spec.is_gateway or spec.is_local:
|
||||
continue
|
||||
if any(kw in model_lower for kw in spec.keywords):
|
||||
model_normalized = model_lower.replace("-", "_")
|
||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||
normalized_prefix = model_prefix.replace("-", "_")
|
||||
std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
|
||||
|
||||
# Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex.
|
||||
for spec in std_specs:
|
||||
if model_prefix and normalized_prefix == spec.name:
|
||||
return spec
|
||||
|
||||
for spec in std_specs:
|
||||
if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class GroqTranscriptionProvider:
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error(f"Audio file not found: {file_path}")
|
||||
logger.error("Audio file not found: {}", file_path)
|
||||
return ""
|
||||
|
||||
try:
|
||||
@@ -61,5 +61,5 @@ class GroqTranscriptionProvider:
|
||||
return data.get("text", "")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Groq transcription error: {e}")
|
||||
logger.error("Groq transcription error: {}", e)
|
||||
return ""
|
||||
|
||||
+79
-59
@@ -1,6 +1,7 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -15,10 +16,12 @@ from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||
class Session:
|
||||
"""
|
||||
A conversation session.
|
||||
|
||||
|
||||
Stores messages in JSONL format for easy reading and persistence.
|
||||
|
||||
Messages are trimmed after consolidation to keep session size manageable.
|
||||
"""
|
||||
|
||||
|
||||
key: str # channel:chat_id
|
||||
messages: list[dict[str, Any]] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -35,25 +38,46 @@ class Session:
|
||||
}
|
||||
self.messages.append(msg)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
|
||||
|
||||
def add_raw_message(self, msg: dict[str, Any]) -> None:
|
||||
"""Add a pre-formed message dict to the session, preserving all fields."""
|
||||
stored = dict(msg)
|
||||
if "timestamp" not in stored:
|
||||
stored["timestamp"] = datetime.now().isoformat()
|
||||
self.messages.append(stored)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
# Fields that are valid in the Anthropic/OpenAI messages API.
|
||||
# Everything else (timestamp, tools_used, etc.) is internal metadata.
|
||||
_API_FIELDS = {"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"}
|
||||
|
||||
def get_history(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get message history for LLM context.
|
||||
|
||||
Args:
|
||||
max_messages: Maximum messages to return.
|
||||
|
||||
Get full message history for LLM context.
|
||||
|
||||
The server-side context editing API (clear_tool_uses_20250919) handles
|
||||
trimming old tool chains safely at token thresholds, so we send the full
|
||||
history and let the server decide what to drop.
|
||||
|
||||
Messages with ``_hidden_sig`` get a ``[HIDDEN:{sig}]`` prefix applied to
|
||||
their content so the model knows the user never saw them. The prefix is
|
||||
applied at read time (not stored in content) to preserve prompt-cache
|
||||
stability: the same prefixed string is produced every turn.
|
||||
|
||||
Returns:
|
||||
List of messages in LLM format.
|
||||
List of messages in LLM format (API-relevant fields only).
|
||||
"""
|
||||
# Get recent messages
|
||||
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
|
||||
|
||||
# Convert to LLM format (just role and content)
|
||||
return [{"role": m["role"], "content": m["content"]} for m in recent]
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in self.messages:
|
||||
msg = {k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
|
||||
sig = m.get("_hidden_sig")
|
||||
if sig and isinstance(msg.get("content"), str):
|
||||
msg["content"] = f"[HIDDEN:{sig}] {msg['content']}"
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages in the session."""
|
||||
"""Clear all messages and reset session to initial state."""
|
||||
self.messages = []
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
@@ -61,19 +85,25 @@ class Session:
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages conversation sessions.
|
||||
|
||||
|
||||
Sessions are stored as JSONL files in the sessions directory.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
|
||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
||||
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
|
||||
self._cache: dict[str, Session] = {}
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.sessions_dir / f"{safe_key}.jsonl"
|
||||
|
||||
def _get_legacy_session_path(self, key: str) -> Path:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
safe_key = safe_filename(key.replace(":", "_"))
|
||||
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
@@ -85,11 +115,9 @@ class SessionManager:
|
||||
Returns:
|
||||
The session.
|
||||
"""
|
||||
# Check cache
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# Try to load from disk
|
||||
session = self._load(key)
|
||||
if session is None:
|
||||
session = Session(key=key)
|
||||
@@ -100,78 +128,69 @@ class SessionManager:
|
||||
def _load(self, key: str) -> Session | None:
|
||||
"""Load a session from disk."""
|
||||
path = self._get_session_path(key)
|
||||
|
||||
if not path.exists():
|
||||
legacy_path = self._get_legacy_session_path(key)
|
||||
if legacy_path.exists():
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(path))
|
||||
logger.info("Migrated session {} from legacy path", key)
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate session {}", key)
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
messages = []
|
||||
metadata = {}
|
||||
created_at = None
|
||||
|
||||
with open(path) as f:
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
|
||||
data = json.loads(line)
|
||||
|
||||
|
||||
if data.get("_type") == "metadata":
|
||||
metadata = data.get("metadata", {})
|
||||
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
|
||||
# Ignore legacy last_consolidated field
|
||||
else:
|
||||
messages.append(data)
|
||||
|
||||
|
||||
return Session(
|
||||
key=key,
|
||||
messages=messages,
|
||||
created_at=created_at or datetime.now(),
|
||||
metadata=metadata
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load session {key}: {e}")
|
||||
logger.warning("Failed to load session {}: {}", key, e)
|
||||
return None
|
||||
|
||||
def save(self, session: Session) -> None:
|
||||
"""Save a session to disk."""
|
||||
path = self._get_session_path(session.key)
|
||||
|
||||
with open(path, "w") as f:
|
||||
# Write metadata first
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
metadata_line = {
|
||||
"_type": "metadata",
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": session.updated_at.isoformat(),
|
||||
"metadata": session.metadata
|
||||
"metadata": session.metadata,
|
||||
}
|
||||
f.write(json.dumps(metadata_line) + "\n")
|
||||
|
||||
# Write messages
|
||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||
for msg in session.messages:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
|
||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||
|
||||
self._cache[session.key] = session
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
key: Session key.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found.
|
||||
"""
|
||||
# Remove from cache
|
||||
def invalidate(self, key: str) -> None:
|
||||
"""Remove a session from the in-memory cache."""
|
||||
self._cache.pop(key, None)
|
||||
|
||||
# Remove file
|
||||
path = self._get_session_path(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_sessions(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
@@ -185,13 +204,14 @@ class SessionManager:
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
try:
|
||||
# Read just the metadata line
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line:
|
||||
data = json.loads(first_line)
|
||||
if data.get("_type") == "metadata":
|
||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||
sessions.append({
|
||||
"key": path.stem.replace("_", ":"),
|
||||
"key": key,
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"path": str(path)
|
||||
|
||||
@@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
|
||||
| `weather` | Get weather info using wttr.in and Open-Meteo |
|
||||
| `summarize` | Summarize URLs, files, and YouTube videos |
|
||||
| `tmux` | Remote-control tmux sessions |
|
||||
| `clawhub` | Search and install skills from ClawHub registry |
|
||||
| `skill-creator` | Create new skills |
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: clawhub
|
||||
description: Search and install agent skills from ClawHub, the public skill registry.
|
||||
homepage: https://clawhub.ai
|
||||
metadata: {"nanobot":{"emoji":"🦞"}}
|
||||
---
|
||||
|
||||
# ClawHub
|
||||
|
||||
Public skill registry for AI agents. Search by natural language (vector search).
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill when the user asks any of:
|
||||
- "find a skill for …"
|
||||
- "search for skills"
|
||||
- "install a skill"
|
||||
- "what skills are available?"
|
||||
- "update my skills"
|
||||
|
||||
## Search
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest search "web scraping" --limit 5
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest install <slug> --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
Replace `<slug>` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
## List installed
|
||||
|
||||
```bash
|
||||
npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires Node.js (`npx` comes with it).
|
||||
- No API key needed for search and install.
|
||||
- Login (`npx --yes clawhub@latest login`) is only required for publishing.
|
||||
- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
|
||||
- After install, remind the user to start a new session to load the skill.
|
||||
@@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
|
||||
cron(action="add", message="Remind me about the meeting", at="<ISO datetime>")
|
||||
```
|
||||
|
||||
Timezone-aware cron:
|
||||
```
|
||||
cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
|
||||
```
|
||||
|
||||
List/remove:
|
||||
```
|
||||
cron(action="list")
|
||||
@@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123")
|
||||
| every hour | every_seconds: 3600 |
|
||||
| every day at 8am | cron_expr: "0 8 * * *" |
|
||||
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
|
||||
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
|
||||
| at a specific time | at: ISO datetime string (compute from current time) |
|
||||
|
||||
## Timezone
|
||||
|
||||
Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
|
||||
|
||||
@@ -9,7 +9,7 @@ always: true
|
||||
## Structure
|
||||
|
||||
- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context.
|
||||
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep.
|
||||
- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM].
|
||||
|
||||
## Search Past Events
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Instructions
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## Scheduled Reminders
|
||||
|
||||
When user asks for a reminder at a specific time, use `exec` to run:
|
||||
```
|
||||
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
|
||||
```
|
||||
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||
|
||||
## Heartbeat Tasks
|
||||
|
||||
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
|
||||
|
||||
- **Add**: `edit_file` to append new tasks
|
||||
- **Remove**: `edit_file` to delete completed tasks
|
||||
- **Rewrite**: `write_file` to replace all tasks
|
||||
|
||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Tool Usage Notes
|
||||
|
||||
Tool signatures are provided automatically via function calling.
|
||||
This file documents non-obvious constraints and usage patterns.
|
||||
|
||||
## exec — Safety Limits
|
||||
|
||||
- Commands have a configurable timeout (default 60s)
|
||||
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
|
||||
- Output is truncated at 10,000 characters
|
||||
- `restrictToWorkspace` config can limit file access to the workspace
|
||||
|
||||
## cron — Scheduled Reminders
|
||||
|
||||
- Please refer to cron skill for usage.
|
||||
+40
-53
@@ -1,80 +1,67 @@
|
||||
"""Utility functions for nanobot."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
"""Ensure a directory exists, creating it if necessary."""
|
||||
"""Ensure directory exists, return it."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_data_path() -> Path:
|
||||
"""Get the nanobot data directory (~/.nanobot)."""
|
||||
"""~/.nanobot data directory."""
|
||||
return ensure_dir(Path.home() / ".nanobot")
|
||||
|
||||
|
||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||
"""
|
||||
Get the workspace path.
|
||||
|
||||
Args:
|
||||
workspace: Optional workspace path. Defaults to ~/.nanobot/workspace.
|
||||
|
||||
Returns:
|
||||
Expanded and ensured workspace path.
|
||||
"""
|
||||
if workspace:
|
||||
path = Path(workspace).expanduser()
|
||||
else:
|
||||
path = Path.home() / ".nanobot" / "workspace"
|
||||
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
|
||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||
return ensure_dir(path)
|
||||
|
||||
|
||||
def get_sessions_path() -> Path:
|
||||
"""Get the sessions storage directory."""
|
||||
return ensure_dir(get_data_path() / "sessions")
|
||||
|
||||
|
||||
def get_skills_path(workspace: Path | None = None) -> Path:
|
||||
"""Get the skills directory within the workspace."""
|
||||
ws = workspace or get_workspace_path()
|
||||
return ensure_dir(ws / "skills")
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
"""Get current timestamp in ISO format."""
|
||||
"""Current ISO timestamp."""
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str:
|
||||
"""Truncate a string to max length, adding suffix if truncated."""
|
||||
if len(s) <= max_len:
|
||||
return s
|
||||
return s[: max_len - len(suffix)] + suffix
|
||||
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
"""Convert a string to a safe filename."""
|
||||
# Replace unsafe characters
|
||||
unsafe = '<>:"/\\|?*'
|
||||
for char in unsafe:
|
||||
name = name.replace(char, "_")
|
||||
return name.strip()
|
||||
"""Replace unsafe path characters with underscores."""
|
||||
return _UNSAFE_CHARS.sub("_", name).strip()
|
||||
|
||||
|
||||
def parse_session_key(key: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse a session key into channel and chat_id.
|
||||
|
||||
Args:
|
||||
key: Session key in format "channel:chat_id"
|
||||
|
||||
Returns:
|
||||
Tuple of (channel, chat_id)
|
||||
"""
|
||||
parts = key.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid session key: {key}")
|
||||
return parts[0], parts[1]
|
||||
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
||||
"""Sync bundled templates to workspace. Only creates missing files."""
|
||||
from importlib.resources import files as pkg_files
|
||||
try:
|
||||
tpl = pkg_files("nanobot") / "templates"
|
||||
except Exception:
|
||||
return []
|
||||
if not tpl.is_dir():
|
||||
return []
|
||||
|
||||
added: list[str] = []
|
||||
|
||||
def _write(src, dest: Path):
|
||||
if dest.exists():
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
|
||||
added.append(str(dest.relative_to(workspace)))
|
||||
|
||||
for item in tpl.iterdir():
|
||||
if item.name.endswith(".md"):
|
||||
_write(item, workspace / item.name)
|
||||
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
|
||||
_write(None, workspace / "memory" / "HISTORY.md")
|
||||
(workspace / "skills").mkdir(exist_ok=True)
|
||||
|
||||
if added and not silent:
|
||||
from rich.console import Console
|
||||
for name in added:
|
||||
Console().print(f" [dim]Created {name}[/dim]")
|
||||
return added
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies = [
|
||||
"qq-botpy>=1.0.0",
|
||||
"python-socks[asyncio]>=2.4.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
"vncdotool>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -46,6 +47,14 @@ dev = [
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
mem0 = [
|
||||
"mem0ai>=0.1.0",
|
||||
]
|
||||
matrix = [
|
||||
"matrix-nio>=0.20.0",
|
||||
"mistune>=3.0.0",
|
||||
"nh3>=0.2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nanobot = "nanobot.cli.commands:app"
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# test-pr.sh - Quick PR testing script for nanobot staging
|
||||
#
|
||||
# Usage: ./test-pr.sh <pr-number> [test-message]
|
||||
# Example: ./test-pr.sh 31 "test tool use feature"
|
||||
|
||||
set -e
|
||||
|
||||
PR_NUM="$1"
|
||||
TEST_MSG="${2:-Hello, testing PR #$PR_NUM}"
|
||||
REPO_DIR="/config/workspace/nanobot-oauth-port/nanobot-fork"
|
||||
STAGING_CONFIG="/config/workspace/.nanobot-staging/config.json"
|
||||
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
echo "Usage: $0 <pr-number> [test-message]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Fetching PR #$PR_NUM..."
|
||||
cd "$REPO_DIR"
|
||||
git fetch wylab "+pull/$PR_NUM/head:pr-$PR_NUM"
|
||||
|
||||
echo "==> Checking out pr-$PR_NUM..."
|
||||
git checkout "pr-$PR_NUM"
|
||||
|
||||
echo "==> Installing in editable mode..."
|
||||
uv pip install -e . -q
|
||||
|
||||
echo "==> Testing with message: $TEST_MSG"
|
||||
NANOBOT_CONFIG="$STAGING_CONFIG" "$REPO_DIR/.venv/bin/nanobot" agent -m "$TEST_MSG"
|
||||
|
||||
echo ""
|
||||
echo "==> Test complete. Branch pr-$PR_NUM is still checked out."
|
||||
echo " Run 'git checkout main' to return to main branch."
|
||||
@@ -0,0 +1,102 @@
|
||||
# tests/test_agent_loop_metadata.py
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_passes_metadata():
|
||||
"""Test that process_direct passes metadata to InboundMessage."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="test response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call with metadata
|
||||
test_metadata = {"suppress_output": True, "test_key": "test_value"}
|
||||
await loop.process_direct(
|
||||
content="test message",
|
||||
metadata=test_metadata
|
||||
)
|
||||
|
||||
# Verify provider.chat was called
|
||||
assert provider.chat.called
|
||||
call_args = provider.chat.call_args
|
||||
messages = call_args.kwargs["messages"]
|
||||
|
||||
# The user message should contain the content
|
||||
# (We can't easily check InboundMessage directly, but we verify
|
||||
# the flow worked by checking the session was created)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
assert len(session.messages) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suppress_mode_adds_hidden_prefix():
|
||||
"""Test that suppress_output metadata adds [HIDDEN:signature] prefix."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="This is the agent response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call with suppress_output=True
|
||||
response = await loop.process_direct(
|
||||
content="test message",
|
||||
metadata={"suppress_output": True}
|
||||
)
|
||||
|
||||
# Response content should have [HIDDEN:signature] prefix with 8-char hex signature
|
||||
assert response.startswith("[HIDDEN:")
|
||||
assert "]" in response
|
||||
# Extract signature part between [HIDDEN: and ]
|
||||
prefix_end = response.index("]")
|
||||
signature = response[8:prefix_end] # Skip "[HIDDEN:" to get signature
|
||||
assert len(signature) == 8 # 8-character hex signature
|
||||
assert all(c in "0123456789abcdef" for c in signature) # Valid hex
|
||||
assert "This is the agent response" in response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_mode_no_hidden_prefix():
|
||||
"""Test that normal messages don't get [HIDDEN:signature] prefix."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat = AsyncMock(return_value=LLMResponse(
|
||||
content="Normal response",
|
||||
tool_calls=[]
|
||||
))
|
||||
provider.get_default_model = MagicMock(return_value="test-model")
|
||||
provider.thinking_budget = 0
|
||||
|
||||
workspace = Path("/tmp/test-workspace")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
|
||||
|
||||
# Call without suppress_output
|
||||
response = await loop.process_direct(content="test message")
|
||||
|
||||
# Response should NOT have [HIDDEN:signature] prefix
|
||||
assert not response.startswith("[HIDDEN:")
|
||||
assert response == "Normal response"
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests for agent loop handling of ToolResult and CLIResult objects."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult, CLIResult
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
"""Create mock LLM provider."""
|
||||
provider = MagicMock()
|
||||
provider.chat = AsyncMock()
|
||||
provider.thinking_budget = 0
|
||||
return provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Create mock session manager."""
|
||||
session_mgr = MagicMock()
|
||||
session_mgr.load = AsyncMock(return_value={
|
||||
"messages": [],
|
||||
"metadata": {},
|
||||
})
|
||||
session_mgr.save = MagicMock() # Synchronous in production, not async
|
||||
return session_mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bus():
|
||||
"""Create mock message bus."""
|
||||
bus = MagicMock(spec=MessageBus)
|
||||
bus.publish = AsyncMock()
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_loop(mock_provider, mock_session_manager, mock_bus, tmp_path):
|
||||
"""Create agent loop for testing."""
|
||||
return AgentLoop(
|
||||
provider=mock_provider,
|
||||
session_manager=mock_session_manager,
|
||||
bus=mock_bus,
|
||||
workspace=tmp_path,
|
||||
max_iterations=5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_output(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with output field."""
|
||||
# Mock LLM responses
|
||||
mock_provider.chat.side_effect = [
|
||||
# First call: request tool
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||
),
|
||||
# Second call: final response
|
||||
LLMResponse(content="Done"),
|
||||
]
|
||||
|
||||
# Mock tool that returns ToolResult
|
||||
tool_result = ToolResult(output="Tool executed successfully")
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
# Verify tool result was added to messages
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
|
||||
# Find the tool result message
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "Tool executed successfully"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_error(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with error field."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="test_tool", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Error handled"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(error="Command failed: exit code 1")
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert "Error:" in tool_msg["content"]
|
||||
assert "Command failed: exit code 1" in tool_msg["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_with_base64_image(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with base64_image field."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Taking screenshot",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="screenshot", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Screenshot analyzed"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(
|
||||
output="Screenshot taken",
|
||||
base64_image="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
|
||||
# Should contain both text and image
|
||||
assert isinstance(tool_msg["content"], list)
|
||||
assert len(tool_msg["content"]) == 2
|
||||
|
||||
# Text content
|
||||
text_part = next(p for p in tool_msg["content"] if p["type"] == "text")
|
||||
assert text_part["text"] == "Screenshot taken"
|
||||
|
||||
# Image content
|
||||
image_part = next(p for p in tool_msg["content"] if p["type"] == "image")
|
||||
assert image_part["source"]["type"] == "base64"
|
||||
assert image_part["source"]["media_type"] == "image/png"
|
||||
assert "iVBORw0KGgoAAAANS" in image_part["source"]["data"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_result_handling(agent_loop, mock_provider):
|
||||
"""Test handling CLIResult from text editor tools."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Editing file",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="edit", arguments={})],
|
||||
),
|
||||
LLMResponse(content="File edited"),
|
||||
]
|
||||
|
||||
cli_result = CLIResult(
|
||||
exit_code=0,
|
||||
output="File updated successfully",
|
||||
error="",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=cli_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "File updated successfully"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_string_result(agent_loop, mock_provider):
|
||||
"""Test backward compatibility with string results from function tools."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Using tool",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="legacy_tool", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Done"),
|
||||
]
|
||||
|
||||
# Legacy tool returns plain string
|
||||
agent_loop.tools.execute = AsyncMock(return_value="Plain text result")
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
assert tool_msg["content"] == "Plain text result"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_result_output_and_error(agent_loop, mock_provider):
|
||||
"""Test handling ToolResult with both output and error."""
|
||||
mock_provider.chat.side_effect = [
|
||||
LLMResponse(
|
||||
content="Running command",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="bash", arguments={})],
|
||||
),
|
||||
LLMResponse(content="Handled"),
|
||||
]
|
||||
|
||||
tool_result = ToolResult(
|
||||
output="Partial output before error",
|
||||
error="Unexpected termination",
|
||||
)
|
||||
agent_loop.tools.execute = AsyncMock(return_value=tool_result)
|
||||
|
||||
message = InboundMessage(
|
||||
channel="test",
|
||||
chat_id="123",
|
||||
sender_id="user1",
|
||||
content="Test message",
|
||||
)
|
||||
|
||||
response = await agent_loop._process_message(message)
|
||||
|
||||
calls = mock_provider.chat.call_args_list
|
||||
second_call_messages = calls[1][1]["messages"]
|
||||
tool_msg = next(m for m in second_call_messages if m.get("role") == "tool")
|
||||
|
||||
# Should contain both output and error
|
||||
content = tool_msg["content"]
|
||||
assert "Partial output before error" in content
|
||||
assert "Error:" in content
|
||||
assert "Unexpected termination" in content
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for Anthropic native tool base classes."""
|
||||
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.base import (
|
||||
BaseAnthropicTool,
|
||||
ToolResult,
|
||||
CLIResult,
|
||||
ToolError,
|
||||
)
|
||||
|
||||
|
||||
class DummyTool(BaseAnthropicTool):
|
||||
"""Test tool implementation."""
|
||||
api_type = "test_20250227"
|
||||
name = "test_tool"
|
||||
beta_flag = "test-beta"
|
||||
|
||||
async def __call__(self, **kwargs):
|
||||
return ToolResult(output="test output")
|
||||
|
||||
def to_params(self):
|
||||
return {"type": self.api_type, "name": self.name}
|
||||
|
||||
|
||||
def test_tool_result_dataclass():
|
||||
"""Test ToolResult can be created with all fields."""
|
||||
result = ToolResult(output="hello", error=None, base64_image=None, system="system message")
|
||||
assert result.output == "hello"
|
||||
assert result.error is None
|
||||
assert result.base64_image is None
|
||||
assert result.system == "system message"
|
||||
|
||||
|
||||
def test_cli_result_dataclass():
|
||||
"""Test CLIResult can be created with all fields."""
|
||||
result = CLIResult(exit_code=0, output="command output", error="")
|
||||
assert result.output == "command output"
|
||||
assert result.exit_code == 0
|
||||
assert result.error == ""
|
||||
|
||||
|
||||
def test_tool_error_exception():
|
||||
"""Test ToolError can be raised and caught."""
|
||||
with pytest.raises(ToolError):
|
||||
raise ToolError("Test error message")
|
||||
|
||||
|
||||
def test_base_anthropic_tool_to_params():
|
||||
"""Test tool returns correct params format."""
|
||||
tool = DummyTool()
|
||||
params = tool.to_params()
|
||||
assert params["type"] == "test_20250227"
|
||||
assert params["name"] == "test_tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_anthropic_tool_call():
|
||||
"""Test tool can be called and returns ToolResult."""
|
||||
tool = DummyTool()
|
||||
result = await tool()
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.output == "test output"
|
||||
@@ -28,18 +28,8 @@ def test_provider_uses_bearer_auth(provider):
|
||||
assert "x-api-key" not in headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_prepends_system_prompt(provider):
|
||||
"""Chat should prepend Claude Code identity to system prompt."""
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
with patch.object(provider, "_make_request", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = {"content": [{"type": "text", "text": "Hi"}], "stop_reason": "end_turn"}
|
||||
await provider.chat(messages)
|
||||
|
||||
call_args = mock.call_args
|
||||
system = call_args[1]["system"]
|
||||
assert "Claude Code" in system
|
||||
# test_chat_prepends_system_prompt removed - feature no longer exists
|
||||
# System prompt handling is done by the agent loop, not the provider
|
||||
|
||||
|
||||
def test_parse_response_text(provider):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for native tool support in AnthropicOAuthProvider."""
|
||||
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
def test_convert_tools_passes_through_native_tools():
|
||||
"""Test that native tool format is passed through unchanged."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "bash_20250124",
|
||||
"name": "bash"
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "bash_20250124"
|
||||
assert result[0]["name"] == "bash"
|
||||
|
||||
|
||||
def test_convert_tools_handles_mixed_tool_types():
|
||||
"""Test conversion of both function and native tools."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "custom_tool",
|
||||
"description": "A custom tool",
|
||||
"parameters": {"type": "object", "properties": {"arg": {"type": "string"}}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "bash_20250124",
|
||||
"name": "bash"
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 2
|
||||
|
||||
# Function tool gets converted
|
||||
assert result[0]["name"] == "custom_tool"
|
||||
assert result[0]["description"] == "A custom tool"
|
||||
assert "input_schema" in result[0]
|
||||
|
||||
# Native tool passed through
|
||||
assert result[1]["type"] == "bash_20250124"
|
||||
assert result[1]["name"] == "bash"
|
||||
|
||||
|
||||
def test_convert_tools_preserves_function_tool_conversion():
|
||||
"""Test that existing function tool conversion still works."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test",
|
||||
"description": "desc",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
result = provider._convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "test"
|
||||
assert result[0]["description"] == "desc"
|
||||
assert result[0]["input_schema"] == {"type": "object"}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Test that bash tool handles heredoc commands correctly.
|
||||
|
||||
Reproduces the bug where `; echo '<<exit>>'` appended on the same line
|
||||
as a heredoc terminator prevents bash from recognizing the terminator,
|
||||
causing the session to hang forever.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heredoc_command():
|
||||
"""Heredoc commands must complete without hanging."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Simple command works
|
||||
result = await tool(command="echo hello")
|
||||
assert result.output == "hello"
|
||||
|
||||
# Heredoc command — this is the exact pattern that caused the hang
|
||||
result = await asyncio.wait_for(
|
||||
tool(command="cat << 'EOF'\nline1\nline2\nEOF"),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert "line1" in result.output
|
||||
assert "line2" in result.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heredoc_append_to_file():
|
||||
"""Heredoc append (the exact pattern the LLM uses) must work."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"),
|
||||
timeout=5.0,
|
||||
)
|
||||
# Should complete without error
|
||||
assert result.error is None or result.error == ""
|
||||
|
||||
# Verify the file was written
|
||||
result2 = await tool(command="cat /tmp/test_heredoc_bash.txt")
|
||||
assert "hello world" in result2.output
|
||||
|
||||
# Cleanup
|
||||
await tool(command="rm -f /tmp/test_heredoc_bash.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_commands_still_work():
|
||||
"""Ensure regular commands still work after the fix."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Semicolons in commands
|
||||
result = await tool(command="echo a; echo b")
|
||||
assert "a" in result.output
|
||||
assert "b" in result.output
|
||||
|
||||
# Multiline script
|
||||
result = await tool(command="for i in 1 2 3; do echo $i; done")
|
||||
assert "1" in result.output
|
||||
assert "3" in result.output
|
||||
|
||||
# Command with exit code
|
||||
result = await tool(command="true")
|
||||
assert result.output == "(no output)" or result.output is not None
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for BashTool20250124."""
|
||||
|
||||
import pytest
|
||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_simple_command():
|
||||
"""Test bash tool executes simple command."""
|
||||
tool = BashTool20250124()
|
||||
result = await tool(command="echo hello")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert "hello" in result.output
|
||||
assert result.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_persistent_session():
|
||||
"""Test bash tool maintains session across calls."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
result1 = await tool(command="export TEST_VAR=42")
|
||||
assert result1.error is None
|
||||
|
||||
# Read variable (should persist)
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" in result2.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_restart():
|
||||
"""Test bash tool can restart session."""
|
||||
tool = BashTool20250124()
|
||||
|
||||
# Set variable
|
||||
await tool(command="export TEST_VAR=42")
|
||||
|
||||
# Restart
|
||||
result = await tool(restart=True)
|
||||
assert "restarted" in (result.system or result.output or "").lower()
|
||||
|
||||
# Variable should be gone
|
||||
result2 = await tool(command="echo $TEST_VAR")
|
||||
assert "42" not in result2.output
|
||||
|
||||
|
||||
def test_bash_tool_to_params():
|
||||
"""Test bash tool returns correct params."""
|
||||
tool = BashTool20250124()
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "bash_20250124"
|
||||
assert params["name"] == "bash"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for beta flag collection from native tools."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_flags_collected_from_tools():
|
||||
"""Test that beta flags are extracted from tool objects."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
# Mock tool objects with beta_flag attribute and to_params method
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("computer-use-2025-11-24"),
|
||||
MockTool("computer-use-2025-11-24"), # Duplicate should be deduplicated
|
||||
]
|
||||
|
||||
# We need to test this via the actual API call flow
|
||||
# Mock httpx client
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call with messages and tools
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
# Check that beta flag was added to headers (merged with hardcoded flags)
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
# Should include hardcoded flags + tool flag, sorted alphabetically
|
||||
assert headers["anthropic-beta"] == "claude-code-20250219,computer-use-2025-11-24,context-management-2025-06-27,oauth-2025-04-20"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_beta_flags_joined():
|
||||
"""Test that multiple unique beta flags are joined with commas."""
|
||||
provider = AnthropicOAuthProvider(oauth_token="test", thinking_budget=0)
|
||||
|
||||
class MockTool:
|
||||
def __init__(self, beta_flag):
|
||||
self.beta_flag = beta_flag
|
||||
|
||||
def to_params(self):
|
||||
return {"type": "bash_20250124", "name": "bash"}
|
||||
|
||||
tools_with_flags = [
|
||||
MockTool("flag-a"),
|
||||
MockTool("flag-b"),
|
||||
]
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "test"}],
|
||||
"model": "claude-opus-4",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 10, "output_tokens": 10}
|
||||
}
|
||||
|
||||
with patch.object(provider, '_client') as mock_client:
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
await provider.chat(
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
model="claude-opus-4",
|
||||
max_tokens=100,
|
||||
tools=tools_with_flags
|
||||
)
|
||||
|
||||
call_args = mock_client.post.call_args
|
||||
headers = call_args[1]["headers"]
|
||||
assert "anthropic-beta" in headers
|
||||
# Should include hardcoded flags + tool flags, sorted alphabetically and joined with comma
|
||||
assert headers["anthropic-beta"] == "claude-code-20250219,context-management-2025-06-27,flag-a,flag-b,oauth-2025-04-20"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for bus-level correlation (request-response via Futures)."""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus():
|
||||
return MessageBus()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_correlation_returns_future(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
assert isinstance(future, asyncio.Future)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_sets_future_result(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert future.done()
|
||||
assert future.result() == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_match_is_noop(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "other-id"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_metadata_is_noop(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello")
|
||||
bus.resolve_correlation(msg)
|
||||
assert not future.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_cleans_up_store(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
msg = OutboundMessage(channel="hook", chat_id="test", content="hello", metadata={"correlation_id": "test-id-1"})
|
||||
bus.resolve_correlation(msg)
|
||||
assert "test-id-1" not in bus._correlation_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_correlation(bus):
|
||||
future = bus.register_correlation("test-id-1")
|
||||
bus.cancel_correlation("test-id-1")
|
||||
assert "test-id-1" not in bus._correlation_store
|
||||
assert future.cancelled()
|
||||
@@ -12,7 +12,8 @@ def mock_prompt_session():
|
||||
"""Mock the global prompt session."""
|
||||
mock_session = MagicMock()
|
||||
mock_session.prompt_async = AsyncMock()
|
||||
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session):
|
||||
with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
|
||||
patch("nanobot.cli.commands.patch_stdout"):
|
||||
yield mock_session
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_model
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths():
|
||||
"""Mock config/workspace paths for test isolation."""
|
||||
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
||||
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
|
||||
|
||||
base_dir = Path("./test_onboard_data")
|
||||
if base_dir.exists():
|
||||
shutil.rmtree(base_dir)
|
||||
base_dir.mkdir()
|
||||
|
||||
config_file = base_dir / "config.json"
|
||||
workspace_dir = base_dir / "workspace"
|
||||
workspace_dir.mkdir() # Create workspace directory
|
||||
|
||||
mock_cp.return_value = config_file
|
||||
mock_ws.return_value = workspace_dir
|
||||
mock_sc.side_effect = lambda config: config_file.write_text("{}")
|
||||
|
||||
yield config_file, workspace_dir
|
||||
|
||||
if base_dir.exists():
|
||||
shutil.rmtree(base_dir)
|
||||
|
||||
|
||||
def test_onboard_fresh_install(mock_paths):
|
||||
"""No existing config — should create from scratch."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Created config" in result.stdout
|
||||
assert "Created workspace" in result.stdout
|
||||
assert "nanobot is ready" in result.stdout
|
||||
assert config_file.exists()
|
||||
assert (workspace_dir / "AGENTS.md").exists()
|
||||
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
||||
|
||||
|
||||
def test_onboard_existing_config_refresh(mock_paths):
|
||||
"""Config exists, user declines overwrite — should exit without changes."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
config_file.write_text('{"existing": true}')
|
||||
|
||||
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||
|
||||
# User declined, so command exits (typer.Exit() returns 0)
|
||||
assert result.exit_code == 0
|
||||
assert "Config already exists" in result.stdout
|
||||
assert "Overwrite?" in result.stdout
|
||||
|
||||
|
||||
def test_onboard_existing_config_overwrite(mock_paths):
|
||||
"""Config exists, user confirms overwrite — should create new config."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
config_file.write_text('{"existing": true}')
|
||||
|
||||
result = runner.invoke(app, ["onboard"], input="y\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Config already exists" in result.stdout
|
||||
assert "Created config" in result.stdout
|
||||
assert workspace_dir.exists()
|
||||
|
||||
|
||||
def test_onboard_existing_workspace_safe_create(mock_paths):
|
||||
"""Workspace exists (from fixture) — should add missing templates."""
|
||||
config_file, workspace_dir = mock_paths
|
||||
# workspace_dir already exists from fixture
|
||||
# No existing config, so onboard should proceed
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Created workspace" in result.stdout
|
||||
assert "Created AGENTS.md" in result.stdout
|
||||
assert (workspace_dir / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
|
||||
|
||||
assert config.get_provider_name() == "github_copilot"
|
||||
|
||||
|
||||
def test_config_matches_openai_codex_with_hyphen_prefix():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
|
||||
|
||||
assert config.get_provider_name() == "openai_codex"
|
||||
|
||||
|
||||
def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
|
||||
spec = find_by_model("github-copilot/gpt-5.3-codex")
|
||||
|
||||
assert spec is not None
|
||||
assert spec.name == "github_copilot"
|
||||
|
||||
|
||||
def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
|
||||
provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex")
|
||||
|
||||
resolved = provider._resolve_model("github-copilot/gpt-5.3-codex")
|
||||
|
||||
assert resolved == "github_copilot/gpt-5.3-codex"
|
||||
|
||||
|
||||
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for ComputerTool20251124."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from nanobot.agent.tools.anthropic.computer import ComputerTool20251124
|
||||
from nanobot.agent.tools.anthropic.base import ToolResult
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_screenshot():
|
||||
"""Test computer tool can take screenshot."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
# Mock VNC client
|
||||
with patch('nanobot.agent.tools.anthropic.computer.vnc_api.connect') as mock_connect:
|
||||
mock_client = MagicMock()
|
||||
# Mock captureScreen to write fake PNG data to file path
|
||||
def fake_capture(path):
|
||||
from pathlib import Path
|
||||
Path(path).write_bytes(b"fake_png_data")
|
||||
mock_client.captureScreen = MagicMock(side_effect=fake_capture)
|
||||
mock_client.mouseMove = MagicMock()
|
||||
mock_client.keyPress = MagicMock()
|
||||
mock_client.refreshScreen = MagicMock()
|
||||
mock_connect.return_value = mock_client
|
||||
|
||||
result = await tool(action="screenshot")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.base64_image is not None
|
||||
assert len(result.base64_image) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_mouse_move():
|
||||
"""Test computer tool can move mouse."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
with patch('nanobot.agent.tools.anthropic.computer.vnc_api.connect') as mock_connect:
|
||||
mock_client = MagicMock()
|
||||
mock_client.mouseMove = MagicMock()
|
||||
mock_connect.return_value = mock_client
|
||||
|
||||
result = await tool(action="mouse_move", coordinate=[100, 200])
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.error is None
|
||||
mock_client.mouseMove.assert_called_once_with(100, 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_tool_key():
|
||||
"""Test computer tool can press keys."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
|
||||
with patch('nanobot.agent.tools.anthropic.computer.vnc_api.connect') as mock_connect:
|
||||
mock_client = MagicMock()
|
||||
mock_client.keyPress = MagicMock()
|
||||
mock_connect.return_value = mock_client
|
||||
|
||||
result = await tool(action="key", text="Return")
|
||||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.error is None
|
||||
# Implementation converts keys to lowercase
|
||||
mock_client.keyPress.assert_called_once_with("return")
|
||||
|
||||
|
||||
def test_computer_tool_to_params():
|
||||
"""Test computer tool returns correct params."""
|
||||
tool = ComputerTool20251124(vnc_host="localhost", vnc_port=5900)
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "computer_20251124"
|
||||
assert params["name"] == "computer"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for config loader (get_config_path and _migrate_config)"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.loader import get_config_path, _migrate_config
|
||||
|
||||
|
||||
def test_get_config_path_default():
|
||||
"""get_config_path returns ~/.nanobot/config.json by default"""
|
||||
# Ensure NANOBOT_CONFIG is not set
|
||||
env_backup = os.environ.pop("NANOBOT_CONFIG", None)
|
||||
try:
|
||||
path = get_config_path()
|
||||
assert path == Path.home() / ".nanobot" / "config.json"
|
||||
finally:
|
||||
if env_backup:
|
||||
os.environ["NANOBOT_CONFIG"] = env_backup
|
||||
|
||||
|
||||
def test_get_config_path_with_env_var():
|
||||
"""get_config_path uses NANOBOT_CONFIG env var when set"""
|
||||
custom_path = "/tmp/test-nanobot-config.json"
|
||||
env_backup = os.environ.get("NANOBOT_CONFIG")
|
||||
try:
|
||||
os.environ["NANOBOT_CONFIG"] = custom_path
|
||||
path = get_config_path()
|
||||
assert path == Path(custom_path)
|
||||
finally:
|
||||
if env_backup:
|
||||
os.environ["NANOBOT_CONFIG"] = env_backup
|
||||
else:
|
||||
os.environ.pop("NANOBOT_CONFIG", None)
|
||||
|
||||
|
||||
def test_migrate_config_with_oauth_credentials():
|
||||
"""_migrate_config extracts api_key from oauthCredentials"""
|
||||
data = {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"oauthCredentials": {
|
||||
"access_token": "sk-ant-test-token",
|
||||
"refresh_token": "",
|
||||
"expires_at": 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = _migrate_config(data)
|
||||
|
||||
# api_key should be extracted
|
||||
assert result["providers"]["anthropic"]["api_key"] == "sk-ant-test-token"
|
||||
# oauthCredentials should be removed after migration
|
||||
assert "oauthCredentials" not in result["providers"]["anthropic"]
|
||||
|
||||
|
||||
def test_migrate_config_without_oauth_credentials():
|
||||
"""_migrate_config leaves config unchanged when no oauthCredentials"""
|
||||
data = {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-existing-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = _migrate_config(data)
|
||||
|
||||
# Should remain unchanged
|
||||
assert result["providers"]["anthropic"]["api_key"] == "sk-ant-existing-key"
|
||||
assert "oauthCredentials" not in result["providers"]["anthropic"]
|
||||
|
||||
|
||||
def test_migrate_config_already_migrated():
|
||||
"""_migrate_config doesn't overwrite existing api_key"""
|
||||
data = {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"api_key": "sk-ant-existing-key",
|
||||
"oauthCredentials": {
|
||||
"access_token": "sk-ant-oauth-token",
|
||||
"refresh_token": "",
|
||||
"expires_at": 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = _migrate_config(data)
|
||||
|
||||
# Existing api_key should be preserved
|
||||
assert result["providers"]["anthropic"]["api_key"] == "sk-ant-existing-key"
|
||||
# oauthCredentials should NOT be removed (api_key already existed)
|
||||
assert "oauthCredentials" in result["providers"]["anthropic"]
|
||||
|
||||
|
||||
def test_migrate_config_empty_access_token():
|
||||
"""_migrate_config skips empty access_token"""
|
||||
data = {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"oauthCredentials": {
|
||||
"access_token": "",
|
||||
"refresh_token": "",
|
||||
"expires_at": 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = _migrate_config(data)
|
||||
|
||||
# api_key should not be set
|
||||
assert "api_key" not in result["providers"]["anthropic"]
|
||||
# oauthCredentials should remain (no migration happened)
|
||||
assert "oauthCredentials" in result["providers"]["anthropic"]
|
||||
|
||||
|
||||
def test_migrate_config_preserves_other_fields():
|
||||
"""_migrate_config preserves other provider config fields"""
|
||||
data = {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"oauthCredentials": {
|
||||
"access_token": "sk-ant-test-token",
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
"customField": "customValue",
|
||||
"anotherField": 123,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = _migrate_config(data)
|
||||
|
||||
# api_key added, oauthCredentials removed
|
||||
assert result["providers"]["anthropic"]["api_key"] == "sk-ant-test-token"
|
||||
assert "oauthCredentials" not in result["providers"]["anthropic"]
|
||||
# Other fields preserved
|
||||
assert result["providers"]["anthropic"]["customField"] == "customValue"
|
||||
assert result["providers"]["anthropic"]["anotherField"] == 123
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for cache-friendly prompt construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime as real_datetime
|
||||
from pathlib import Path
|
||||
import datetime as datetime_module
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
|
||||
|
||||
class _FakeDatetime(real_datetime):
|
||||
current = real_datetime(2026, 2, 24, 13, 59)
|
||||
|
||||
@classmethod
|
||||
def now(cls, tz=None): # type: ignore[override]
|
||||
return cls.current
|
||||
|
||||
|
||||
def _make_workspace(tmp_path: Path) -> Path:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir(parents=True)
|
||||
return workspace
|
||||
|
||||
|
||||
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
|
||||
"""System prompt should not change just because wall clock minute changes."""
|
||||
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
|
||||
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
_FakeDatetime.current = real_datetime(2026, 2, 24, 13, 59)
|
||||
prompt1 = builder.build_system_prompt()
|
||||
|
||||
_FakeDatetime.current = real_datetime(2026, 2, 24, 14, 0)
|
||||
prompt2 = builder.build_system_prompt()
|
||||
|
||||
assert prompt1 == prompt2
|
||||
|
||||
|
||||
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
||||
"""Runtime metadata should be included in the system prompt."""
|
||||
workspace = _make_workspace(tmp_path)
|
||||
builder = ContextBuilder(workspace)
|
||||
|
||||
messages = builder.build_messages(
|
||||
history=[],
|
||||
current_message="Return exactly: OK",
|
||||
channel="cli",
|
||||
chat_id="direct",
|
||||
)
|
||||
|
||||
# Runtime context should be in the system prompt
|
||||
assert messages[0]["role"] == "system"
|
||||
assert "## Current Session" in messages[0]["content"]
|
||||
assert "Channel: cli" in messages[0]["content"]
|
||||
assert "Chat ID: direct" in messages[0]["content"]
|
||||
|
||||
# The actual user message should be the last message
|
||||
assert messages[-1]["role"] == "user"
|
||||
assert messages[-1]["content"] == "Return exactly: OK"
|
||||
@@ -0,0 +1,29 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"demo",
|
||||
"--message",
|
||||
"hello",
|
||||
"--cron",
|
||||
"0 9 * * *",
|
||||
"--tz",
|
||||
"America/Vancovuer",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout
|
||||
assert not (tmp_path / "cron" / "jobs.json").exists()
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
|
||||
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
|
||||
with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"):
|
||||
service.add_job(
|
||||
name="tz typo",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
assert service.list_jobs(include_disabled=True) == []
|
||||
|
||||
|
||||
def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
service = CronService(tmp_path / "cron" / "jobs.json")
|
||||
|
||||
job = service.add_job(
|
||||
name="tz ok",
|
||||
schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
|
||||
message="hello",
|
||||
)
|
||||
|
||||
assert job.schedule.tz == "America/Vancouver"
|
||||
assert job.state.next_run_at_ms is not None
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for EditTool20250728."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||
from nanobot.agent.tools.anthropic.base import CLIResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edit_tool():
|
||||
"""Create an EditTool20250728 instance."""
|
||||
return EditTool20250728()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file(tmp_path):
|
||||
"""Create a temporary file with some content."""
|
||||
file_path = tmp_path / "test.txt"
|
||||
file_path.write_text("line 1\nline 2\nline 3\n")
|
||||
return file_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_command(edit_tool, temp_file):
|
||||
"""Test viewing a file with line numbers."""
|
||||
result = await edit_tool(
|
||||
command="view",
|
||||
path=str(temp_file)
|
||||
)
|
||||
assert result.output is not None
|
||||
assert "1|line 1" in result.output
|
||||
assert "2|line 2" in result.output
|
||||
assert "3|line 3" in result.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_command(edit_tool, tmp_path):
|
||||
"""Test creating a new file."""
|
||||
new_file = tmp_path / "new.txt"
|
||||
result = await edit_tool(
|
||||
command="create",
|
||||
path=str(new_file),
|
||||
file_text="Hello\nWorld\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert new_file.exists()
|
||||
assert new_file.read_text() == "Hello\nWorld\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_str_replace_command(edit_tool, temp_file):
|
||||
"""Test replacing a unique string."""
|
||||
result = await edit_tool(
|
||||
command="str_replace",
|
||||
path=str(temp_file),
|
||||
old_str="line 2",
|
||||
new_str="LINE TWO"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = temp_file.read_text()
|
||||
assert "LINE TWO" in content
|
||||
assert "line 2" not in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_str_replace_non_unique(edit_tool, temp_file):
|
||||
"""Test that str_replace fails on non-unique match."""
|
||||
# Write content with duplicate "line"
|
||||
temp_file.write_text("line 1\nline 2\nline 3\n")
|
||||
result = await edit_tool(
|
||||
command="str_replace",
|
||||
path=str(temp_file),
|
||||
old_str="line", # This appears 3 times
|
||||
new_str="LINE"
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "must match exactly once" in result.error.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_command(edit_tool, temp_file):
|
||||
"""Test inserting text at a specific line."""
|
||||
result = await edit_tool(
|
||||
command="insert",
|
||||
path=str(temp_file),
|
||||
insert_line=1,
|
||||
new_str="inserted line\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = temp_file.read_text()
|
||||
lines = content.splitlines()
|
||||
assert lines[1] == "inserted line"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_tool_requires_absolute_path():
|
||||
"""Test edit tool rejects relative paths."""
|
||||
tool = EditTool20250728()
|
||||
|
||||
result = await tool(
|
||||
command="view",
|
||||
path="relative/path.txt"
|
||||
)
|
||||
|
||||
assert isinstance(result, CLIResult)
|
||||
assert result.exit_code == 1
|
||||
assert "absolute" in result.error.lower()
|
||||
|
||||
|
||||
def test_edit_tool_to_params():
|
||||
"""Test edit tool returns correct params."""
|
||||
tool = EditTool20250728()
|
||||
params = tool.to_params()
|
||||
|
||||
assert params["type"] == "text_editor_20250728"
|
||||
assert params["name"] == "str_replace_based_edit_tool"
|
||||
@@ -169,7 +169,8 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
"""When auto_reply_enabled=False, replies should be skipped but proactive sends allowed."""
|
||||
class FakeSMTP:
|
||||
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
|
||||
self.sent_messages: list[EmailMessage] = []
|
||||
@@ -201,6 +202,11 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
|
||||
# Mark alice as someone who sent us an email (making this a "reply")
|
||||
channel._last_subject_by_chat["alice@example.com"] = "Previous email"
|
||||
|
||||
# Reply should be skipped (auto_reply_enabled=False)
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
@@ -210,6 +216,7 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
)
|
||||
assert fake_instances == []
|
||||
|
||||
# Reply with force_send=True should be sent
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
@@ -222,6 +229,56 @@ async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
assert len(fake_instances[0].sent_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> None:
|
||||
"""Proactive emails (not replies) should be sent even when auto_reply_enabled=False."""
|
||||
class FakeSMTP:
|
||||
def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
|
||||
self.sent_messages: list[EmailMessage] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def starttls(self, context=None):
|
||||
return None
|
||||
|
||||
def login(self, _user: str, _pw: str):
|
||||
return None
|
||||
|
||||
def send_message(self, msg: EmailMessage):
|
||||
self.sent_messages.append(msg)
|
||||
|
||||
fake_instances: list[FakeSMTP] = []
|
||||
|
||||
def _smtp_factory(host: str, port: int, timeout: int = 30):
|
||||
instance = FakeSMTP(host, port, timeout=timeout)
|
||||
fake_instances.append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.auto_reply_enabled = False
|
||||
channel = EmailChannel(cfg, MessageBus())
|
||||
|
||||
# bob@example.com has never sent us an email (proactive send)
|
||||
# This should be sent even with auto_reply_enabled=False
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="email",
|
||||
chat_id="bob@example.com",
|
||||
content="Hello, this is a proactive email.",
|
||||
)
|
||||
)
|
||||
assert len(fake_instances) == 1
|
||||
assert len(fake_instances[0].sent_messages) == 1
|
||||
sent = fake_instances[0].sent_messages[0]
|
||||
assert sent["To"] == "bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
|
||||
class FakeSMTP:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# tests/test_heartbeat_idle.py
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_skips_when_user_active():
|
||||
"""Test that heartbeat doesn't trigger if user messaged recently."""
|
||||
workspace = Path("/tmp/test-heartbeat")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
# Create session with recent user message
|
||||
sessions = SessionManager(workspace)
|
||||
session = sessions.get_or_create("telegram:239824268")
|
||||
session.add_message("user", "Recent message")
|
||||
sessions.save(session)
|
||||
|
||||
# Create heartbeat callback
|
||||
callback_called = False
|
||||
async def on_heartbeat(prompt, metadata=None):
|
||||
nonlocal callback_called
|
||||
callback_called = True
|
||||
return "response"
|
||||
|
||||
# Create heartbeat service
|
||||
service = HeartbeatService(
|
||||
workspace=workspace,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=1, # Short interval for testing
|
||||
enabled=True,
|
||||
session_manager=sessions,
|
||||
target_session_key="telegram:239824268"
|
||||
)
|
||||
|
||||
# Trigger heartbeat
|
||||
await service._tick()
|
||||
|
||||
# Callback should NOT have been called (user was active recently)
|
||||
assert not callback_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_triggers_when_user_idle():
|
||||
"""Test that heartbeat triggers after 30min of inactivity."""
|
||||
workspace = Path("/tmp/test-heartbeat")
|
||||
workspace.mkdir(exist_ok=True)
|
||||
|
||||
# Create HEARTBEAT.md with content
|
||||
heartbeat_file = workspace / "HEARTBEAT.md"
|
||||
heartbeat_file.write_text("# Tasks\n- Check something\n")
|
||||
|
||||
# Create session with old user message (>30min ago)
|
||||
sessions = SessionManager(workspace)
|
||||
session = sessions.get_or_create("telegram:239824268")
|
||||
|
||||
# Manually set old timestamp
|
||||
old_timestamp = (datetime.now() - timedelta(minutes=31)).isoformat()
|
||||
session.messages.append({
|
||||
"role": "user",
|
||||
"content": "Old message",
|
||||
"timestamp": old_timestamp
|
||||
})
|
||||
sessions.save(session)
|
||||
|
||||
# Create heartbeat callback
|
||||
callback_called = False
|
||||
callback_metadata = None
|
||||
|
||||
async def on_heartbeat(prompt, metadata=None):
|
||||
nonlocal callback_called, callback_metadata
|
||||
callback_called = True
|
||||
callback_metadata = metadata
|
||||
return "response"
|
||||
|
||||
# Create heartbeat service
|
||||
service = HeartbeatService(
|
||||
workspace=workspace,
|
||||
on_heartbeat=on_heartbeat,
|
||||
interval_s=1,
|
||||
enabled=True,
|
||||
session_manager=sessions,
|
||||
target_session_key="telegram:239824268"
|
||||
)
|
||||
|
||||
# Trigger heartbeat
|
||||
await service._tick()
|
||||
|
||||
# Callback SHOULD have been called (user idle for >30min)
|
||||
assert callback_called
|
||||
assert callback_metadata == {"suppress_output": True}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user